diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 11521c9c6..a905531c2 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 574b1ab73..a1cc2af19 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -47,7 +47,7 @@ jobs: sentry-cli dif upload --project ghostty --wait dsym.zip build-macos: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -94,7 +94,7 @@ jobs: - name: Build Ghostty.app run: | cd macos - sudo xcode-select -s /Applications/Xcode_16.0.app + sudo xcode-select -s /Applications/Xcode_26.0.app xcodebuild -target Ghostty -configuration Release # We inject the "build number" as simply the number of commits since HEAD. @@ -199,7 +199,7 @@ jobs: destination-dir: ./ build-macos-debug: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -246,7 +246,7 @@ jobs: - name: Build Ghostty.app run: | cd macos - sudo xcode-select -s /Applications/Xcode_16.0.app + sudo xcode-select -s /Applications/Xcode_26.0.app xcodebuild -target Ghostty -configuration Release # We inject the "build number" as simply the number of commits since HEAD. diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 6190bed16..3deafd066 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -120,7 +120,7 @@ jobs: build-macos: needs: [setup] - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 env: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} @@ -139,7 +139,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_16.4.app - name: Setup Sparkle env: @@ -288,7 +288,7 @@ jobs: appcast: needs: [setup, build-macos] - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia env: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index b4a341a5d..2a3277ea6 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -107,7 +107,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -132,7 +132,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@v2 + uses: softprops/action-gh-release@v2.3.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -154,7 +154,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -173,7 +173,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_26.0.app # Setup Sparkle - name: Setup Sparkle @@ -299,7 +299,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.3.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -369,7 +369,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -388,7 +388,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_26.0.app # Setup Sparkle - name: Setup Sparkle @@ -507,7 +507,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.3.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -544,7 +544,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -563,7 +563,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_26.0.app # Setup Sparkle - name: Setup Sparkle @@ -682,7 +682,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.3.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1401f8325..4d09603f4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,7 @@ jobs: - build-nix - build-snap - build-macos + - build-macos-tahoe - build-macos-matrix - build-windows - build-windows-cross @@ -67,7 +68,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -98,7 +99,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -134,7 +135,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -163,7 +164,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -196,7 +197,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -240,7 +241,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -269,7 +270,7 @@ jobs: ghostty-source.tar.gz build-macos: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia needs: test steps: - name: Checkout code @@ -284,8 +285,8 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_26.0.app - name: get the Zig deps id: deps @@ -296,7 +297,47 @@ jobs: - name: Build GhosttyKit run: nix develop -c zig build --system ${{ steps.deps.outputs.deps }} - # The native app is built with native XCode tooling. This also does + # The native app is built with native Xcode tooling. This also does + # codesigning. IMPORTANT: this must NOT run in a Nix environment. + # Nix breaks xcodebuild so this has to be run outside. + - name: Build Ghostty.app + run: cd macos && xcodebuild -target Ghostty + + # Build the iOS target without code signing just to verify it works. + - name: Build Ghostty iOS + run: | + cd macos + xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" + + build-macos-tahoe: + runs-on: namespace-profile-ghostty-macos-tahoe + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + - uses: cachix/cachix-action@v16 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_26.0.app + + - name: get the Zig deps + id: deps + run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT + + # GhosttyKit is the framework that is built from Zig for our native + # Mac app to access. + - name: Build GhosttyKit + run: nix develop -c zig build --system ${{ steps.deps.outputs.deps }} + + # The native app is built with native Xcode tooling. This also does # codesigning. IMPORTANT: this must NOT run in a Nix environment. # Nix breaks xcodebuild so this has to be run outside. - name: Build Ghostty.app @@ -309,7 +350,7 @@ jobs: xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" build-macos-matrix: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia needs: test steps: - name: Checkout code @@ -324,8 +365,8 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_26.0.app - name: get the Zig deps id: deps @@ -382,7 +423,7 @@ jobs: mkdir dist tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -492,7 +533,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -523,7 +564,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -568,7 +609,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -607,7 +648,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -627,7 +668,7 @@ jobs: nix develop -c zig build -Dsentry=${{ matrix.sentry }} test-macos: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia needs: test steps: - name: Checkout code @@ -642,8 +683,8 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_26.0.app - name: get the Zig deps id: deps @@ -662,7 +703,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -689,7 +730,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -716,7 +757,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -743,7 +784,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -770,7 +811,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -797,7 +838,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -832,7 +873,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -890,7 +931,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 27b35b441..2533285e6 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix diff --git a/.prettierignore b/.prettierignore index 490538680..f131a5edc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,6 +11,9 @@ zig-out/ # macos is managed by XCode GUI macos/ +# produced by Icon Composer on macOS +images/Ghostty.icon/icon.json + # website dev run website/.next diff --git a/CODEOWNERS b/CODEOWNERS index a53fb6da2..829a31e51 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -81,6 +81,10 @@ # - @ghostty-org/localization/* - Anything related to localization # for a specific locale. # +# - @ghosty-org/localization/manager - Manage all localization tasks +# and tooling. They are not responsible for any specific locale but +# are responsible for the overall localization process and tooling. +# # - @ghostty-org/macos - The Ghostty macOS app and any macOS-specific # features, configurations, etc. # diff --git a/LICENSE b/LICENSE index 14e132f55..0a07a66cd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Mitchell Hashimoto +Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index d5c9dba02..b59964e61 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,28 @@ macOS users don't require any additional dependencies. > source tarballs, see the > [website](http://ghostty.org/docs/install/build). +### Xcode Version and SDKs + +Building the Ghostty macOS app requires that Xcode, the macOS SDK, +and the iOS SDK are all installed. + +A common issue is that the incorrect version of Xcode is either +installed or selected. Use the `xcode-select` command to +ensure that the correct version of Xcode is selected: + +```shell-session +sudo xcode-select --switch /Applications/Xcode-beta.app +``` + +> [!IMPORTANT] +> +> Main branch development of Ghostty is preparing for the next major +> macOS release, Tahoe (macOS 26). Therefore, the main branch requires +> **Xcode 26 and the macOS 26 SDK**. +> +> You do not need to be running on macOS 26 to build Ghostty, you can +> still use Xcode 26 beta on macOS 15 stable. + ### Linting #### Prettier diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 696bed75f..000000000 --- a/TODO.md +++ /dev/null @@ -1,21 +0,0 @@ -Performance: - -- Loading fonts on startups should probably happen in multiple threads - -Correctness: - -- test wrap against wraptest: https://github.com/mattiase/wraptest - - automate this in some way -- Charsets: UTF-8 vs. ASCII mode - - we only support UTF-8 input right now - - need fallback glyphs if they're not supported - - can effect a crash using `vttest` menu `3 10` since it tries to parse - ASCII as UTF-8. - -Mac: - -- Preferences window - -Major Features: - -- Bell diff --git a/build.zig.zon b/build.zig.zon index 796ce1475..43986637f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -8,8 +8,8 @@ .libxev = .{ // mitchellh/libxev - .url = "https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz", - .hash = "libxev-0.0.0-86vtc-ziEgDbLP0vihUn1MhsxNKY4GJEga6BEr7oyHpz", + .url = "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz", + .hash = "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3", .lazy = true, }, .vaxis = .{ @@ -103,8 +103,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz", - .hash = "N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz", + .hash = "N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 68ec4522a..d9f43a766 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,20 +54,20 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn": { + "N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz", - "hash": "sha256-DKWVUxZEZA8x+3njPaTucr/u/Mmhef0YwhwOnOWn/N4=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz", + "hash": "sha256-g9o2CIc/TfWYoUS/l/HP5KZECD7qNsdQUlFruaKkVz4=" }, "N-V-__8AAJrvXQCqAT8Mg9o_tk6m0yf5Fz-gCNEOKLyTSerD": { "name": "libpng", "url": "https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz", "hash": "sha256-/syVtGzwXo4/yKQUdQ4LparQDYnp/fF16U/wQcrxoDo=" }, - "libxev-0.0.0-86vtc-ziEgDbLP0vihUn1MhsxNKY4GJEga6BEr7oyHpz": { + "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3": { "name": "libxev", - "url": "https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz", - "hash": "sha256-oKZqA9d79jHnp/HsqJWQE33Ffn5Ee5G4VnlQepQuY4o=" + "url": "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz", + "hash": "sha256-VwFByDoptqiN5UkolFQ7TbRhwMERReD9Er2pjxTCYIU=" }, "N-V-__8AAG3RoQEyRC2Vw7Qoro5SYBf62IHn3HjqtNVY6aWK": { "name": "libxml2", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 7c3e08d2d..26209e778 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -170,11 +170,11 @@ in }; } { - name = "N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn"; + name = "N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz"; - hash = "sha256-DKWVUxZEZA8x+3njPaTucr/u/Mmhef0YwhwOnOWn/N4="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz"; + hash = "sha256-g9o2CIc/TfWYoUS/l/HP5KZECD7qNsdQUlFruaKkVz4="; }; } { @@ -186,11 +186,11 @@ in }; } { - name = "libxev-0.0.0-86vtc-ziEgDbLP0vihUn1MhsxNKY4GJEga6BEr7oyHpz"; + name = "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3"; path = fetchZigArtifact { name = "libxev"; - url = "https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz"; - hash = "sha256-oKZqA9d79jHnp/HsqJWQE33Ffn5Ee5G4VnlQepQuY4o="; + url = "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz"; + hash = "sha256-VwFByDoptqiN5UkolFQ7TbRhwMERReD9Er2pjxTCYIU="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 0c71c80e4..553b0fb06 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -27,8 +27,8 @@ https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5. https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/glfw/glfw/archive/e7ea71be039836da3a98cea55ae5569cb5eb885c.tar.gz https://github.com/jcollie/ghostty-gobject/releases/download/0.14.0-2025-03-18-21-1/ghostty-gobject-0.14.0-2025-03-18-21-1.tar.zst -https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz -https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz +https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz +https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz https://github.com/mitchellh/zig-objc/archive/3ab0d37c7d6b933d6ded1b3a35b6b60f05590a98.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz https://github.com/vancluever/z2d/archive/1bf4bc81819385f4b24596445c9a7cf3b3592b08.tar.gz diff --git a/dist/linux/app.desktop b/dist/linux/app.desktop.in similarity index 72% rename from dist/linux/app.desktop rename to dist/linux/app.desktop.in index 6e464ea87..c39164158 100644 --- a/dist/linux/app.desktop +++ b/dist/linux/app.desktop.in @@ -1,13 +1,15 @@ [Desktop Entry] -Name=Ghostty +Version=1.0 +Name=@NAME@ Type=Application Comment=A terminal emulator -Exec=ghostty +TryExec=@GHOSTTY@ +Exec=@GHOSTTY@ --launched-from=desktop Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; StartupNotify=true -StartupWMClass=com.mitchellh.ghostty +StartupWMClass=@APPID@ Terminal=false Actions=new-window; X-GNOME-UsesNotifications=true @@ -16,7 +18,8 @@ X-TerminalArgTitle=--title= X-TerminalArgAppId=--class= X-TerminalArgDir=--working-directory= X-TerminalArgHold=--wait-after-command +DBusActivatable=true [Desktop Action new-window] Name=New Window -Exec=ghostty +Exec=@GHOSTTY@ --launched-from=desktop diff --git a/dist/linux/com.mitchellh.ghostty.metainfo.xml b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in similarity index 94% rename from dist/linux/com.mitchellh.ghostty.metainfo.xml rename to dist/linux/com.mitchellh.ghostty.metainfo.xml.in index 0424d3a09..42ccc2754 100644 --- a/dist/linux/com.mitchellh.ghostty.metainfo.xml +++ b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in @@ -1,8 +1,8 @@ - com.mitchellh.ghostty - com.mitchellh.ghostty.desktop - Ghostty + @APPID@ + @APPID@.desktop + @NAME@ https://ghostty.org https://ghostty.org/docs https://github.com/ghostty-org/ghostty/discussions diff --git a/dist/linux/dbus.service.flatpak.in b/dist/linux/dbus.service.flatpak.in new file mode 100644 index 000000000..213cda78f --- /dev/null +++ b/dist/linux/dbus.service.flatpak.in @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=@APPID@ +Exec=@GHOSTTY@ --launched-from=dbus diff --git a/dist/linux/dbus.service.in b/dist/linux/dbus.service.in new file mode 100644 index 000000000..2f782a7ed --- /dev/null +++ b/dist/linux/dbus.service.in @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=@APPID@ +SystemdService=@APPID@.service +Exec=@GHOSTTY@ --launched-from=dbus diff --git a/dist/linux/systemd.service.in b/dist/linux/systemd.service.in new file mode 100644 index 000000000..b0ef3d59a --- /dev/null +++ b/dist/linux/systemd.service.in @@ -0,0 +1,7 @@ +[Unit] +Description=@NAME@ + +[Service] +Type=dbus +BusName=@APPID@ +ExecStart=@GHOSTTY@ --launched-from=systemd diff --git a/dist/macos/Ghostty.icns b/dist/macos/Ghostty.icns deleted file mode 100644 index 44a44711a..000000000 Binary files a/dist/macos/Ghostty.icns and /dev/null differ diff --git a/dist/macos/Info.plist b/dist/macos/Info.plist deleted file mode 100644 index 8283cc529..000000000 --- a/dist/macos/Info.plist +++ /dev/null @@ -1,17 +0,0 @@ - - - - - CFBundleExecutable - ghostty - CFBundleIdentifier - com.mitchellh.ghostty - CFBundleName - Ghostty - CFBundleDisplayName - Ghostty - CFBundleIconFile - Ghostty.icns - - - diff --git a/flake.lock b/flake.lock index df09a9666..4b8ce405c 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1733328505, - "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "lastModified": 1747046372, + "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", "owner": "edolstra", "repo": "flake-compat", - "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", "type": "github" }, "original": { @@ -34,44 +34,24 @@ "type": "github" } }, - "nixpkgs-stable": { + "nixpkgs": { "locked": { - "lastModified": 1741992157, - "narHash": "sha256-nlIfTsTrMSksEJc1f7YexXiPVuzD1gOfeN1ggwZyUoc=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "da4b122f63095ca1199bd4d526f9e26426697689", - "type": "github" + "lastModified": 1748189127, + "narHash": "sha256-zRDR+EbbeObu4V2X5QCd2Bk5eltfDlCr5yvhBwUT6pY=", + "rev": "7c43f080a7f28b2774f3b3f43234ca11661bf334", + "type": "tarball", + "url": "https://releases.nixos.org/nixos/25.05/nixos-25.05.802491.7c43f080a7f2/nixexprs.tar.xz" }, "original": { - "owner": "nixos", - "ref": "release-24.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-unstable": { - "locked": { - "lastModified": 1741865919, - "narHash": "sha256-4thdbnP6dlbdq+qZWTsm4ffAwoS8Tiq1YResB+RP6WE=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "573c650e8a14b2faa0041645ab18aed7e60f0c9a", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" + "type": "tarball", + "url": "https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz" } }, "root": { "inputs": { "flake-compat": "flake-compat", "flake-utils": "flake-utils", - "nixpkgs-stable": "nixpkgs-stable", - "nixpkgs-unstable": "nixpkgs-unstable", + "nixpkgs": "nixpkgs", "zig": "zig", "zon2nix": "zon2nix" } @@ -98,15 +78,15 @@ "flake-utils" ], "nixpkgs": [ - "nixpkgs-stable" + "nixpkgs" ] }, "locked": { - "lastModified": 1741825901, - "narHash": "sha256-aeopo+aXg5I2IksOPFN79usw7AeimH1+tjfuMzJHFdk=", + "lastModified": 1748261582, + "narHash": "sha256-3i0IL3s18hdDlbsf0/E+5kyPRkZwGPbSFngq5eToiAA=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "0b14285e283f5a747f372fb2931835dd937c4383", + "rev": "aafb1b093fb838f7a02613b719e85ec912914221", "type": "github" }, "original": { @@ -121,7 +101,7 @@ "flake-utils" ], "nixpkgs": [ - "nixpkgs-unstable" + "nixpkgs" ] }, "locked": { diff --git a/flake.nix b/flake.nix index d4c6aa6ca..6794afb11 100644 --- a/flake.nix +++ b/flake.nix @@ -2,12 +2,10 @@ description = "👻"; inputs = { - nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable"; - # We want to stay as up to date as possible but need to be careful that the # glibc versions used by our dependencies from Nix are compatible with the # system glibc that the user is building for. - nixpkgs-stable.url = "github:nixos/nixpkgs/release-24.11"; + nixpkgs.url = "https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz"; flake-utils.url = "github:numtide/flake-utils"; # Used for shell.nix @@ -19,7 +17,7 @@ zig = { url = "github:mitchellh/zig-overlay"; inputs = { - nixpkgs.follows = "nixpkgs-stable"; + nixpkgs.follows = "nixpkgs"; flake-utils.follows = "flake-utils"; flake-compat.follows = ""; }; @@ -28,7 +26,7 @@ zon2nix = { url = "github:jcollie/zon2nix?ref=56c159be489cc6c0e73c3930bd908ddc6fe89613"; inputs = { - nixpkgs.follows = "nixpkgs-unstable"; + nixpkgs.follows = "nixpkgs"; flake-utils.follows = "flake-utils"; }; }; @@ -36,24 +34,19 @@ outputs = { self, - nixpkgs-unstable, - nixpkgs-stable, + nixpkgs, zig, zon2nix, ... }: - builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} ( + builtins.foldl' nixpkgs.lib.recursiveUpdate {} ( builtins.map ( system: let - pkgs-stable = nixpkgs-stable.legacyPackages.${system}; - pkgs-unstable = nixpkgs-unstable.legacyPackages.${system}; + pkgs = nixpkgs.legacyPackages.${system}; in { - devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix { - zig = zig.packages.${system}."0.14.0"; - wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {}; - uv = pkgs-unstable.uv; - # remove once blueprint-compiler 0.16.0 is in the stable nixpkgs - blueprint-compiler = pkgs-unstable.blueprint-compiler; + devShell.${system} = pkgs.callPackage ./nix/devShell.nix { + zig = zig.packages.${system}."0.14.1"; + wraptest = pkgs.callPackage ./nix/wraptest.nix {}; zon2nix = zon2nix; }; @@ -64,30 +57,29 @@ revision = self.shortRev or self.dirtyShortRev or "dirty"; }; in rec { - deps = pkgs-unstable.callPackage ./build.zig.zon.nix {}; - ghostty-debug = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "Debug"); - ghostty-releasesafe = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe"); - ghostty-releasefast = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "ReleaseFast"); + deps = pkgs.callPackage ./build.zig.zon.nix {}; + ghostty-debug = pkgs.callPackage ./nix/package.nix (mkArgs "Debug"); + ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseSafe"); + ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseFast"); ghostty = ghostty-releasefast; default = ghostty; }; - formatter.${system} = pkgs-stable.alejandra; + formatter.${system} = pkgs.alejandra; apps.${system} = let runVM = ( module: let vm = import ./nix/vm/create.nix { - inherit system module; - nixpkgs = nixpkgs-unstable; + inherit system module nixpkgs; overlay = self.overlays.debug; }; - program = pkgs-unstable.writeShellScript "run-ghostty-vm" '' + program = pkgs.writeShellScript "run-ghostty-vm" '' SHARED_DIR=$(pwd) export SHARED_DIR - ${pkgs-unstable.lib.getExe vm.config.system.build.vm} "$@" + ${pkgs.lib.getExe vm.config.system.build.vm} "$@" ''; in { type = "app"; diff --git a/flatpak/com.mitchellh.ghostty.Devel.yml b/flatpak/com.mitchellh.ghostty-debug.yml similarity index 85% rename from flatpak/com.mitchellh.ghostty.Devel.yml rename to flatpak/com.mitchellh.ghostty-debug.yml index fe24a7c56..fe4722ef5 100644 --- a/flatpak/com.mitchellh.ghostty.Devel.yml +++ b/flatpak/com.mitchellh.ghostty-debug.yml @@ -1,4 +1,4 @@ -app-id: com.mitchellh.ghostty.Devel +app-id: com.mitchellh.ghostty-debug runtime: org.gnome.Platform runtime-version: "48" sdk: org.gnome.Sdk @@ -6,11 +6,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.ziglang default-branch: tip command: ghostty -# Integrate the rename into zig build, maybe? -rename-desktop-file: com.mitchellh.ghostty.desktop -rename-appdata-file: com.mitchellh.ghostty.metainfo.xml rename-icon: com.mitchellh.ghostty -desktop-file-name-suffix: " (Devel)" finish-args: # 3D rendering - --device=dri diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 2ee48f269..4990f794a 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz", - "dest": "vendor/p/N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn", - "sha256": "0ca595531644640f31fb79e33da4ee72bfeefcc9a179fd18c21c0e9ce5a7fcde" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz", + "dest": "vendor/p/N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS", + "sha256": "83da3608873f4df598a144bf97f1cfe4a644083eea36c75052516bb9a2a4573e" }, { "type": "archive", @@ -79,9 +79,9 @@ }, { "type": "archive", - "url": "https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz", - "dest": "vendor/p/libxev-0.0.0-86vtc-ziEgDbLP0vihUn1MhsxNKY4GJEga6BEr7oyHpz", - "sha256": "a0a66a03d77bf631e7a7f1eca89590137dc57e7e447b91b85679507a942e638a" + "url": "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz", + "dest": "vendor/p/libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3", + "sha256": "570141c83a29b6a88de5492894543b4db461c0c11145e0fd12bda98f14c26085" }, { "type": "archive", diff --git a/images/Ghostty.icon/Assets/Ghostty.png b/images/Ghostty.icon/Assets/Ghostty.png new file mode 100644 index 000000000..49795c006 Binary files /dev/null and b/images/Ghostty.icon/Assets/Ghostty.png differ diff --git a/images/Ghostty.icon/Assets/Inner Bevel 6px.png b/images/Ghostty.icon/Assets/Inner Bevel 6px.png new file mode 100644 index 000000000..678193779 Binary files /dev/null and b/images/Ghostty.icon/Assets/Inner Bevel 6px.png differ diff --git a/images/Ghostty.icon/Assets/Screen Effects.png b/images/Ghostty.icon/Assets/Screen Effects.png new file mode 100644 index 000000000..0af7d3338 Binary files /dev/null and b/images/Ghostty.icon/Assets/Screen Effects.png differ diff --git a/images/Ghostty.icon/Assets/Screen.png b/images/Ghostty.icon/Assets/Screen.png new file mode 100644 index 000000000..2023b6ffa Binary files /dev/null and b/images/Ghostty.icon/Assets/Screen.png differ diff --git a/images/Ghostty.icon/Assets/gloss.png b/images/Ghostty.icon/Assets/gloss.png new file mode 100644 index 000000000..f11196010 Binary files /dev/null and b/images/Ghostty.icon/Assets/gloss.png differ diff --git a/images/Ghostty.icon/icon.json b/images/Ghostty.icon/icon.json new file mode 100644 index 000000000..b29c9d81f --- /dev/null +++ b/images/Ghostty.icon/icon.json @@ -0,0 +1,170 @@ +{ + "color-space-for-untagged-svg-colors" : "display-p3", + "fill" : { + "linear-gradient" : [ + "display-p3:0.87945,0.87945,0.87945,1.00000", + "display-p3:0.40000,0.40000,0.40392,1.00000" + ] + }, + "groups" : [ + { + "blend-mode" : "normal", + "layers" : [ + { + "blend-mode" : "overlay", + "fill" : { + "linear-gradient" : [ + "srgb:1.00000,1.00000,1.00000,1.00000", + "srgb:0.00000,0.00000,0.00000,1.00000" + ] + }, + "hidden" : false, + "image-name" : "gloss.png", + "name" : "GlossTop", + "opacity" : 0.25, + "position" : { + "scale" : 0.98, + "translation-in-points" : [ + 0.90625, + -236.4609375 + ] + } + }, + { + "blend-mode" : "normal", + "fill" : "automatic", + "hidden" : false, + "image-name" : "gloss.png", + "name" : "gloss", + "position" : { + "scale" : 0.98, + "translation-in-points" : [ + 0.90625, + -236.4609375 + ] + } + } + ], + "lighting" : "individual", + "name" : "Group 4", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + }, + { + "blend-mode" : "overlay", + "layers" : [ + { + "blend-mode" : "overlay", + "fill" : "automatic", + "glass" : false, + "hidden" : false, + "image-name" : "Screen Effects.png", + "name" : "Screen Effects" + }, + { + "blend-mode" : "overlay", + "fill" : "automatic", + "glass" : true, + "hidden" : false, + "image-name" : "Screen Effects.png", + "name" : "Screen Effects" + } + ], + "lighting" : "individual", + "name" : "Group 3", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : false, + "value" : 0.5 + } + }, + { + "blur-material" : null, + "layers" : [ + { + "blend-mode" : "normal", + "fill" : "automatic", + "hidden" : false, + "image-name" : "Ghostty.png", + "name" : "Ghostty", + "position" : { + "scale" : 1, + "translation-in-points" : [ + -185.015625, + -143.8359375 + ] + } + }, + { + "blend-mode" : "normal", + "fill" : { + "solid" : "extended-srgb:0.00000,0.47843,1.00000,1.00000" + }, + "glass" : true, + "hidden" : false, + "image-name" : "Ghostty.png", + "name" : "GhosttyBlur", + "position" : { + "scale" : 1, + "translation-in-points" : [ + -186.59375, + -143.8359375 + ] + } + }, + { + "hidden" : false, + "image-name" : "Screen.png", + "name" : "Screen" + } + ], + "lighting" : "individual", + "name" : "Group 2", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : false, + "value" : 0.5 + } + }, + { + "blend-mode" : "normal", + "blur-material" : null, + "hidden" : false, + "layers" : [ + { + "image-name" : "Inner Bevel 6px.png", + "name" : "Inner Bevel 6px" + } + ], + "lighting" : "individual", + "name" : "Group 1", + "shadow" : { + "kind" : "layer-color", + "opacity" : 0.2 + }, + "specular" : false, + "translucency" : { + "enabled" : false, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/images/icons/icon_1024.png b/images/icons/icon_1024.png index a0b716c87..22361edcb 100644 Binary files a/images/icons/icon_1024.png and b/images/icons/icon_1024.png differ diff --git a/images/icons/icon_1024@2x.png b/images/icons/icon_1024@2x.png new file mode 100644 index 000000000..22361edcb Binary files /dev/null and b/images/icons/icon_1024@2x.png differ diff --git a/images/icons/icon_128.png b/images/icons/icon_128.png index bad0eb891..317ad9f0f 100644 Binary files a/images/icons/icon_128.png and b/images/icons/icon_128.png differ diff --git a/images/icons/icon_256.png b/images/icons/icon_256.png index 803224416..9988ac11e 100644 Binary files a/images/icons/icon_256.png and b/images/icons/icon_256.png differ diff --git a/images/icons/icon_256@2x.png b/images/icons/icon_256@2x.png index b51b8d7dc..9988ac11e 100644 Binary files a/images/icons/icon_256@2x.png and b/images/icons/icon_256@2x.png differ diff --git a/images/icons/icon_512.png b/images/icons/icon_512.png index b51b8d7dc..759511f68 100644 Binary files a/images/icons/icon_512.png and b/images/icons/icon_512.png differ diff --git a/images/icons/icon_512@2x.png b/images/icons/icon_512@2x.png new file mode 100644 index 000000000..759511f68 Binary files /dev/null and b/images/icons/icon_512@2x.png differ diff --git a/include/ghostty.h b/include/ghostty.h index 941223943..181f7b7f8 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -292,6 +292,11 @@ typedef enum { GHOSTTY_KEY_AUDIO_VOLUME_MUTE, GHOSTTY_KEY_AUDIO_VOLUME_UP, GHOSTTY_KEY_WAKE_UP, + + // "Legacy, Non-standard, and Special Keys" § 3.7 + GHOSTTY_KEY_COPY, + GHOSTTY_KEY_CUT, + GHOSTTY_KEY_PASTE, } ghostty_input_key_e; typedef struct { @@ -350,8 +355,41 @@ typedef struct { double tl_px_y; uint32_t offset_start; uint32_t offset_len; + const char* text; + uintptr_t text_len; +} ghostty_text_s; + +typedef enum { + GHOSTTY_POINT_ACTIVE, + GHOSTTY_POINT_VIEWPORT, + GHOSTTY_POINT_SCREEN, + GHOSTTY_POINT_SURFACE, +} ghostty_point_tag_e; + +typedef enum { + GHOSTTY_POINT_COORD_EXACT, + GHOSTTY_POINT_COORD_TOP_LEFT, + GHOSTTY_POINT_COORD_BOTTOM_RIGHT, +} ghostty_point_coord_e; + +typedef struct { + ghostty_point_tag_e tag; + ghostty_point_coord_e coord; + uint32_t x; + uint32_t y; +} ghostty_point_s; + +typedef struct { + ghostty_point_s top_left; + ghostty_point_s bottom_right; + bool rectangle; } ghostty_selection_s; +typedef struct { + const char* key; + const char* value; +} ghostty_env_var_s; + typedef struct { void* nsview; } ghostty_platform_macos_s; @@ -373,6 +411,9 @@ typedef struct { float font_size; const char* working_directory; const char* command; + ghostty_env_var_s* env_vars; + size_t env_var_count; + const char* initial_input; } ghostty_surface_config_s; typedef struct { @@ -648,6 +689,7 @@ typedef enum { GHOSTTY_ACTION_INITIAL_SIZE, GHOSTTY_ACTION_CELL_SIZE, GHOSTTY_ACTION_INSPECTOR, + GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, GHOSTTY_ACTION_RENDER_INSPECTOR, GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, @@ -667,6 +709,8 @@ typedef enum { GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_UNDO, + GHOSTTY_ACTION_REDO, GHOSTTY_ACTION_CHECK_FOR_UPDATES } ghostty_action_tag_e; @@ -771,13 +815,15 @@ 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, ghostty_surface_config_s*); +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); 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); @@ -823,16 +869,16 @@ void ghostty_surface_complete_clipboard_request(ghostty_surface_t, void*, bool); bool ghostty_surface_has_selection(ghostty_surface_t); -uintptr_t ghostty_surface_selection(ghostty_surface_t, char*, uintptr_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*); #ifdef __APPLE__ void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); void* ghostty_surface_quicklook_font(ghostty_surface_t); -uintptr_t ghostty_surface_quicklook_word(ghostty_surface_t, - char*, - uintptr_t, - ghostty_selection_s*); -bool ghostty_surface_selection_info(ghostty_surface_t, ghostty_selection_s*); +bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*); #endif ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); diff --git a/macos/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 9c6bc2e81..000000000 --- a/macos/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "images" : [ - { - "filename" : "macOS-AppIcon-1024px.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "filename" : "macOS-AppIcon-16px-16pt@1x.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "macOS-AppIcon-32px-16pt@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "macOS-AppIcon-32px-32pt@1x.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "macOS-AppIcon-64px-32pt@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "macOS-AppIcon-128px-128pt@1x.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "macOS-AppIcon-256px-128pt@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "macOS-AppIcon-256px-128pt@2x 1.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "macOS-AppIcon-512px-256pt@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "macOS-AppIcon-512px.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "macOS-AppIcon-1024px 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px 1.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px 1.png deleted file mode 100644 index a0b716c87..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px 1.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px.png deleted file mode 100644 index a0b716c87..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-128px-128pt@1x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-128px-128pt@1x.png deleted file mode 100644 index bad0eb891..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-128px-128pt@1x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-16px-16pt@1x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-16px-16pt@1x.png deleted file mode 100644 index cacff7a54..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-16px-16pt@1x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x 1.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x 1.png deleted file mode 100644 index 46c3f7050..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x 1.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x.png deleted file mode 100644 index 46c3f7050..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-16pt@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-16pt@2x.png deleted file mode 100644 index c8011a605..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-16pt@2x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-32pt@1x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-32pt@1x.png deleted file mode 100644 index 5e68d5fd0..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-32pt@1x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px-256pt@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px-256pt@2x.png deleted file mode 100644 index b51b8d7dc..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px-256pt@2x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px.png deleted file mode 100644 index f302b40bb..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-64px-32pt@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-64px-32pt@2x.png deleted file mode 100644 index e394a5170..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-64px-32pt@2x.png and /dev/null differ diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a34c4685f..cf806c7bd 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -12,10 +12,18 @@ 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; + A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; }; + A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; }; + A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194102E05A480007258CC /* QuickTerminalIntent.swift */; }; + A51194132E05D006007258CC /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; }; + A51194172E05D964007258CC /* PermissionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194162E05D95E007258CC /* PermissionRequest.swift */; }; + A51194192E05DFC4007258CC /* IntentPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194182E05DFBB007258CC /* IntentPermission.swift */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; - A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */; }; + A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */; }; + A51545002DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */; }; + A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */; }; A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51BFC1D2B2FB5CE00E92F16 /* About.xib */; }; A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */; }; A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC212B2FB6B400E92F16 /* AboutView.swift */; }; @@ -50,7 +58,16 @@ A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */; }; A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; }; A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; }; - A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; + A553F4062E05E93000257779 /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; }; + A553F4072E05E93D00257779 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; }; + A553F4132E06EB1600257779 /* Ghostty.icon in Resources */ = {isa = PBXBuildFile; fileRef = A553F4122E06EB1600257779 /* Ghostty.icon */; }; + A553F4142E06EB1600257779 /* Ghostty.icon in Resources */ = {isa = PBXBuildFile; fileRef = A553F4122E06EB1600257779 /* Ghostty.icon */; }; + A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; }; + A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; }; + A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; }; + A5593FE52DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */; }; + A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */; }; + A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */; }; A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */; }; A56B880B2A840447007A0E29 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A56B880A2A840447007A0E29 /* Carbon.framework */; }; @@ -59,6 +76,12 @@ A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; }; A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; }; + A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; }; + A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; }; + A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; }; + A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366E2DF25D8300E04A10 /* Duration+Extension.swift */; }; + A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */; }; + A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636722DF4813000E04A10 /* UndoManager+Extension.swift */; }; A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; }; A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; @@ -66,9 +89,6 @@ A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A59630992AEE1C6400D64628 /* Terminal.xib */; }; A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309B2AEE1C9E00D64628 /* TerminalController.swift */; }; A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309D2AEE1D6C00D64628 /* TerminalView.swift */; }; - A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; }; - A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */; }; - A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */; }; A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; A5985CE62C33060F00C57AD3 /* man in Resources */ = {isa = PBXBuildFile; fileRef = A5985CE52C33060F00C57AD3 /* man */; }; @@ -78,9 +98,10 @@ A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3C92D4445E20033CF96 /* Dock.swift */; }; A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */; }; - A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; + A5A6F72A2CC41B8900B232A5 /* AppInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* AppInfo.swift */; }; A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */; }; A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; }; A5CA378E2D31D6C300931030 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378D2D31D6C100931030 /* Weak.swift */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; @@ -106,9 +127,20 @@ A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; }; A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; }; A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; }; + A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */; }; + A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */; }; + A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */; }; + A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */; }; + A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */; }; + A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */; }; + A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */; }; + A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */; }; + A5E408402E04532C0035FEAC /* CommandEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083F2E04532A0035FEAC /* CommandEntity.swift */; }; + A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; }; + A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.swift */; }; + A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408462E0485270035FEAC /* InputIntent.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; - AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; }; @@ -125,8 +157,16 @@ 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; + A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; + A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = ""; }; + A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = ""; }; + A51194122E05D003007258CC /* Optional+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extension.swift"; sourceTree = ""; }; + A51194162E05D95E007258CC /* PermissionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionRequest.swift; sourceTree = ""; }; + A51194182E05DFBB007258CC /* IntentPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentPermission.swift; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; - A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; + A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = ""; }; + A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = ""; }; + A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsVenturaTerminalWindow.swift; sourceTree = ""; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = ""; }; A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = ""; }; A51BFC212B2FB6B400E92F16 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -155,7 +195,13 @@ A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = ""; }; A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = ""; }; A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = ""; }; - A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; + A553F4122E06EB1600257779 /* Ghostty.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = Ghostty.icon; path = ../images/Ghostty.icon; sourceTree = SOURCE_ROOT; }; + A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; + A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = ""; }; + A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = ""; }; + A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarVentura.xib; sourceTree = ""; }; + A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentTitlebarTerminalWindow.swift; sourceTree = ""; }; + A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTransparentTitlebar.xib; sourceTree = ""; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = ""; }; A56B880A2A840447007A0E29 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; @@ -164,6 +210,12 @@ A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = ""; }; A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = ""; }; A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = ""; }; + A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = ""; }; + A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = ""; }; + A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; + A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = ""; }; + A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringUndoManager.swift; sourceTree = ""; }; + A58636722DF4813000E04A10 /* UndoManager+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UndoManager+Extension.swift"; sourceTree = ""; }; A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -171,9 +223,6 @@ A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = ""; }; A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; - A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; - A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.TerminalSplit.swift; sourceTree = ""; }; - A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitNode.swift; sourceTree = ""; }; A5985CD62C320C4500C57AD3 /* String+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = ""; }; A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAppearance+Extension.swift"; sourceTree = ""; }; @@ -182,11 +231,12 @@ A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; A5A2A3C92D4445E20033CF96 /* Dock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dock.swift; sourceTree = ""; }; A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+Extension.swift"; sourceTree = ""; }; - A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = ""; }; + A5A6F7292CC41B8700B232A5 /* AppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfo.swift; sourceTree = ""; }; A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastWindowPosition.swift; sourceTree = ""; }; A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; + A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMenuItem+Extension.swift"; sourceTree = ""; }; A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; A5CA378D2D31D6C100931030 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; @@ -213,9 +263,20 @@ A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ClipboardConfirmation.xib; sourceTree = ""; }; A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = ""; }; A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = ""; }; + A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabGroupCloseCoordinator.swift; sourceTree = ""; }; + A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTerminalIntent.swift; sourceTree = ""; }; + A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIntentError.swift; sourceTree = ""; }; + A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalEntity.swift; sourceTree = ""; }; + A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTerminalDetailsIntent.swift; sourceTree = ""; }; + A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Surface.swift; sourceTree = ""; }; + A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Command.swift; sourceTree = ""; }; + A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Error.swift; sourceTree = ""; }; + A5E4083F2E04532A0035FEAC /* CommandEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandEntity.swift; sourceTree = ""; }; + A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = ""; }; + A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = ""; }; + A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; - AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; C1F26EA62B738B9900404083 /* NSView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView+Extension.swift"; sourceTree = ""; }; C1F26EE72B76CBFC00404083 /* VibrantLayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = VibrantLayer.h; sourceTree = ""; }; @@ -273,8 +334,10 @@ A56D58872ACDE6BE00508D2C /* Services */, A59630982AEE1C4400D64628 /* Terminal */, A5CBD05A2CA0C5910017A1AE /* QuickTerminal */, + A5E4082C2E0237270035FEAC /* App Intents */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, A57D79252C9C8782001D522E /* Secure Input */, + A58636622DEF955100E04A10 /* Splits */, A53A29742DB2E04900B6E02C /* Command Palette */, A534263E2A7DCC5800EBB7A2 /* Settings */, A51BFC1C2B2FB5AB00E92F16 /* About */, @@ -287,34 +350,25 @@ A534263D2A7DCBB000EBB7A2 /* Helpers */ = { isa = PBXGroup; children = ( + A58636692DF0A98100E04A10 /* Extensions */, A5874D9B2DAD781100E83852 /* Private */, + A5A6F7292CC41B8700B232A5 /* AppInfo.swift */, A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */, - A5A6F7292CC41B8700B232A5 /* Xcode.swift */, A5CEAFFE29C2410700646FDA /* Backport.swift */, A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5CBD0572C9F30860017A1AE /* Cursor.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, + A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, + A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */, A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */, - A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, - A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, - A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */, - C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, - A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, - A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, - A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */, - A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, - C1F26EA62B738B9900404083 /* NSView+Extension.swift */, - AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, - A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */, - A5985CD62C320C4500C57AD3 /* String+Extension.swift */, - A5CC36142C9CDA03004D6760 /* View+Extension.swift */, + A51194162E05D95E007258CC /* PermissionRequest.swift */, + A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */, A5CA378D2D31D6C100931030 /* Weak.swift */, C1F26EE72B76CBFC00404083 /* VibrantLayer.h */, C1F26EE82B76CBFC00404083 /* VibrantLayer.m */, - A5CEAFDA29B8005900646FDA /* SplitView */, ); path = Helpers; sourceTree = ""; @@ -388,6 +442,23 @@ path = Sources; sourceTree = ""; }; + A5593FDD2DF8D56000B47B10 /* Window Styles */ = { + isa = PBXGroup; + children = ( + A59630992AEE1C6400D64628 /* Terminal.xib */, + A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */, + A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */, + A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */, + A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */, + A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */, + A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */, + A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */, + A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */, + A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */, + ); + path = "Window Styles"; + sourceTree = ""; + }; A55B7BB429B6F4410055DE60 /* Ghostty */ = { isa = PBXGroup; children = ( @@ -397,14 +468,14 @@ A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */, A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */, A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */, + A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */, A514C8D52B54A16400493A16 /* Ghostty.Config.swift */, A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */, + A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */, A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, - A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */, - A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */, - A55685DF29A03A9F004303CE /* AppError.swift */, + A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */, A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */, A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */, ); @@ -428,6 +499,42 @@ path = "Secure Input"; sourceTree = ""; }; + A58636622DEF955100E04A10 /* Splits */ = { + isa = PBXGroup; + children = ( + A586365E2DEE6C2100E04A10 /* SplitTree.swift */, + A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */, + A5CEAFDB29B8009000646FDA /* SplitView.swift */, + A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */, + ); + path = Splits; + sourceTree = ""; + }; + A58636692DF0A98100E04A10 /* Extensions */ = { + isa = PBXGroup; + children = ( + A586366A2DF0A98900E04A10 /* Array+Extension.swift */, + A50297342DFA0F3300B4E924 /* Double+Extension.swift */, + A586366E2DF25D8300E04A10 /* Duration+Extension.swift */, + A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */, + A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */, + A51194122E05D003007258CC /* Optional+Extension.swift */, + C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, + A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, + A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, + A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */, + A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */, + A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, + AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, + C1F26EA62B738B9900404083 /* NSView+Extension.swift */, + A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */, + A5985CD62C320C4500C57AD3 /* String+Extension.swift */, + A58636722DF4813000E04A10 /* UndoManager+Extension.swift */, + A5CC36142C9CDA03004D6760 /* View+Extension.swift */, + ); + path = Extensions; + sourceTree = ""; + }; A5874D9B2DAD781100E83852 /* Private */ = { isa = PBXGroup; children = ( @@ -440,13 +547,10 @@ A59630982AEE1C4400D64628 /* Terminal */ = { isa = PBXGroup; children = ( - A59630992AEE1C6400D64628 /* Terminal.xib */, - A596309F2AEF6AEB00D64628 /* TerminalManager.swift */, + A5593FDD2DF8D56000B47B10 /* Window Styles */, A596309B2AEE1C9E00D64628 /* TerminalController.swift */, A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, - A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */, - AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */, ); @@ -475,6 +579,7 @@ children = ( A571AB1C2A206FC600248498 /* Ghostty-Info.plist */, A5B30538299BEAAB0047F10C /* Assets.xcassets */, + A553F4122E06EB1600257779 /* Ghostty.icon */, A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */, A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */, 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */, @@ -515,15 +620,6 @@ path = "Global Keybinds"; sourceTree = ""; }; - A5CEAFDA29B8005900646FDA /* SplitView */ = { - isa = PBXGroup; - children = ( - A5CEAFDB29B8009000646FDA /* SplitView.swift */, - A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */, - ); - path = SplitView; - sourceTree = ""; - }; A5D495A3299BECBA00DD1313 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -543,6 +639,32 @@ path = ClipboardConfirmation; sourceTree = ""; }; + A5E4082C2E0237270035FEAC /* App Intents */ = { + isa = PBXGroup; + children = ( + A5E408412E0453370035FEAC /* Entities */, + A511940E2E050590007258CC /* CloseTerminalIntent.swift */, + A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */, + A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */, + A51194102E05A480007258CC /* QuickTerminalIntent.swift */, + A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */, + A5E408462E0485270035FEAC /* InputIntent.swift */, + A5E408442E0483F80035FEAC /* KeybindIntent.swift */, + A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */, + A51194182E05DFBB007258CC /* IntentPermission.swift */, + ); + path = "App Intents"; + sourceTree = ""; + }; + A5E408412E0453370035FEAC /* Entities */ = { + isa = PBXGroup; + children = ( + A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */, + A5E4083F2E04532A0035FEAC /* CommandEntity.swift */, + ); + path = Entities; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -630,9 +752,12 @@ buildActionMask = 2147483647; files = ( FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */, + A553F4142E06EB1600257779 /* Ghostty.icon in Resources */, + A5593FE52DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib in Resources */, 29C15B1D2CDC3B2900520DD4 /* bat in Resources */, A586167C2B7703CC009BDB1D /* fish in Resources */, 55154BE02B33911F001622DC /* ghostty in Resources */, + A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */, A546F1142D7B68D7003B11A0 /* locale in Resources */, A5985CE62C33060F00C57AD3 /* man in Resources */, 9351BE8E3D22937F003B3499 /* nvim in Resources */, @@ -641,10 +766,12 @@ FC5218FA2D10FFCE004C93E0 /* zsh in Resources */, A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */, A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */, + A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */, A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */, A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */, 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */, A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */, + A51545002DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib in Resources */, A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -654,6 +781,7 @@ buildActionMask = 2147483647; files = ( A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */, + A553F4132E06EB1600257779 /* Ghostty.icon in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -665,77 +793,101 @@ buildActionMask = 2147483647; files = ( A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */, - A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, + A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */, A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */, A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, + A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */, A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, + A51194132E05D006007258CC /* Optional+Extension.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, + A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */, A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */, + A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */, A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, + A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, + A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */, + A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */, + A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */, A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */, + A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */, + A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, + A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */, C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, + A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */, + A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, - A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */, AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */, + A51194172E05D964007258CC /* PermissionRequest.swift in Sources */, + A51194192E05DFC4007258CC /* IntentPermission.swift in Sources */, A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */, - A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */, + A5A6F72A2CC41B8900B232A5 /* AppInfo.swift in Sources */, A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, A5CA378E2D31D6C300931030 /* Weak.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, + A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */, A5874D992DAD751B00E83852 /* CGS.swift in Sources */, + A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */, + A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */, + A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */, - A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */, + A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */, + A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */, + A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */, + A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */, A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, - A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */, + A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */, A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, + A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */, + A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */, A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */, A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */, A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, - A55685E029A03A9F004303CE /* AppError.swift in Sources */, A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */, A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */, A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */, + A5E408402E04532C0035FEAC /* CommandEntity.swift in Sources */, + A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A53A29882DB69D2F00B6E02C /* TerminalCommandPalette.swift in Sources */, A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */, A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */, A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */, A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */, + A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */, A52FFF592CAA4FF3000C6A5B /* Fullscreen.swift in Sources */, - AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */, C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */, A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */, A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */, @@ -748,6 +900,7 @@ buildActionMask = 2147483647; files = ( A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */, + A553F4062E05E93000257779 /* Optional+Extension.swift in Sources */, A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */, A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */, A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */, @@ -757,6 +910,7 @@ A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */, A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */, A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */, + A553F4072E05E93D00257779 /* Array+Extension.swift in Sources */, C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -822,7 +976,7 @@ 3B39CAA32B33946300DABEB8 /* ReleaseLocal */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; @@ -992,7 +1146,7 @@ A5B30541299BEAAB0047F10C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; @@ -1046,7 +1200,7 @@ A5B30542299BEAAB0047F10C /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; @@ -1099,7 +1253,7 @@ A5D449A82B53AE7B000F5B83 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; @@ -1138,7 +1292,7 @@ A5D449A92B53AE7B000F5B83 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; @@ -1177,7 +1331,7 @@ A5D449AA2B53AE7B000F5B83 /* ReleaseLocal */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 38b26f606..734fcbc20 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -18,6 +18,7 @@ class AppDelegate: NSObject, ) /// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config + @IBOutlet private var menuAbout: NSMenuItem? @IBOutlet private var menuServices: NSMenu? @IBOutlet private var menuCheckForUpdates: NSMenuItem? @IBOutlet private var menuOpenConfig: NSMenuItem? @@ -36,6 +37,8 @@ class AppDelegate: NSObject, @IBOutlet private var menuCloseWindow: NSMenuItem? @IBOutlet private var menuCloseAllWindows: NSMenuItem? + @IBOutlet private var menuUndo: NSMenuItem? + @IBOutlet private var menuRedo: NSMenuItem? @IBOutlet private var menuCopy: NSMenuItem? @IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuPasteSelection: NSMenuItem? @@ -85,11 +88,14 @@ class AppDelegate: NSObject, /// The ghostty global state. Only one per process. let ghostty: Ghostty.App = Ghostty.App() - /// Manages our terminal windows. - let terminalManager: TerminalManager + /// The global undo manager for app-level state such as window restoration. + lazy var undoManager = ExpiringUndoManager() /// Our quick terminal. This starts out uninitialized and only initializes if used. - private var quickController: QuickTerminalController? = nil + private(set) lazy var quickController = QuickTerminalController( + ghostty, + position: derivedConfig.quickTerminalPosition + ) /// Manages updates let updaterController: SPUStandardUpdaterController @@ -114,7 +120,6 @@ class AppDelegate: NSObject, } override init() { - terminalManager = TerminalManager(ghostty) updaterController = SPUStandardUpdaterController( // Important: we must not start the updater here because we need to read our configuration // first to determine whether we're automatically checking, downloading, etc. The updater @@ -197,6 +202,16 @@ class AppDelegate: NSObject, name: .ghosttyBellDidRing, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyNewWindow(_:)), + name: Ghostty.Notification.ghosttyNewWindow, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyNewTab(_:)), + name: Ghostty.Notification.ghosttyNewTab, + object: nil) // Configure user notifications let actions = [ @@ -231,6 +246,9 @@ class AppDelegate: NSObject, ghostty_app_set_color_scheme(app, scheme) } + + // Setup our menu + setupMenuImages() } func applicationDidBecomeActive(_ notification: Notification) { @@ -248,8 +266,10 @@ class AppDelegate: NSObject, // is possible to have other windows in a few scenarios: // - if we're opening a URL since `application(_:openFile:)` is called before this. // - if we're restoring from persisted state - if terminalManager.windows.count == 0 && derivedConfig.initialWindow { - terminalManager.newWindow() + if TerminalController.all.isEmpty && derivedConfig.initialWindow { + undoManager.disableUndoRegistration() + _ = TerminalController.newWindow(ghostty) + undoManager.enableUndoRegistration() } } } @@ -269,7 +289,7 @@ class AppDelegate: NSObject, // NOTE(mitchellh): I don't think we need this check at all anymore. I'm keeping it // here because I don't want to remove it in a patch release cycle but we should // target removing it soon. - if (self.quickController == nil && windows.allSatisfy { !$0.isVisible }) { + if (windows.allSatisfy { !$0.isVisible }) { return .terminateNow } @@ -316,6 +336,13 @@ class AppDelegate: NSObject, } } + func applicationWillTerminate(_ notification: Notification) { + // We have no notifications we want to persist after death, + // so remove them all now. In the future we may want to be + // more selective and only remove surface-targeted notifications. + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + } + /// This is called when the application is already open and someone double-clicks the icon /// or clicks the dock icon. func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { @@ -327,10 +354,15 @@ class AppDelegate: NSObject, // This is possible with flag set to false if there a race where the // window is still initializing and is not visible but the user clicked // the dock icon. - guard terminalManager.windows.count == 0 else { return true } + guard TerminalController.all.isEmpty else { return true } + + // If the application isn't active yet then we don't want to process + // this because we're not ready. This happens sometimes in Xcode runs + // but I haven't seen it happen in releases. I'm unsure why. + guard applicationHasBecomeActive else { return true } // No visible windows, open a new one. - terminalManager.newWindow() + _ = TerminalController.newWindow(ghostty) return false } @@ -346,16 +378,24 @@ class AppDelegate: NSObject, var config = Ghostty.SurfaceConfiguration() if (isDirectory.boolValue) { - // When opening a directory, create a new tab in the main window with that as the working directory. + // When opening a directory, create a new tab in the main + // window with that as the working directory. // If no windows exist, a new one will be created. config.workingDirectory = filename - terminalManager.newTab(withBaseConfig: config) + _ = TerminalController.newTab(ghostty, withBaseConfig: config) } else { - // When opening a file, open a new window with that file as the command, - // and its parent directory as the working directory. - config.command = filename + // When opening a file, we want to execute the file. To do this, we + // don't override the command directly, because it won't load the + // profile/rc files for the shell, which is super important on macOS + // due to things like Homebrew. Instead, we set the command to + // `; exit` which is what Terminal and iTerm2 do. + config.initialInput = "\(filename); exit\n" + + // Set the parent directory to our working directory so that relative + // paths in scripts work. config.workingDirectory = (filename as NSString).deletingLastPathComponent - terminalManager.newWindow(withBaseConfig: config) + + _ = TerminalController.newWindow(ghostty, withBaseConfig: config) } return true @@ -366,6 +406,46 @@ class AppDelegate: NSObject, return dockMenu } + /// 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.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.menuChangeTitle?.setImageIfDesired(systemSymbolName: "pencil.line") + self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") + 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.3.layers.3d.top.filled") + } + /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. private func syncMenuShortcuts(_ config: Ghostty.Config) { guard ghostty.readiness == .ready else { return } @@ -386,6 +466,8 @@ class AppDelegate: NSObject, 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) @@ -442,10 +524,6 @@ class AppDelegate: NSObject, menu.keyEquivalentModifierMask = .init(swiftUIFlags: shortcut.modifiers) } - private func focusedSurface() -> ghostty_surface_t? { - return terminalManager.focusedSurface?.surface - } - // MARK: Notifications and Events /// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get @@ -530,11 +608,13 @@ class AppDelegate: NSObject, } @objc private func ghosttyBellDidRing(_ notification: Notification) { - // Bounce the dock icon if we're not focused. - NSApp.requestUserAttention(.informationalRequest) + if (ghostty.config.bellFeatures.contains(.attention)) { + // Bounce the dock icon if we're not focused. + NSApp.requestUserAttention(.informationalRequest) - // Handle setting the dock badge based on permissions - ghosttyUpdateBadgeForBell() + // Handle setting the dock badge based on permissions + ghosttyUpdateBadgeForBell() + } } private func ghosttyUpdateBadgeForBell() { @@ -576,6 +656,26 @@ class AppDelegate: NSObject, } } + @objc private func ghosttyNewWindow(_ notification: Notification) { + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? Ghostty.SurfaceConfiguration + _ = TerminalController.newWindow(ghostty, withBaseConfig: config) + } + + @objc private func ghosttyNewTab(_ notification: Notification) { + guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } + guard let window = surfaceView.window else { return } + + // We only want to listen to new tabs if the focused parent is + // a regular terminal controller. + guard window.windowController is TerminalController else { return } + + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? Ghostty.SurfaceConfiguration + + _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config) + } + private func setDockBadge(_ label: String? = "•") { NSApp.dockTile.badgeLabel = label NSApp.dockTile.display() @@ -611,7 +711,7 @@ class AppDelegate: NSObject, // Config could change keybindings, so update everything that depends on that syncMenuShortcuts(config) - terminalManager.relabelAllTabs() + TerminalController.all.forEach { $0.relabelTabs() } // Config could change window appearance. We wrap this in an async queue because when // this is called as part of application launch it can deadlock with an internal @@ -740,9 +840,11 @@ class AppDelegate: NSObject, //MARK: - GhosttyAppDelegate func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? { - for c in terminalManager.windows { - if let v = c.controller.surfaceTree?.findUUID(uuid: uuid) { - return v + for c in TerminalController.all { + for view in c.surfaceTree { + if view.uuid == uuid { + return view + } } } @@ -793,7 +895,7 @@ class AppDelegate: NSObject, } @IBAction func newWindow(_ sender: Any?) { - terminalManager.newWindow() + _ = TerminalController.newWindow(ghostty) // We also activate our app so that it becomes front. This may be // necessary for the dock menu. @@ -801,7 +903,7 @@ class AppDelegate: NSObject, } @IBAction func newTab(_ sender: Any?) { - terminalManager.newTab() + _ = TerminalController.newTab(ghostty) // We also activate our app so that it becomes front. This may be // necessary for the dock menu. @@ -809,7 +911,7 @@ class AppDelegate: NSObject, } @IBAction func closeAllWindows(_ sender: Any?) { - terminalManager.closeAllWindows() + TerminalController.closeAllWindows() AboutController.shared.hide() } @@ -827,14 +929,6 @@ class AppDelegate: NSObject, } @IBAction func toggleQuickTerminal(_ sender: Any) { - if quickController == nil { - quickController = QuickTerminalController( - ghostty, - position: derivedConfig.quickTerminalPosition - ) - } - - guard let quickController = self.quickController else { return } quickController.toggle() } @@ -871,6 +965,14 @@ class AppDelegate: NSObject, NSApplication.shared.arrangeInFront(sender) } + @IBAction func undo(_ sender: Any?) { + undoManager.undo() + } + + @IBAction func redo(_ sender: Any?) { + undoManager.redo() + } + private struct DerivedConfig { let initialWindow: Bool let shouldQuitAfterLastWindowClosed: Bool @@ -960,6 +1062,22 @@ extension AppDelegate: NSMenuItemValidation { // terminal window (not quick terminal). return NSApp.keyWindow is TerminalWindow + case #selector(undo(_:)): + if undoManager.canUndo { + item.title = "Undo \(undoManager.undoActionName)" + } else { + item.title = "Undo" + } + return undoManager.canUndo + + case #selector(redo(_:)): + if undoManager.canRedo { + item.title = "Redo \(undoManager.redoActionName)" + } else { + item.title = "Redo" + } + return undoManager.canRedo + default: return true } diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 828e82bd0..5cd6d9bec 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -14,6 +14,7 @@ + @@ -40,6 +41,7 @@ + @@ -57,6 +59,7 @@ + @@ -204,6 +207,19 @@ + + + + + + + + + + + + + @@ -236,18 +252,18 @@ - - - - - - + + + + + + diff --git a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift new file mode 100644 index 000000000..923d22c97 --- /dev/null +++ b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift @@ -0,0 +1,35 @@ +import AppKit +import AppIntents +import GhosttyKit + +struct CloseTerminalIntent: AppIntent { + static var title: LocalizedStringResource = "Close Terminal" + static var description = IntentDescription("Close an existing terminal.") + + @Parameter( + title: "Terminal", + description: "The terminal to close.", + ) + var terminal: TerminalEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = .background + + @MainActor + func perform() async throws -> some IntentResult { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + + guard let surfaceView = terminal.surfaceView else { + throw GhosttyIntentError.surfaceNotFound + } + + guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { + return .result() + } + + controller.closeSurface(surfaceView, withConfirmation: false) + return .result() + } +} diff --git a/macos/Sources/Features/App Intents/CommandPaletteIntent.swift b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift new file mode 100644 index 000000000..fa983054b --- /dev/null +++ b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift @@ -0,0 +1,38 @@ +import AppKit +import AppIntents + +/// App intent that invokes a command palette entry. +@available(macOS 14.0, *) +struct CommandPaletteIntent: AppIntent { + static var title: LocalizedStringResource = "Invoke Command Palette Action" + + @Parameter( + title: "Terminal", + description: "The terminal to base available commands from." + ) + var terminal: TerminalEntity + + @Parameter( + title: "Command", + description: "The command to invoke.", + optionsProvider: CommandQuery() + ) + var command: CommandEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = .background + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + let performed = surface.perform(action: command.action) + return .result(value: performed) + } +} diff --git a/macos/Sources/Features/App Intents/Entities/CommandEntity.swift b/macos/Sources/Features/App Intents/Entities/CommandEntity.swift new file mode 100644 index 000000000..f7abcc6de --- /dev/null +++ b/macos/Sources/Features/App Intents/Entities/CommandEntity.swift @@ -0,0 +1,128 @@ +import AppIntents + +// MARK: AppEntity + +@available(macOS 14.0, *) +struct CommandEntity: AppEntity { + let id: ID + + // Note: for macOS 26 we can move all the properties to @ComputedProperty. + + @Property(title: "Title") + var title: String + + @Property(title: "Description") + var description: String + + @Property(title: "Action") + var action: String + + /// The underlying data model + let command: Ghostty.Command + + /// A command identifier is a composite key based on the terminal and action. + struct ID: Hashable { + let terminalId: TerminalEntity.ID + let actionKey: String + + init(terminalId: TerminalEntity.ID, actionKey: String) { + self.terminalId = terminalId + self.actionKey = actionKey + } + } + + static var typeDisplayRepresentation: TypeDisplayRepresentation { + TypeDisplayRepresentation(name: "Command Palette Command") + } + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: LocalizedStringResource(stringLiteral: command.title), + subtitle: LocalizedStringResource(stringLiteral: command.description), + ) + } + + static var defaultQuery = CommandQuery() + + init(_ command: Ghostty.Command, for terminal: TerminalEntity) { + self.id = .init(terminalId: terminal.id, actionKey: command.actionKey) + self.command = command + self.title = command.title + self.description = command.description + self.action = command.action + } +} + +@available(macOS 14.0, *) +extension CommandEntity.ID: RawRepresentable { + var rawValue: String { + return "\(terminalId):\(actionKey)" + } + + init?(rawValue: String) { + let components = rawValue.split(separator: ":", maxSplits: 1) + guard components.count == 2 else { return nil } + + guard let terminalId = TerminalEntity.ID(uuidString: String(components[0])) else { + return nil + } + + self.terminalId = terminalId + self.actionKey = String(components[1]) + } +} + +// Required by AppEntity +@available(macOS 14.0, *) +extension CommandEntity.ID: EntityIdentifierConvertible { + static func entityIdentifier(for entityIdentifierString: String) -> CommandEntity.ID? { + .init(rawValue: entityIdentifierString) + } + + var entityIdentifierString: String { + rawValue + } +} + +// MARK: EntityQuery + +@available(macOS 14.0, *) +struct CommandQuery: EntityQuery { + // Inject our terminal parameter from our command palette intent. + @IntentParameterDependency(\.$terminal) + var commandPaletteIntent + + @MainActor + func entities(for identifiers: [CommandEntity.ID]) async throws -> [CommandEntity] { + // Extract unique terminal IDs to avoid fetching duplicates + let terminalIds = Set(identifiers.map(\.terminalId)) + let terminals = try await TerminalEntity.defaultQuery.entities(for: Array(terminalIds)) + + // Build a cache of terminals and their available commands + // This avoids repeated command fetching for the same terminal + typealias Tuple = (terminal: TerminalEntity, commands: [Ghostty.Command]) + let commandMap: [TerminalEntity.ID: Tuple] = + terminals.reduce(into: [:]) { result, terminal in + guard let commands = try? terminal.surfaceModel?.commands() else { return } + result[terminal.id] = (terminal: terminal, commands: commands) + } + + // Map each identifier to its corresponding CommandEntity. If a command doesn't + // exist it maps to nil and is removed via compactMap. + return identifiers.compactMap { id in + guard let (terminal, commands) = commandMap[id.terminalId], + let command = commands.first(where: { $0.actionKey == id.actionKey }) else { + return nil + } + + return CommandEntity(command, for: terminal) + } + } + + @MainActor + func suggestedEntities() async throws -> [CommandEntity] { + guard let terminal = commandPaletteIntent?.terminal, + let surface = terminal.surfaceModel else { return [] } + return try surface.commands().map { CommandEntity($0, for: terminal) } + } +} diff --git a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift new file mode 100644 index 000000000..e29fbba3f --- /dev/null +++ b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift @@ -0,0 +1,139 @@ +import AppKit +import AppIntents +import SwiftUI + +struct TerminalEntity: AppEntity { + let id: UUID + + @Property(title: "Title") + var title: String + + @Property(title: "Working Directory") + var workingDirectory: String? + + @Property(title: "Kind") + var kind: Kind + + @MainActor + @DeferredProperty(title: "Full Contents") + @available(macOS 26.0, *) + var screenContents: String? { + get async { + guard let surfaceView else { return nil } + return surfaceView.cachedScreenContents.get() + } + } + + @MainActor + @DeferredProperty(title: "Visible Contents") + @available(macOS 26.0, *) + var visibleContents: String? { + get async { + guard let surfaceView else { return nil } + return surfaceView.cachedVisibleContents.get() + } + } + + var screenshot: Image? + + static var typeDisplayRepresentation: TypeDisplayRepresentation { + TypeDisplayRepresentation(name: "Terminal") + } + + @MainActor + var displayRepresentation: DisplayRepresentation { + var rep = DisplayRepresentation(title: "\(title)") + if let screenshot, + let nsImage = ImageRenderer(content: screenshot).nsImage, + let data = nsImage.tiffRepresentation { + rep.image = .init(data: data) + } + + return rep + } + + /// Returns the view associated with this entity. This may no longer exist. + @MainActor + var surfaceView: Ghostty.SurfaceView? { + Self.defaultQuery.all.first { $0.uuid == self.id } + } + + @MainActor + var surfaceModel: Ghostty.Surface? { + surfaceView?.surfaceModel + } + + static var defaultQuery = TerminalQuery() + + init(_ view: Ghostty.SurfaceView) { + self.id = view.uuid + self.title = view.title + self.workingDirectory = view.pwd + self.screenshot = view.screenshot() + + // Determine the kind based on the window controller type + if view.window?.windowController is QuickTerminalController { + self.kind = .quick + } else { + self.kind = .normal + } + } +} + +extension TerminalEntity { + enum Kind: String, AppEnum { + case normal + case quick + + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Kind") + + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .normal: .init(title: "Normal"), + .quick: .init(title: "Quick") + ] + } +} + +struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery { + @MainActor + func entities(for identifiers: [TerminalEntity.ID]) async throws -> [TerminalEntity] { + return all.filter { + identifiers.contains($0.uuid) + }.map { + TerminalEntity($0) + } + } + + @MainActor + func entities(matching string: String) async throws -> [TerminalEntity] { + return all.filter { + $0.title.localizedCaseInsensitiveContains(string) + }.map { + TerminalEntity($0) + } + } + + @MainActor + func allEntities() async throws -> [TerminalEntity] { + return all.map { TerminalEntity($0) } + } + + @MainActor + func suggestedEntities() async throws -> [TerminalEntity] { + return try await allEntities() + } + + @MainActor + var all: [Ghostty.SurfaceView] { + // Find all of our terminal windows. This will include the quick terminal + // but only if it was previously opened. + let controllers = NSApp.windows.compactMap { + $0.windowController as? BaseTerminalController + } + + // Get all our surfaces + return controllers.flatMap { + $0.surfaceTree.root?.leaves() ?? [] + } + } +} diff --git a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift new file mode 100644 index 000000000..1cbaa9d68 --- /dev/null +++ b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift @@ -0,0 +1,69 @@ +import AppKit +import AppIntents + +/// App intent that retrieves details about a specific terminal. +struct GetTerminalDetailsIntent: AppIntent { + static var title: LocalizedStringResource = "Get Details of Terminal" + + @Parameter( + title: "Detail", + description: "The detail to extract about a terminal." + ) + var detail: TerminalDetail + + @Parameter( + title: "Terminal", + description: "The terminal to extract information about." + ) + var terminal: TerminalEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = .background + + static var parameterSummary: some ParameterSummary { + Summary("Get \(\.$detail) from \(\.$terminal)") + } + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + + switch detail { + case .title: return .result(value: terminal.title) + case .workingDirectory: return .result(value: terminal.workingDirectory) + case .allContents: + guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound } + return .result(value: view.cachedScreenContents.get()) + case .selectedText: + guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound } + return .result(value: view.accessibilitySelectedText()) + case .visibleText: + guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound } + return .result(value: view.cachedVisibleContents.get()) + } + } +} + +// MARK: TerminalDetail + +enum TerminalDetail: String { + case title + case workingDirectory + case allContents + case selectedText + case visibleText +} + +extension TerminalDetail: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Detail") + + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .title: .init(title: "Title"), + .workingDirectory: .init(title: "Working Directory"), + .allContents: .init(title: "Full Contents"), + .selectedText: .init(title: "Selected Text"), + .visibleText: .init(title: "Visible Text"), + ] +} diff --git a/macos/Sources/Features/App Intents/GhosttyIntentError.swift b/macos/Sources/Features/App Intents/GhosttyIntentError.swift new file mode 100644 index 000000000..c52b7a52e --- /dev/null +++ b/macos/Sources/Features/App Intents/GhosttyIntentError.swift @@ -0,0 +1,13 @@ +enum GhosttyIntentError: Error, CustomLocalizedStringResourceConvertible { + case appUnavailable + case surfaceNotFound + case permissionDenied + + var localizedStringResource: LocalizedStringResource { + switch self { + case .appUnavailable: "The Ghostty app isn't properly initialized." + case .surfaceNotFound: "The terminal no longer exists." + case .permissionDenied: "Ghostty doesn't allow Shortcuts." + } + } +} diff --git a/macos/Sources/Features/App Intents/InputIntent.swift b/macos/Sources/Features/App Intents/InputIntent.swift new file mode 100644 index 000000000..17c97fbbb --- /dev/null +++ b/macos/Sources/Features/App Intents/InputIntent.swift @@ -0,0 +1,317 @@ +import AppKit +import AppIntents + +/// App intent to input text in a terminal. +struct InputTextIntent: AppIntent { + static var title: LocalizedStringResource = "Input Text to Terminal" + + @Parameter( + title: "Text", + description: "The text to input to the terminal. The text will be inputted as if it was pasted.", + inputOptions: String.IntentInputOptions( + capitalizationType: .none, + multiline: true, + autocorrect: false, + smartQuotes: false, + smartDashes: false + ) + ) + var text: String + + @Parameter( + title: "Terminal", + description: "The terminal to scope this action to." + ) + var terminal: TerminalEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = [.background, .foreground] + + @MainActor + func perform() async throws -> some IntentResult { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + surface.sendText(text) + return .result() + } +} + +/// App intent to trigger a keyboard event. +struct KeyEventIntent: AppIntent { + static var title: LocalizedStringResource = "Send Keyboard Event to Terminal" + static var description = IntentDescription("Simulate a keyboard event. This will not handle text encoding; use the 'Input Text' action for that.") + + @Parameter( + title: "Key", + description: "The key to send to the terminal.", + default: .enter + ) + var key: Ghostty.Input.Key + + @Parameter( + title: "Modifier(s)", + description: "The modifiers to send with the key event.", + default: [] + ) + var mods: [KeyEventMods] + + @Parameter( + title: "Event Type", + description: "A key press or release.", + default: .press + ) + var action: Ghostty.Input.Action + + @Parameter( + title: "Terminal", + description: "The terminal to scope this action to." + ) + var terminal: TerminalEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = [.background, .foreground] + + @MainActor + func perform() async throws -> some IntentResult { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + // Convert KeyEventMods array to Ghostty.Input.Mods + let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in + result.union(mod.ghosttyMod) + } + + let keyEvent = Ghostty.Input.KeyEvent( + key: key, + action: action, + mods: ghosttyMods + ) + surface.sendKeyEvent(keyEvent) + + return .result() + } +} + +// MARK: MouseButtonIntent + +/// App intent to trigger a mouse button event. +struct MouseButtonIntent: AppIntent { + static var title: LocalizedStringResource = "Send Mouse Button Event to Terminal" + + @Parameter( + title: "Button", + description: "The mouse button to press or release.", + default: .left + ) + var button: Ghostty.Input.MouseButton + + @Parameter( + title: "Action", + description: "Whether to press or release the button.", + default: .press + ) + var action: Ghostty.Input.MouseState + + @Parameter( + title: "Modifier(s)", + description: "The modifiers to send with the mouse event.", + default: [] + ) + var mods: [KeyEventMods] + + @Parameter( + title: "Terminal", + description: "The terminal to scope this action to." + ) + var terminal: TerminalEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = [.background, .foreground] + + @MainActor + func perform() async throws -> some IntentResult { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + // Convert KeyEventMods array to Ghostty.Input.Mods + let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in + result.union(mod.ghosttyMod) + } + + let mouseEvent = Ghostty.Input.MouseButtonEvent( + action: action, + button: button, + mods: ghosttyMods + ) + surface.sendMouseButton(mouseEvent) + + return .result() + } +} + +/// App intent to send a mouse position event. +struct MousePosIntent: AppIntent { + static var title: LocalizedStringResource = "Send Mouse Position Event to Terminal" + static var description = IntentDescription("Send a mouse position event to the terminal. This reports the cursor position for mouse tracking.") + + @Parameter( + title: "X Position", + description: "The horizontal position of the mouse cursor in pixels.", + default: 0 + ) + var x: Double + + @Parameter( + title: "Y Position", + description: "The vertical position of the mouse cursor in pixels.", + default: 0 + ) + var y: Double + + @Parameter( + title: "Modifier(s)", + description: "The modifiers to send with the mouse position event.", + default: [] + ) + var mods: [KeyEventMods] + + @Parameter( + title: "Terminal", + description: "The terminal to scope this action to." + ) + var terminal: TerminalEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = [.background, .foreground] + + @MainActor + func perform() async throws -> some IntentResult { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + // Convert KeyEventMods array to Ghostty.Input.Mods + let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in + result.union(mod.ghosttyMod) + } + + let mousePosEvent = Ghostty.Input.MousePosEvent( + x: x, + y: y, + mods: ghosttyMods + ) + surface.sendMousePos(mousePosEvent) + + return .result() + } +} + +/// App intent to send a mouse scroll event. +struct MouseScrollIntent: AppIntent { + static var title: LocalizedStringResource = "Send Mouse Scroll Event to Terminal" + static var description = IntentDescription("Send a mouse scroll event to the terminal with configurable precision and momentum.") + + @Parameter( + title: "X Scroll Delta", + description: "The horizontal scroll amount.", + default: 0 + ) + var x: Double + + @Parameter( + title: "Y Scroll Delta", + description: "The vertical scroll amount.", + default: 0 + ) + var y: Double + + @Parameter( + title: "High Precision", + description: "Whether this is a high-precision scroll event (e.g., from trackpad).", + default: false + ) + var precision: Bool + + @Parameter( + title: "Momentum Phase", + description: "The momentum phase for inertial scrolling.", + default: Ghostty.Input.Momentum.none + ) + var momentum: Ghostty.Input.Momentum + + @Parameter( + title: "Terminal", + description: "The terminal to scope this action to." + ) + var terminal: TerminalEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = [.background, .foreground] + + @MainActor + func perform() async throws -> some IntentResult { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + let scrollEvent = Ghostty.Input.MouseScrollEvent( + x: x, + y: y, + mods: .init(precision: precision, momentum: momentum) + ) + surface.sendMouseScroll(scrollEvent) + + return .result() + } +} + +// MARK: Mods + +enum KeyEventMods: String, AppEnum, CaseIterable { + case shift + case control + case option + case command + + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Modifier Key") + + static var caseDisplayRepresentations: [KeyEventMods : DisplayRepresentation] = [ + .shift: "Shift", + .control: "Control", + .option: "Option", + .command: "Command" + ] + + var ghosttyMod: Ghostty.Input.Mods { + switch self { + case .shift: .shift + case .control: .ctrl + case .option: .alt + case .command: .super + } + } +} diff --git a/macos/Sources/Features/App Intents/IntentPermission.swift b/macos/Sources/Features/App Intents/IntentPermission.swift new file mode 100644 index 000000000..210d2cb2e --- /dev/null +++ b/macos/Sources/Features/App Intents/IntentPermission.swift @@ -0,0 +1,57 @@ +import AppKit + +/// Requests permission for Shortcuts app to interact with Ghostty +/// +/// This function displays a permission dialog asking the user to allow Shortcuts +/// to interact with Ghostty. The permission is automatically cached for 10 minutes +/// if the user selects "Allow", meaning subsequent intent calls won't show the dialog +/// again during that time period. +/// +/// The permission uses a shared UserDefaults key across all intents, so granting +/// permission for one intent allows all Ghostty intents to execute without additional +/// prompts for the duration of the cache period. +/// +/// - Returns: `true` if permission is granted, `false` if denied +/// +/// ## Usage +/// Add this check at the beginning of any App Intent's `perform()` method: +/// ```swift +/// @MainActor +/// func perform() async throws -> some IntentResult { +/// guard await requestIntentPermission() else { +/// throw GhosttyIntentError.permissionDenied +/// } +/// // ... continue with intent implementation +/// } +/// ``` +func requestIntentPermission() async -> Bool { + await withCheckedContinuation { continuation in + Task { @MainActor in + if let delegate = NSApp.delegate as? AppDelegate { + switch (delegate.ghostty.config.macosShortcuts) { + case .allow: + continuation.resume(returning: true) + return + + case .deny: + continuation.resume(returning: false) + return + + case .ask: + // Continue with the permission dialog + break + } + } + + + PermissionRequest.show( + "com.mitchellh.ghostty.shortcutsPermission", + message: "Allow Shortcuts to interact with Ghostty?", + allowDuration: .forever, + rememberDuration: nil, + ) { response in + continuation.resume(returning: response) + } + } + } +} diff --git a/macos/Sources/Features/App Intents/KeybindIntent.swift b/macos/Sources/Features/App Intents/KeybindIntent.swift new file mode 100644 index 000000000..b31da4a50 --- /dev/null +++ b/macos/Sources/Features/App Intents/KeybindIntent.swift @@ -0,0 +1,35 @@ +import AppKit +import AppIntents + +struct KeybindIntent: AppIntent { + static var title: LocalizedStringResource = "Invoke a Keybind Action" + + @Parameter( + title: "Terminal", + description: "The terminal to invoke the action on." + ) + var terminal: TerminalEntity + + @Parameter( + title: "Action", + description: "The keybind action to invoke. This can be any valid keybind action you could put in a configuration file." + ) + var action: String + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = [.background, .foreground] + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + let performed = surface.perform(action: action) + return .result(value: performed) + } +} diff --git a/macos/Sources/Features/App Intents/NewTerminalIntent.swift b/macos/Sources/Features/App Intents/NewTerminalIntent.swift new file mode 100644 index 000000000..9b95208bb --- /dev/null +++ b/macos/Sources/Features/App Intents/NewTerminalIntent.swift @@ -0,0 +1,168 @@ +import AppKit +import AppIntents +import GhosttyKit + +/// App intent that allows creating a new terminal window or tab. +/// +/// This requires macOS 15 or greater because we use features of macOS 15 here. +@available(macOS 15.0, *) +struct NewTerminalIntent: AppIntent { + static var title: LocalizedStringResource = "New Terminal" + static var description = IntentDescription("Create a new terminal.") + + @Parameter( + title: "Location", + description: "The location that the terminal should be created.", + default: .window + ) + var location: NewTerminalLocation + + @Parameter( + title: "Command", + description: "Command to execute within your configured shell.", + ) + var command: String? + + @Parameter( + title: "Working Directory", + description: "The working directory to open in the terminal.", + supportedContentTypes: [.folder] + ) + var workingDirectory: IntentFile? + + @Parameter( + title: "Environment Variables", + description: "Environment variables in `KEY=VALUE` format.", + default: [] + ) + var env: [String] + + @Parameter( + title: "Parent Terminal", + description: "The terminal to inherit the base configuration from." + ) + var parent: TerminalEntity? + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = .foreground(.immediate) + + @available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes") + static var openAppWhenRun = true + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + guard let appDelegate = NSApp.delegate as? AppDelegate else { + throw GhosttyIntentError.appUnavailable + } + let ghostty = appDelegate.ghostty + + var config = Ghostty.SurfaceConfiguration() + + // We don't run command as "command" and instead use "initialInput" so + // that we can get all the login scripts to setup things like PATH. + if let command { + config.initialInput = "\(command); exit\n" + } + + // If we were given a working directory then open that directory + if let url = workingDirectory?.fileURL { + let dir = url.hasDirectoryPath ? url : url.deletingLastPathComponent() + config.workingDirectory = dir.path(percentEncoded: false) + } + + // Parse environment variables from KEY=VALUE format + for envVar in env { + if let separatorIndex = envVar.firstIndex(of: "=") { + let key = String(envVar[...NewDirection? { + switch self { + case .splitLeft: return .left + case .splitRight: return .right + case .splitUp: return .up + case .splitDown: return .down + default: return nil + } + } +} + +extension NewTerminalLocation: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Location") + + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .tab: .init(title: "Tab"), + .window: .init(title: "Window"), + .splitLeft: .init(title: "Split Left"), + .splitRight: .init(title: "Split Right"), + .splitUp: .init(title: "Split Up"), + .splitDown: .init(title: "Split Down"), + ] +} diff --git a/macos/Sources/Features/App Intents/QuickTerminalIntent.swift b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift new file mode 100644 index 000000000..2e6c9850c --- /dev/null +++ b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift @@ -0,0 +1,32 @@ +import AppKit +import AppIntents + +struct QuickTerminalIntent: AppIntent { + static var title: LocalizedStringResource = "Open the Quick Terminal" + static var description = IntentDescription("Open the Quick Terminal. If it is already open, then do nothing.") + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = .background + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue<[TerminalEntity]> { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + + guard let delegate = NSApp.delegate as? AppDelegate else { + throw GhosttyIntentError.appUnavailable + } + + // This is safe to call even if it is already shown. + let c = delegate.quickController + c.animateIn() + + // Grab all our terminals + let terminals = c.surfaceTree.root?.leaves().map { + TerminalEntity($0) + } ?? [] + + return .result(value: terminals) + } +} diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift index 4d522067e..8a461699f 100644 --- a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift +++ b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift @@ -4,12 +4,26 @@ extension View { /// Returns the ghostty icon to use for views. func ghosttyIconImage() -> Image { #if os(macOS) + // If we have a specific icon set, then use that if let delegate = NSApplication.shared.delegate as? AppDelegate, let nsImage = delegate.appIcon { return Image(nsImage: nsImage) } + + // Grab the icon from the running application. This is the best way + // I've found so far to get the proper icon for our current icon + // tinting and so on with macOS Tahoe + if let icon = NSRunningApplication.current.icon { + return Image(nsImage: icon) + } + + // Get our defined application icon image. + if let nsImage = NSApp.applicationIconImage { + return Image(nsImage: nsImage) + } #endif + // Fall back to a static representation return Image("AppIconImage") } } diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 57a76dd43..d02828494 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -17,32 +17,19 @@ struct TerminalCommandPaletteView: View { // The commands available to the command palette. private var commandOptions: [CommandOption] { - guard let surface = surfaceView.surface else { return [] } - - var ptr: UnsafeMutablePointer? = nil - var count: Int = 0 - ghostty_surface_commands(surface, &ptr, &count) - guard let ptr else { return [] } - - let buffer = UnsafeBufferPointer(start: ptr, count: count) - return Array(buffer).filter { c in - let key = String(cString: c.action_key) - switch (key) { - case "toggle_tab_overview", - "toggle_window_decorations": - return false - default: - return true - } - }.map { c in - let action = String(cString: c.action) - return CommandOption( - title: String(cString: c.title), - description: String(cString: c.description), - symbols: ghosttyConfig.keyboardShortcut(for: action)?.keyList - ) { - onAction(action) + guard let surface = surfaceView.surfaceModel else { return [] } + do { + return try surface.commands().map { c in + return CommandOption( + title: c.title, + description: c.description, + symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList + ) { + onAction(c.action) + } } + } catch { + return [] } } diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift index 935c2fb03..ae77535be 100644 --- a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -141,12 +141,7 @@ fileprivate func cgEventFlagsChangedHandler( guard let event: NSEvent = .init(cgEvent: cgEvent) else { return result } // Build our event input and call ghostty - var key_ev = ghostty_input_key_s() - key_ev.action = GHOSTTY_ACTION_PRESS - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) - key_ev.text = nil - key_ev.composing = false + let key_ev = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) if (ghostty_app_key(ghostty, key_ev)) { GlobalEventTap.logger.info("global key event handled event=\(event)") return nil diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 1abe30da1..3bd8bc18f 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -21,6 +21,14 @@ class QuickTerminalController: BaseTerminalController { // The active space when the quick terminal was last shown. private var previousActiveSpace: CGSSpace? = nil + /// The window frame saved when the quick terminal's surface tree becomes empty. + /// + /// This preserves the user's window size and position when all terminal surfaces + /// are closed (e.g., via the `exit` command). When a new surface is created, + /// the window will be restored to this frame, preventing SwiftUI from resetting + /// the window to its default minimum size. + private var lastClosedFrame: NSRect? = nil + /// Non-nil if we have hidden dock state. private var hiddenDock: HiddenDock? = nil @@ -30,11 +38,15 @@ class QuickTerminalController: BaseTerminalController { init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree tree: Ghostty.SplitNode? = nil + surfaceTree tree: SplitTree? = nil ) { self.position = position self.derivedConfig = DerivedConfig(ghostty.config) - super.init(ghostty, baseConfig: base, surfaceTree: tree) + + // Important detail here: we initialize with an empty surface tree so + // that we don't start a terminal process. This gets started when the + // first terminal is shown in `animateIn`. + super.init(ghostty, baseConfig: base, surfaceTree: .init()) // Setup our notifications for behaviors let center = NotificationCenter.default @@ -53,6 +65,12 @@ class QuickTerminalController: BaseTerminalController { selector: #selector(ghosttyConfigDidChange(_:)), name: .ghosttyConfigDidChange, object: nil) + center.addObserver( + self, + selector: #selector(closeWindow(_:)), + name: .ghosttyCloseWindow, + object: nil + ) center.addObserver( self, selector: #selector(onNewTab), @@ -185,13 +203,51 @@ class QuickTerminalController: BaseTerminalController { // MARK: Base Controller Overrides - override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { super.surfaceTreeDidChange(from: from, to: to) - // If our surface tree is nil then we animate the window out. - if (to == nil) { + // If our surface tree is nil then we animate the window out. We + // defer reinitializing the tree to save some memory here. + if to.isEmpty { animateOut() + return } + + // If we're not empty (e.g. this isn't the first set) and we're + // not visible, then we animate in. This allows us to show the quick + // terminal when things such as undo/redo are done. + if !from.isEmpty && !visible { + animateIn() + return + } + } + + override func closeSurface( + _ node: SplitTree.Node, + withConfirmation: Bool = true + ) { + // If this isn't the root then we're dealing with a split closure. + if surfaceTree.root != node { + super.closeSurface(node, withConfirmation: withConfirmation) + return + } + + // If this isn't a final leaf then we're dealing with a split closure + guard case .leaf(let surface) = node else { + super.closeSurface(node, withConfirmation: withConfirmation) + return + } + + // If its the root, we check if the process exited. If it did, + // then we do empty the tree. + if surface.processExited { + surfaceTree = .init() + return + } + + // If its the root then we just animate out. We never actually allow + // the surface to fully close. + animateOut() } // MARK: Methods @@ -230,17 +286,18 @@ class QuickTerminalController: BaseTerminalController { // Set previous active space self.previousActiveSpace = CGSSpace.active() + // If our surface tree is empty then we initialize a new terminal. The surface + // tree can be empty if for example we run "exit" in the terminal and force + // animate out. + if surfaceTree.isEmpty, + let ghostty_app = ghostty.app { + let view = Ghostty.SurfaceView(ghostty_app, baseConfig: nil) + surfaceTree = SplitTree(view: view) + focusedSurface = view + } + // Animate the window in animateWindowIn(window: window, from: position) - - // If our surface tree is nil then we initialize a new terminal. The surface - // tree can be nil if for example we run "eixt" in the terminal and force - // animate out. - if (surfaceTree == nil) { - let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil) - surfaceTree = .leaf(leaf) - focusedSurface = leaf.surface - } } func animateOut() { @@ -262,6 +319,12 @@ class QuickTerminalController: BaseTerminalController { private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = derivedConfig.quickTerminalScreen.screen else { return } + // Restore our previous frame if we have one + if let lastClosedFrame { + window.setFrame(lastClosedFrame, display: false) + self.lastClosedFrame = nil + } + // Move our window off screen to the top position.setInitial(in: window, on: screen) @@ -372,6 +435,12 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { + // Save the current window frame before animating out. This preserves + // the user's preferred window size and position for when the quick + // terminal is reactivated with a new surface. Without this, SwiftUI + // would reset the window to its minimum content size. + lastClosedFrame = window.frame + // If we hid the dock then we unhide it. hiddenDock = nil diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index a06e7d151..f60f94211 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -5,7 +5,7 @@ class ServiceProvider: NSObject { static private let errorNoString = NSString(string: "Could not load any text from the clipboard.") /// The target for an open operation - enum OpenTarget { + private enum OpenTarget { case tab case window } @@ -15,7 +15,7 @@ class ServiceProvider: NSObject { userData: String?, error: AutoreleasingUnsafeMutablePointer ) { - openTerminalFromPasteboard(pasteboard: pasteboard, target: .tab, error: error) + openTerminal(from: pasteboard, target: .tab, error: error) } @objc func openWindow( @@ -23,47 +23,39 @@ class ServiceProvider: NSObject { userData: String?, error: AutoreleasingUnsafeMutablePointer ) { - openTerminalFromPasteboard(pasteboard: pasteboard, target: .window, error: error) + openTerminal(from: pasteboard, target: .window, error: error) } - @inline(__always) - private func openTerminalFromPasteboard( - pasteboard: NSPasteboard, + private func openTerminal( + from pasteboard: NSPasteboard, target: OpenTarget, error: AutoreleasingUnsafeMutablePointer ) { - guard let objs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [NSURL] else { + guard let delegate = NSApp.delegate as? AppDelegate else { return } + + guard let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else { error.pointee = Self.errorNoString return } - let urlObjects = objs.map { $0 as URL } - openTerminal(urlObjects, target: target) - } - - private func openTerminal(_ urls: [URL], target: OpenTarget) { - guard let delegateRaw = NSApp.delegate else { return } - guard let delegate = delegateRaw as? AppDelegate else { return } - let terminalManager = delegate.terminalManager - - let uniqueCwds: Set = Set( - urls.map { url -> URL in - // We only open in directories. + // Build a set of unique directory URLs to open. File paths are truncated + // to their directories because that's the only thing we can open. + let directoryURLs = Set( + pathURLs.map { url -> URL in url.hasDirectoryPath ? url : url.deletingLastPathComponent() } ) - for cwd in uniqueCwds { - // Build our config + for url in directoryURLs { var config = Ghostty.SurfaceConfiguration() - config.workingDirectory = cwd.path(percentEncoded: false) + config.workingDirectory = url.path(percentEncoded: false) switch (target) { case .window: - terminalManager.newWindow(withBaseConfig: config) + _ = TerminalController.newWindow(delegate.ghostty, withBaseConfig: config) case .tab: - terminalManager.newTab(withBaseConfig: config) + _ = TerminalController.newTab(delegate.ghostty, withBaseConfig: config) } } diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift new file mode 100644 index 000000000..b353f6cbe --- /dev/null +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -0,0 +1,1284 @@ +import AppKit + +/// SplitTree represents a tree of views that can be divided. +struct SplitTree: Codable { + /// The root of the tree. This can be nil to indicate the tree is empty. + let root: Node? + + /// The node that is currently zoomed. A zoomed split is expected to take up the full + /// size of the view area where the splits are shown. + let zoomed: Node? + + /// A single node in the tree is either a leaf node (a view) or a split (has a + /// left/right or top/bottom). + indirect enum Node: Codable { + case leaf(view: ViewType) + case split(Split) + + struct Split: Equatable, Codable { + let direction: Direction + let ratio: Double + let left: Node + let right: Node + } + } + + enum Direction: Codable { + case horizontal // Splits are laid out left and right + case vertical // Splits are laid out top and bottom + } + + /// The path to a specific node in the tree. + struct Path { + let path: [Component] + + var isEmpty: Bool { path.isEmpty } + + enum Component { + case left + case right + } + } + + /// Spatial representation of the split tree. This can be used to better understand + /// its physical representation to perform tasks such as navigation. + struct Spatial { + let slots: [Slot] + + /// A single slot within the spatial mapping of a tree. Note that the bounds are + /// _relative_. They can't be mapped to physical pixels because the SplitTree + /// isn't aware of actual rendering. But relative to each other the bounds are + /// correct. + struct Slot { + let node: Node + let bounds: CGRect + } + + /// Direction for spatial navigation within the split tree. + enum Direction { + case left + case right + case up + case down + } + } + + enum SplitError: Error { + case viewNotFound + } + + enum NewDirection { + case left + case right + case down + case up + } + + /// The direction that focus can move from a node. + enum FocusDirection { + // Follow a consistent tree-like structure. + case previous + case next + + // Spatially-aware navigation targets. These take into account the + // layout to find the spatially correct node to move to. Spatial navigation + // is always from the top-left corner for now. + case spatial(Spatial.Direction) + } +} + +// MARK: SplitTree + +extension SplitTree { + var isEmpty: Bool { + root == nil + } + + /// Returns true if this tree is split. + var isSplit: Bool { + if case .split = root { true } else { false } + } + + init() { + self.init(root: nil, zoomed: nil) + } + + init(view: ViewType) { + self.init(root: .leaf(view: view), zoomed: nil) + } + + /// Checks if the tree contains the specified node. + /// + /// Note that SplitTree implements Sequence on views so there's already a `contains` + /// for views too. + /// + /// - Parameter node: The node to search for in the tree + /// - Returns: True if the node exists in the tree, false otherwise + func contains(_ node: Node) -> Bool { + guard let root else { return false } + return root.path(to: node) != nil + } + + /// Insert a new view at the given view point by creating a split in the given direction. + /// This will always reset the zoomed state of the tree. + func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { + guard let root else { throw SplitError.viewNotFound } + return .init( + root: try root.insert(view: view, at: at, direction: direction), + zoomed: nil) + } + + /// Remove a node from the tree. If the node being removed is part of a split, + /// the sibling node takes the place of the parent split. + func remove(_ target: Node) -> Self { + guard let root else { return self } + + // If we're removing the root itself, return an empty tree + if root == target { + return .init(root: nil, zoomed: nil) + } + + // Otherwise, try to remove from the tree + let newRoot = root.remove(target) + + // Update zoomed if it was the removed node + let newZoomed = (zoomed == target) ? nil : zoomed + + return .init(root: newRoot, zoomed: newZoomed) + } + + /// Replace a node in the tree with a new node. + func replace(node: Node, with newNode: Node) throws -> Self { + guard let root else { throw SplitError.viewNotFound } + + // Get the path to the node we want to replace + guard let path = root.path(to: node) else { + throw SplitError.viewNotFound + } + + // Replace the node + let newRoot = try root.replaceNode(at: path, with: newNode) + + // Update zoomed if it was the replaced node + let newZoomed = (zoomed == node) ? newNode : zoomed + + return .init(root: newRoot, zoomed: newZoomed) + } + + /// Find the next view to focus based on the current focused node and direction + func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? { + guard let root else { return nil } + + switch direction { + case .previous: + // For previous, we traverse in order and find the previous leaf from our leftmost + let allLeaves = root.leaves() + let currentView = currentNode.leftmostLeaf() + guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else { + // Shouldn't be possible leftmostLeaf can't return something that doesn't exist! + return nil + } + let index = allLeaves.indexWrapping(before: currentIndex) + return allLeaves[index] + + case .next: + // For previous, we traverse in order and find the next leaf from our rightmost + let allLeaves = root.leaves() + let currentView = currentNode.rightmostLeaf() + guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else { + return nil + } + let index = allLeaves.indexWrapping(after: currentIndex) + return allLeaves[index] + + case .spatial(let spatialDirection): + // Get spatial representation and find best candidate + let spatial = root.spatial() + let nodes = spatial.slots(in: spatialDirection, from: currentNode) + + // If we have no nodes in the direction specified then we don't do + // anything. + if nodes.isEmpty { + return nil + } + + // Extract the view from the best candidate node. The best candidate + // node is the closest leaf node. If we have no leaves (impossible?) + // just use the first node. + let bestNode = nodes.first(where: { + if case .leaf = $0.node { return true } else { return false } + }) ?? nodes[0] + switch bestNode.node { + case .leaf(let view): + return view + + case .split: + // If the best candidate is a split node, use its the leaf/rightmost + // depending on our spatial direction. + return switch (spatialDirection) { + case .up, .left: bestNode.node.leftmostLeaf() + case .down, .right: bestNode.node.rightmostLeaf() + } + } + } + } + + /// Equalize all splits in the tree so that each split's ratio is based on the + /// relative weight (number of leaves) of its children. + func equalize() -> Self { + guard let root else { return self } + let newRoot = root.equalize() + return .init(root: newRoot, zoomed: zoomed) + } + + /// Resize a node in the tree by the given pixel amount in the specified direction. + /// + /// This method adjusts the split ratios of the tree to accommodate the requested resize + /// operation. For up/down resizing, it finds the nearest parent vertical split and adjusts + /// its ratio. For left/right resizing, it finds the nearest parent horizontal split. + /// The bounds parameter is used to construct the spatial tree representation which is + /// needed to calculate the current pixel dimensions. + /// + /// This will always reset the zoomed state. + /// + /// - Parameters: + /// - node: The node to resize + /// - by: The number of pixels to resize by + /// - direction: The direction to resize in (up, down, left, right) + /// - bounds: The bounds used to construct the spatial tree representation + /// - Returns: A new SplitTree with the adjusted split ratios + /// - Throws: SplitError.viewNotFound if the node is not found in the tree or no suitable parent split exists + func resize(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self { + guard let root else { throw SplitError.viewNotFound } + + // Find the path to the target node + guard let path = root.path(to: node) else { + throw SplitError.viewNotFound + } + + // Determine which type of split we need to find based on resize direction + let targetSplitDirection: Direction = switch direction { + case .up, .down: .vertical + case .left, .right: .horizontal + } + + // Find the nearest parent split of the correct type by walking up the path + var splitPath: Path? + var splitNode: Node? + + for i in stride(from: path.path.count - 1, through: 0, by: -1) { + let parentPath = Path(path: Array(path.path.prefix(i))) + if let parent = root.node(at: parentPath), case .split(let split) = parent { + if split.direction == targetSplitDirection { + splitPath = parentPath + splitNode = parent + break + } + } + } + + guard let splitPath = splitPath, + let splitNode = splitNode, + case .split(let split) = splitNode else { + throw SplitError.viewNotFound + } + + // Get current spatial representation to calculate pixel dimensions + let spatial = root.spatial(within: bounds.size) + guard let splitSlot = spatial.slots.first(where: { $0.node == splitNode }) else { + throw SplitError.viewNotFound + } + + // Calculate the new ratio based on pixel change + let pixelOffset = Double(pixels) + let newRatio: Double + + switch (split.direction, direction) { + case (.horizontal, .left): + // Moving left boundary: decrease left side + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.width))) + case (.horizontal, .right): + // Moving right boundary: increase left side + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.width))) + case (.vertical, .up): + // Moving top boundary: decrease top side + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.height))) + case (.vertical, .down): + // Moving bottom boundary: increase top side + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.height))) + default: + // Direction doesn't match split type - shouldn't happen due to earlier logic + throw SplitError.viewNotFound + } + + // Create new split with adjusted ratio + let newSplit = Node.Split( + direction: split.direction, + ratio: newRatio, + left: split.left, + right: split.right + ) + + // Replace the split node with the new one + let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit)) + return .init(root: newRoot, zoomed: nil) + } + + /// Returns the total bounds of the split hierarchy using NSView bounds. + /// Ignores x/y coordinates and assumes views are laid out in a perfect grid. + /// Also ignores any possible padding between views. + /// - Returns: The total width and height needed to contain all views + func viewBounds() -> CGSize { + guard let root else { return .zero } + return root.viewBounds() + } +} + +// MARK: SplitTree.Node + +extension SplitTree.Node { + typealias Node = SplitTree.Node + typealias NewDirection = SplitTree.NewDirection + typealias SplitError = SplitTree.SplitError + typealias Path = SplitTree.Path + + /// Returns the node in the tree that contains the given view. + func node(view: ViewType) -> Node? { + switch (self) { + case .leaf(view): + return self + + case .split(let split): + if let result = split.left.node(view: view) { + return result + } else if let result = split.right.node(view: view) { + return result + } + + return nil + + default: + return nil + } + } + + /// Returns the path to a given node in the tree. If the returned value is nil then the + /// node doesn't exist. + func path(to node: Self) -> Path? { + var components: [Path.Component] = [] + func search(_ current: Self) -> Bool { + if current == node { + return true + } + + switch current { + case .leaf: + return false + + case .split(let split): + // Try left branch + components.append(.left) + if search(split.left) { + return true + } + components.removeLast() + + // Try right branch + components.append(.right) + if search(split.right) { + return true + } + components.removeLast() + + return false + } + } + + return search(self) ? Path(path: components) : nil + } + + /// Returns the node at the given path from this node as root. + func node(at path: Path) -> Node? { + if path.isEmpty { + return self + } + + guard case .split(let split) = self else { + return nil + } + + let component = path.path[0] + let remainingPath = Path(path: Array(path.path.dropFirst())) + + switch component { + case .left: + return split.left.node(at: remainingPath) + case .right: + return split.right.node(at: remainingPath) + } + } + + /// Inserts a new view into the split tree by creating a split at the location of an existing view. + /// + /// This method creates a new split node containing both the existing view and the new view, + /// The position of the new view relative to the existing view is determined by the direction parameter. + /// + /// - Parameters: + /// - view: The new view to insert into the tree + /// - at: The existing view at whose location the split should be created + /// - direction: The direction relative to the existing view where the new view should be placed + /// + /// - Note: If the existing view (`at`) is not found in the tree, this method does nothing. We should + /// maybe throw instead but at the moment we just do nothing. + func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { + // Get the path to our insertion point. If it doesn't exist we do + // nothing. + guard let path = path(to: .leaf(view: at)) else { + throw SplitError.viewNotFound + } + + // Determine split direction and which side the new view goes on + let splitDirection: SplitTree.Direction + let newViewOnLeft: Bool + switch direction { + case .left: + splitDirection = .horizontal + newViewOnLeft = true + case .right: + splitDirection = .horizontal + newViewOnLeft = false + case .up: + splitDirection = .vertical + newViewOnLeft = true + case .down: + splitDirection = .vertical + newViewOnLeft = false + } + + // Create the new split node + let newNode: Node = .leaf(view: view) + let existingNode: Node = .leaf(view: at) + let newSplit: Node = .split(.init( + direction: splitDirection, + ratio: 0.5, + left: newViewOnLeft ? newNode : existingNode, + right: newViewOnLeft ? existingNode : newNode + )) + + // Replace the node at the path with the new split + return try replaceNode(at: path, with: newSplit) + } + + /// Helper function to replace a node at the given path from the root + func replaceNode(at path: Path, with newNode: Self) throws -> Self { + // If path is empty, replace the root + if path.isEmpty { + return newNode + } + + // Otherwise, we need to replace the proper left/right all along + // the way since Node is a value type (enum). To do that, we need + // recursion. We can't use a simple iterative approach because we + // can't update in-place. + func replaceInner(current: Node, pathOffset: Int) throws -> Node { + // Base case: if we've consumed the entire path, replace this node + if pathOffset >= path.path.count { + return newNode + } + + // We need to go deeper, so current must be a split for the path + // to be valid. Otherwise, the path is invalid. + guard case .split(let split) = current else { + throw SplitError.viewNotFound + } + + let component = path.path[pathOffset] + switch component { + case .left: + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: try replaceInner(current: split.left, pathOffset: pathOffset + 1), + right: split.right + )) + case .right: + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: split.left, + right: try replaceInner(current: split.right, pathOffset: pathOffset + 1) + )) + } + } + + return try replaceInner(current: self, pathOffset: 0) + } + + /// Remove a node from the tree. Returns the modified tree, or nil if removing + /// the node results in an empty tree. + func remove(_ target: Node) -> Node? { + // If we're removing ourselves, return nil + if self == target { + return nil + } + + switch self { + case .leaf: + // A leaf that isn't the target stays as is + return self + + case .split(let split): + // Neither child is directly the target, so we need to recursively + // try to remove from both children + let newLeft = split.left.remove(target) + let newRight = split.right.remove(target) + + // If both are nil then we remove everything. This shouldn't ever + // happen because duplicate nodes shouldn't exist, but we want to + // be robust against it. + if newLeft == nil && newRight == nil { + return nil + } else if newLeft == nil { + return newRight + } else if newRight == nil { + return newLeft + } + + // Both children still exist after removal + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: newLeft!, + right: newRight! + )) + } + } + + /// Resize a split node to the specified ratio. + /// For leaf nodes, this returns the node unchanged. + /// For split nodes, this creates a new split with the updated ratio. + func resize(to ratio: Double) -> Self { + switch self { + case .leaf: + // Leaf nodes don't have a ratio to resize + return self + + case .split(let split): + // Create a new split with the updated ratio + return .split(.init( + direction: split.direction, + ratio: ratio, + left: split.left, + right: split.right + )) + } + } + + /// Get the leftmost leaf in this subtree + func leftmostLeaf() -> ViewType { + switch self { + case .leaf(let view): + return view + case .split(let split): + return split.left.leftmostLeaf() + } + } + + /// Get the rightmost leaf in this subtree + func rightmostLeaf() -> ViewType { + switch self { + case .leaf(let view): + return view + case .split(let split): + return split.right.rightmostLeaf() + } + } + + /// Equalize this node and all its children, returning a new node with splits + /// adjusted so that each split's ratio is based on the relative weight + /// (number of leaves) of its children. + func equalize() -> Node { + let (equalizedNode, _) = equalizeWithWeight() + return equalizedNode + } + + /// Internal helper that equalizes and returns both the node and its weight. + private func equalizeWithWeight() -> (node: Node, weight: Int) { + switch self { + case .leaf: + // A leaf has weight 1 and doesn't change + return (self, 1) + + case .split(let split): + // Calculate weights based on split direction + let leftWeight = split.left.weightForDirection(split.direction) + let rightWeight = split.right.weightForDirection(split.direction) + + // Calculate new ratio based on relative weights + let totalWeight = leftWeight + rightWeight + let newRatio = Double(leftWeight) / Double(totalWeight) + + // Recursively equalize children + let (leftNode, _) = split.left.equalizeWithWeight() + let (rightNode, _) = split.right.equalizeWithWeight() + + // Create new split with equalized ratio + let newSplit = Split( + direction: split.direction, + ratio: newRatio, + left: leftNode, + right: rightNode + ) + + return (.split(newSplit), totalWeight) + } + } + + /// Calculate weight for equalization based on split direction. + /// Children with the same direction contribute their full weight, + /// children with different directions count as 1. + private func weightForDirection(_ direction: SplitTree.Direction) -> Int { + switch self { + case .leaf: + return 1 + case .split(let split): + if split.direction == direction { + return split.left.weightForDirection(direction) + split.right.weightForDirection(direction) + } else { + return 1 + } + } + } + + + /// Calculate the bounds of all views in this subtree based on split ratios + func calculateViewBounds(in bounds: CGRect) -> [(view: ViewType, bounds: CGRect)] { + switch self { + case .leaf(let view): + return [(view, bounds)] + + case .split(let split): + // Calculate bounds for left and right based on split direction and ratio + let leftBounds: CGRect + let rightBounds: CGRect + + switch split.direction { + case .horizontal: + // Split horizontally: left | right + let splitX = bounds.minX + bounds.width * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width * split.ratio, + height: bounds.height + ) + rightBounds = CGRect( + x: splitX, + y: bounds.minY, + width: bounds.width * (1 - split.ratio), + height: bounds.height + ) + + case .vertical: + // Split vertically: top / bottom + // Note: In our normalized coordinate system, Y increases upward + let splitY = bounds.minY + bounds.height * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: splitY, + width: bounds.width, + height: bounds.height * (1 - split.ratio) + ) + rightBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: bounds.height * split.ratio + ) + } + + // Recursively calculate bounds for children + return split.left.calculateViewBounds(in: leftBounds) + + split.right.calculateViewBounds(in: rightBounds) + } + } + + /// Returns the total bounds of this subtree using NSView bounds. + /// Ignores x/y coordinates and assumes views are laid out in a perfect grid. + /// - Returns: The total width and height needed to contain all views in this subtree + func viewBounds() -> CGSize { + switch self { + case .leaf(let view): + return view.bounds.size + + case .split(let split): + let leftBounds = split.left.viewBounds() + let rightBounds = split.right.viewBounds() + + switch split.direction { + case .horizontal: + // Horizontal split: width is sum, height is max + return CGSize( + width: leftBounds.width + rightBounds.width, + height: Swift.max(leftBounds.height, rightBounds.height) + ) + + case .vertical: + // Vertical split: height is sum, width is max + return CGSize( + width: Swift.max(leftBounds.width, rightBounds.width), + height: leftBounds.height + rightBounds.height + ) + } + } + } +} + +// MARK: SplitTree.Node Spatial + +extension SplitTree.Node { + /// Returns the spatial representation of this node and its subtree. + /// + /// This method creates a `Spatial` representation that maps the logical split tree structure + /// to 2D coordinate space. The coordinate system uses (0,0) as the top-left corner with + /// positive X extending right and positive Y extending down. + /// + /// The spatial representation provides: + /// - Relative bounds for each node based on split ratios + /// - Grid-like dimensions where each split adds 1 to the column/row count + /// - Accurate positioning that reflects the actual layout structure + /// + /// The bounds are pixel perfect based on assuming that each row and column are 1 pixel + /// tall or wide, respectively. This needs to be scaled up to the proper bounds for a real + /// layout. + /// + /// Example: + /// ``` + /// // For a layout like: + /// // +--------+----+ + /// // | A | B | + /// // +--------+----+ + /// // | C | D | + /// // +--------+----+ + /// // + /// // The spatial representation would have: + /// // - Total dimensions: (width: 2, height: 2) + /// // - Node bounds based on actual split ratios + /// ``` + /// + /// - Parameter bounds: Optional size constraints for the spatial representation. If nil, uses artificial dimensions based + /// on grid layout + /// - Returns: A `Spatial` struct containing all slots with their calculated bounds + func spatial(within bounds: CGSize? = nil) -> SplitTree.Spatial { + // If we're not given bounds, we use artificial dimensions based on + // the total width/height in columns/rows. + let width: Double + let height: Double + if let bounds { + width = bounds.width + height = bounds.height + } else { + let (w, h) = self.dimensions() + width = Double(w) + height = Double(h) + } + + // Calculate slots with relative bounds + let slots = spatialSlots(in: CGRect(x: 0, y: 0, width: width, height: height)) + return SplitTree.Spatial(slots: slots) + } + + /// Calculates the grid dimensions (columns and rows) needed to represent this subtree. + /// + /// This method recursively analyzes the split tree structure to determine how many + /// columns and rows are needed to represent the layout in a 2D grid. Each leaf node + /// occupies one grid cell (1×1), and each split extends the grid in one direction: + /// + /// - **Horizontal splits**: Add columns (increase width) + /// - **Vertical splits**: Add rows (increase height) + /// + /// The calculation rules are: + /// - **Leaf nodes**: Always (1, 1) - one column, one row + /// - **Horizontal splits**: Width = sum of children widths, Height = max of children heights + /// - **Vertical splits**: Width = max of children widths, Height = sum of children heights + /// + /// Example: + /// ``` + /// // Single leaf: (1, 1) + /// // Horizontal split with 2 leaves: (2, 1) + /// // Vertical split with 2 leaves: (1, 2) + /// // Complex layout with both: (2, 2) or larger + /// ``` + /// + /// - Returns: A tuple containing (width: columns, height: rows) as unsigned integers + private func dimensions() -> (width: UInt, height: UInt) { + switch self { + case .leaf: + return (1, 1) + + case .split(let split): + let leftDimensions = split.left.dimensions() + let rightDimensions = split.right.dimensions() + + switch split.direction { + case .horizontal: + // Horizontal split: width is sum, height is max + return ( + width: leftDimensions.width + rightDimensions.width, + height: Swift.max(leftDimensions.height, rightDimensions.height) + ) + + case .vertical: + // Vertical split: height is sum, width is max + return ( + width: Swift.max(leftDimensions.width, rightDimensions.width), + height: leftDimensions.height + rightDimensions.height + ) + } + } + } + + /// Calculates the spatial slots (nodes with bounds) for this subtree within the given bounds. + /// + /// This method recursively traverses the split tree and calculates the precise bounds + /// for each node based on the split ratios and directions. The bounds are calculated + /// relative to the provided bounds rectangle. + /// + /// The calculation process: + /// 1. **Leaf nodes**: Create a single slot with the provided bounds + /// 2. **Split nodes**: + /// - Divide the bounds according to the split ratio and direction + /// - Create a slot for the split node itself + /// - Recursively calculate slots for both children + /// - Return all slots combined + /// + /// Split ratio interpretation: + /// - **Horizontal splits**: Ratio determines left/right width distribution + /// - Left child gets `ratio * width` + /// - Right child gets `(1 - ratio) * width` + /// - **Vertical splits**: Ratio determines top/bottom height distribution + /// - Top (left) child gets `ratio * height` + /// - Bottom (right) child gets `(1 - ratio) * height` + /// + /// Coordinate system: (0,0) is top-left, positive X goes right, positive Y goes down. + /// + /// - Parameter bounds: The bounding rectangle to subdivide for this subtree + /// - Returns: An array of `Spatial.Slot` objects, each containing a node and its bounds + private func spatialSlots(in bounds: CGRect) -> [SplitTree.Spatial.Slot] { + switch self { + case .leaf: + // A leaf takes up our full bounds. + return [.init(node: self, bounds: bounds)] + + case .split(let split): + let leftBounds: CGRect + let rightBounds: CGRect + + switch split.direction { + case .horizontal: + // Split horizontally: left | right using the ratio + let splitX = bounds.minX + bounds.width * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width * split.ratio, + height: bounds.height + ) + rightBounds = CGRect( + x: splitX, + y: bounds.minY, + width: bounds.width * (1 - split.ratio), + height: bounds.height + ) + + case .vertical: + // Split vertically: top / bottom using the ratio + // Top-left is (0,0), so top (left) gets the upper portion + let splitY = bounds.minY + bounds.height * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: bounds.height * split.ratio + ) + rightBounds = CGRect( + x: bounds.minX, + y: splitY, + width: bounds.width, + height: bounds.height * (1 - split.ratio) + ) + } + + // Recursively calculate slots for children and include a slot for this split + var slots: [SplitTree.Spatial.Slot] = [.init(node: self, bounds: bounds)] + slots += split.left.spatialSlots(in: leftBounds) + slots += split.right.spatialSlots(in: rightBounds) + + return slots + } + } +} + +// MARK: SplitTree.Spatial + +extension SplitTree.Spatial { + /// Returns all slots in the specified direction relative to the reference node. + /// + /// This method finds all slots positioned in the given direction from the reference node: + /// - **Left**: Slots with bounds to the left of the reference node + /// - **Right**: Slots with bounds to the right of the reference node + /// - **Up**: Slots with bounds above the reference node (Y=0 is top) + /// - **Down**: Slots with bounds below the reference node + /// + /// Results are sorted by 2D euclidean distance from the reference node, with closest slots first. + /// Distance is calculated from the top-left corners of the bounds, prioritizing nodes that are + /// closer in both dimensions. + /// + /// **Important**: The returned array contains both split nodes and leaf nodes. When using this + /// for navigation or focus management, you typically want to filter for leaf nodes first, as they + /// represent the actual views that can receive focus. Split nodes are included in the results + /// because they have bounds and occupy space in the layout, but they are structural elements + /// that cannot themselves be focused. If no leaf nodes are found in the results, you may need + /// to traverse into a split node to find its appropriate leaf child. + /// + /// - Parameters: + /// - direction: The direction to search for slots + /// - referenceNode: The node to use as the reference point + /// - Returns: An array of slots in the specified direction, sorted by 2D distance (closest first) + func slots(in direction: Direction, from referenceNode: SplitTree.Node) -> [Slot] { + guard let refSlot = slots.first(where: { $0.node == referenceNode }) else { return [] } + + // Helper function to calculate 2D euclidean distance between top-left corners of two rectangles + func distance(from rect1: CGRect, to rect2: CGRect) -> Double { + // Calculate distance between top-left corners + let dx = rect2.minX - rect1.minX + let dy = rect2.minY - rect1.minY + return sqrt(dx * dx + dy * dy) + } + + let result = switch direction { + case .left: + // Slots to the left: their right edge is at or left of reference's left edge + slots.filter { + $0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX + }.sorted { + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) + } + + case .right: + // Slots to the right: their left edge is at or right of reference's right edge + slots.filter { + $0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX + }.sorted { + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) + } + + case .up: + // Slots above: their bottom edge is at or above reference's top edge + slots.filter { + $0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY + }.sorted { + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) + } + + case .down: + // Slots below: their top edge is at or below reference's bottom edge + slots.filter { + $0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY + }.sorted { + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) + } + } + + return result + } + + /// Returns whether the given node borders the specified side of the spatial bounds. + /// + /// This method checks if a node's bounds touch the edge of the overall spatial area: + /// - **Up**: Node's top edge touches the top of the spatial area (Y=0) + /// - **Down**: Node's bottom edge touches the bottom of the spatial area (Y=maxY) + /// - **Left**: Node's left edge touches the left of the spatial area (X=0) + /// - **Right**: Node's right edge touches the right of the spatial area (X=maxX) + /// + /// - Parameters: + /// - side: The side of the spatial bounds to check + /// - node: The node to check if it borders the specified side + /// - Returns: True if the node borders the specified side, false otherwise + func doesBorder(side: Direction, from node: SplitTree.Node) -> Bool { + // Find the slot for this node + guard let slot = slots.first(where: { $0.node == node }) else { return false } + + // Calculate the overall bounds of all slots + let overallBounds = slots.reduce(CGRect.null) { result, slot in + result.union(slot.bounds) + } + + return switch side { + case .up: + slot.bounds.minY == overallBounds.minY + case .down: + slot.bounds.maxY == overallBounds.maxY + case .left: + slot.bounds.minX == overallBounds.minX + case .right: + slot.bounds.maxX == overallBounds.maxX + } + } +} + +// MARK: SplitTree.Node Protocols + +extension SplitTree.Node: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.leaf(leftView), .leaf(rightView)): + // Compare NSView instances by object identity + return leftView === rightView + + case let (.split(split1), .split(split2)): + return split1 == split2 + + default: + return false + } + } +} + +// MARK: SplitTree Codable + +extension SplitTree.Node { + enum CodingKeys: String, CodingKey { + case view + case split + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if container.contains(.view) { + let view = try container.decode(ViewType.self, forKey: .view) + self = .leaf(view: view) + } else if container.contains(.split) { + let split = try container.decode(Split.self, forKey: .split) + self = .split(split) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "No valid node type found" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .leaf(let view): + try container.encode(view, forKey: .view) + + case .split(let split): + try container.encode(split, forKey: .split) + } + } +} + +// MARK: SplitTree Sequences + +extension SplitTree.Node { + /// Returns all leaf views in this subtree + func leaves() -> [ViewType] { + switch self { + case .leaf(let view): + return [view] + + case .split(let split): + return split.left.leaves() + split.right.leaves() + } + } +} + +extension SplitTree: Sequence { + func makeIterator() -> [ViewType].Iterator { + return root?.leaves().makeIterator() ?? [].makeIterator() + } +} + +extension SplitTree.Node: Sequence { + func makeIterator() -> [ViewType].Iterator { + return leaves().makeIterator() + } +} + +// MARK: SplitTree Collection + +extension SplitTree: Collection { + typealias Index = Int + typealias Element = ViewType + + var startIndex: Int { + return 0 + } + + var endIndex: Int { + return root?.leaves().count ?? 0 + } + + subscript(position: Int) -> ViewType { + precondition(position >= 0 && position < endIndex, "Index out of bounds") + let leaves = root?.leaves() ?? [] + return leaves[position] + } + + func index(after i: Int) -> Int { + precondition(i < endIndex, "Cannot increment index beyond endIndex") + return i + 1 + } +} + +// MARK: Structural Identity + +extension SplitTree.Node { + /// Returns a hashable representation that captures this node's structural identity. + var structuralIdentity: StructuralIdentity { + StructuralIdentity(self) + } + + /// A hashable representation of a node that captures its structural identity. + /// + /// This type provides a way to track changes to a node's structure in SwiftUI + /// by implementing `Hashable` based on: + /// - The node's hierarchical structure (splits and their directions) + /// - The identity of view instances in leaf nodes (using object identity) + /// - The split directions (but not ratios, as those may change slightly) + /// + /// This is useful for SwiftUI's `id()` modifier to detect when a node's structure + /// has changed, triggering appropriate view updates while preserving view identity + /// for unchanged portions of the tree. + struct StructuralIdentity: Hashable { + private let node: SplitTree.Node + + init(_ node: SplitTree.Node) { + self.node = node + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.node.isStructurallyEqual(to: rhs.node) + } + + func hash(into hasher: inout Hasher) { + node.hashStructure(into: &hasher) + } + } + + /// Checks if this node is structurally equal to another node. + /// Two nodes are structurally equal if they have the same tree structure + /// and the same views (by identity) in the same positions. + fileprivate func isStructurallyEqual(to other: Node) -> Bool { + switch (self, other) { + case let (.leaf(view1), .leaf(view2)): + // Views must be the same instance + return view1 === view2 + + case let (.split(split1), .split(split2)): + // Splits must have same direction and structurally equal children + // Note: We intentionally don't compare ratios as they may change slightly + return split1.direction == split2.direction && + split1.left.isStructurallyEqual(to: split2.left) && + split1.right.isStructurallyEqual(to: split2.right) + + default: + // Different node types + return false + } + } + + /// Hash keys for structural identity + private enum HashKey: UInt8 { + case leaf = 0 + case split = 1 + } + + /// Hashes the structural identity of this node. + /// Includes the tree structure and view identities in the hash. + fileprivate func hashStructure(into hasher: inout Hasher) { + switch self { + case .leaf(let view): + hasher.combine(HashKey.leaf) + hasher.combine(ObjectIdentifier(view)) + + case .split(let split): + hasher.combine(HashKey.split) + hasher.combine(split.direction) + // Note: We intentionally don't hash the ratio + split.left.hashStructure(into: &hasher) + split.right.hashStructure(into: &hasher) + } + } +} + +extension SplitTree { + /// Returns a hashable representation that captures this tree's structural identity. + var structuralIdentity: StructuralIdentity { + StructuralIdentity(self) + } + + /// A hashable representation of a SplitTree that captures its structural identity. + /// + /// This type provides a way to track changes to a SplitTree's structure in SwiftUI + /// by implementing `Hashable` based on: + /// - The tree's hierarchical structure (splits and their directions) + /// - The identity of view instances in leaf nodes (using object identity) + /// - The zoomed node state (if any) + /// + /// This is useful for SwiftUI's `id()` modifier to detect when a tree's structure + /// has changed, triggering appropriate view updates while preserving view identity + /// for unchanged portions of the tree. + /// + /// Example usage: + /// ```swift + /// var body: some View { + /// SplitTreeView(tree: splitTree) + /// .id(splitTree.structuralIdentity) + /// } + /// ``` + struct StructuralIdentity: Hashable { + private let root: Node? + private let zoomed: Node? + + init(_ tree: SplitTree) { + self.root = tree.root + self.zoomed = tree.zoomed + } + + static func == (lhs: Self, rhs: Self) -> Bool { + areNodesStructurallyEqual(lhs.root, rhs.root) && + areNodesStructurallyEqual(lhs.zoomed, rhs.zoomed) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(0) // Tree marker + if let root = root { + root.hashStructure(into: &hasher) + } + hasher.combine(1) // Zoomed marker + if let zoomed = zoomed { + zoomed.hashStructure(into: &hasher) + } + } + + /// Helper to compare optional nodes for structural equality + private static func areNodesStructurallyEqual(_ lhs: Node?, _ rhs: Node?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + case let (node1?, node2?): + return node1.isStructurallyEqual(to: node2) + default: + return false + } + } + } +} diff --git a/macos/Sources/Helpers/SplitView/SplitView.Divider.swift b/macos/Sources/Features/Splits/SplitView.Divider.swift similarity index 67% rename from macos/Sources/Helpers/SplitView/SplitView.Divider.swift rename to macos/Sources/Features/Splits/SplitView.Divider.swift index 83847ff0c..a01175dce 100644 --- a/macos/Sources/Helpers/SplitView/SplitView.Divider.swift +++ b/macos/Sources/Features/Splits/SplitView.Divider.swift @@ -7,6 +7,7 @@ extension SplitView { let visibleSize: CGFloat let invisibleSize: CGFloat let color: Color + @Binding var split: CGFloat private var visibleWidth: CGFloat? { switch (direction) { @@ -79,6 +80,40 @@ extension SplitView { NSCursor.pop() } } + .accessibilityElement(children: .ignore) + .accessibilityLabel(axLabel) + .accessibilityValue("\(Int(split * 100))%") + .accessibilityHint(axHint) + .accessibilityAddTraits(.isButton) + .accessibilityAdjustableAction { direction in + let adjustment: CGFloat = 0.025 + switch direction { + case .increment: + split = min(split + adjustment, 0.9) + case .decrement: + split = max(split - adjustment, 0.1) + @unknown default: + break + } + } + } + + private var axLabel: String { + switch direction { + case .horizontal: + return "Horizontal split divider" + case .vertical: + return "Vertical split divider" + } + } + + private var axHint: String { + switch direction { + case .horizontal: + return "Drag to resize the left and right panes" + case .vertical: + return "Drag to resize the top and bottom panes" + } } } } diff --git a/macos/Sources/Helpers/SplitView/SplitView.swift b/macos/Sources/Features/Splits/SplitView.swift similarity index 79% rename from macos/Sources/Helpers/SplitView/SplitView.swift rename to macos/Sources/Features/Splits/SplitView.swift index 8ac2bc33f..3dc3c36a3 100644 --- a/macos/Sources/Helpers/SplitView/SplitView.swift +++ b/macos/Sources/Features/Splits/SplitView.swift @@ -1,5 +1,4 @@ import SwiftUI -import Combine /// A split view shows a left and right (or top and bottom) view with a divider in the middle to do resizing. /// The terminlogy "left" and "right" is always used but for vertical splits "left" is "top" and "right" is "bottom". @@ -13,12 +12,10 @@ struct SplitView: View { /// Divider color let dividerColor: Color - /// If set, the split view supports programmatic resizing via events sent via the publisher. /// Minimum increment (in points) that this split can be resized by, in /// each direction. Both `height` and `width` should be whole numbers /// greater than or equal to 1.0 let resizeIncrements: NSSize - let resizePublisher: PassthroughSubject /// The left and right views to render. let left: L @@ -45,47 +42,32 @@ struct SplitView: View { left .frame(width: leftRect.size.width, height: leftRect.size.height) .offset(x: leftRect.origin.x, y: leftRect.origin.y) + .accessibilityElement(children: .contain) + .accessibilityLabel(leftPaneLabel) right .frame(width: rightRect.size.width, height: rightRect.size.height) .offset(x: rightRect.origin.x, y: rightRect.origin.y) + .accessibilityElement(children: .contain) + .accessibilityLabel(rightPaneLabel) Divider(direction: direction, visibleSize: splitterVisibleSize, invisibleSize: splitterInvisibleSize, - color: dividerColor) + color: dividerColor, + split: $split) .position(splitterPoint) .gesture(dragGesture(geo.size, splitterPoint: splitterPoint)) } - .onReceive(resizePublisher) { value in - resize(for: geo.size, amount: value) - } + .accessibilityElement(children: .contain) + .accessibilityLabel(splitViewLabel) } } - /// Initialize a split view. This view isn't programmatically resizable; it can only be resized - /// by manually dragging the divider. - init(_ direction: SplitViewDirection, - _ split: Binding, - dividerColor: Color, - @ViewBuilder left: (() -> L), - @ViewBuilder right: (() -> R)) { - self.init( - direction, - split, - dividerColor: dividerColor, - resizeIncrements: .init(width: 1, height: 1), - resizePublisher: .init(), - left: left, - right: right - ) - } - - /// Initialize a split view that supports programmatic resizing. + /// Initialize a split view that can be resized by manually dragging the divider. init( _ direction: SplitViewDirection, _ split: Binding, dividerColor: Color, - resizeIncrements: NSSize, - resizePublisher: PassthroughSubject, + resizeIncrements: NSSize = .init(width: 1, height: 1), @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R) ) { @@ -93,25 +75,10 @@ struct SplitView: View { self._split = split self.dividerColor = dividerColor self.resizeIncrements = resizeIncrements - self.resizePublisher = resizePublisher self.left = left() self.right = right() } - private func resize(for size: CGSize, amount: Double) { - let dim: CGFloat - switch (direction) { - case .horizontal: - dim = size.width - case .vertical: - dim = size.height - } - - let pos = split * dim - let new = min(max(minSize, pos + amount), dim - minSize) - split = new / dim - } - private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture { return DragGesture() .onChanged { gesture in @@ -177,6 +144,35 @@ struct SplitView: View { return CGPoint(x: size.width / 2, y: leftRect.size.height) } } + + // MARK: Accessibility + + private var splitViewLabel: String { + switch direction { + case .horizontal: + return "Horizontal split view" + case .vertical: + return "Vertical split view" + } + } + + private var leftPaneLabel: String { + switch direction { + case .horizontal: + return "Left pane" + case .vertical: + return "Top pane" + } + } + + private var rightPaneLabel: String { + switch direction { + case .horizontal: + return "Right pane" + case .vertical: + return "Bottom pane" + } + } } enum SplitViewDirection: Codable { diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift new file mode 100644 index 000000000..f19640707 --- /dev/null +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct TerminalSplitTreeView: View { + let tree: SplitTree + let onResize: (SplitTree.Node, Double) -> Void + + var body: some View { + if let node = tree.zoomed ?? tree.root { + TerminalSplitSubtreeView( + node: node, + isRoot: node == tree.root, + onResize: onResize) + // This is necessary because we can't rely on SwiftUI's implicit + // structural identity to detect changes to this view. Due to + // the tree structure of splits it could result in bad beaviors. + // See: https://github.com/ghostty-org/ghostty/issues/7546 + .id(node.structuralIdentity) + } + } +} + +struct TerminalSplitSubtreeView: View { + @EnvironmentObject var ghostty: Ghostty.App + + let node: SplitTree.Node + var isRoot: Bool = false + let onResize: (SplitTree.Node, Double) -> Void + + var body: some View { + switch (node) { + case .leaf(let leafView): + Ghostty.InspectableSurface( + surfaceView: leafView, + isSplit: !isRoot) + .accessibilityElement(children: .contain) + .accessibilityLabel("Terminal pane") + + case .split(let split): + let splitViewDirection: SplitViewDirection = switch (split.direction) { + case .horizontal: .horizontal + case .vertical: .vertical + } + + SplitView( + splitViewDirection, + .init(get: { + CGFloat(split.ratio) + }, set: { + onResize(node, $0) + }), + dividerColor: ghostty.config.splitDividerColor, + resizeIncrements: .init(width: 1, height: 1), + left: { + TerminalSplitSubtreeView(node: split.left, onResize: onResize) + }, + right: { + TerminalSplitSubtreeView(node: split.right, onResize: onResize) + } + ) + } + } +} diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 62384586a..c93a9450d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -41,8 +41,8 @@ class BaseTerminalController: NSWindowController, didSet { syncFocusToSurfaceTree() } } - /// The surface tree for this window. - @Published var surfaceTree: Ghostty.SplitNode? = nil { + /// The tree of splits within this terminal window. + @Published var surfaceTree: SplitTree = .init() { didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } @@ -75,6 +75,27 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// The time that undo/redo operations that contain running ptys are valid for. + var undoExpiration: Duration { + ghostty.config.undoTimeout + } + + /// The undo manager for this controller is the undo manager of the window, + /// which we set via the delegate method. + override var undoManager: ExpiringUndoManager? { + // This should be set via the delegate method windowWillReturnUndoManager + if let result = window?.undoManager as? ExpiringUndoManager { + return result + } + + // If the window one isn't set, we fallback to our global one. + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + return appDelegate.undoManager + } + + return nil + } + struct SavedFrame { let window: NSRect let screen: NSRect @@ -86,7 +107,7 @@ class BaseTerminalController: NSWindowController, init(_ ghostty: Ghostty.App, baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree tree: Ghostty.SplitNode? = nil + surfaceTree tree: SplitTree? = nil ) { self.ghostty = ghostty self.derivedConfig = DerivedConfig(ghostty.config) @@ -95,7 +116,7 @@ class BaseTerminalController: NSWindowController, // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } - self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) + self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base)) // Setup our notifications for behaviors let center = NotificationCenter.default @@ -125,6 +146,38 @@ class BaseTerminalController: NSWindowController, name: .ghosttyMaximizeDidToggle, object: nil) + // Splits + center.addObserver( + self, + selector: #selector(ghosttyDidCloseSurface(_:)), + name: Ghostty.Notification.ghosttyCloseSurface, + object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidNewSplit(_:)), + name: Ghostty.Notification.ghosttyNewSplit, + object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidEqualizeSplits(_:)), + name: Ghostty.Notification.didEqualizeSplits, + object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidFocusSplit(_:)), + name: Ghostty.Notification.ghosttyFocusSplit, + object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidToggleSplitZoom(_:)), + name: Ghostty.Notification.didToggleSplitZoom, + object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidResizeSplit(_:)), + name: Ghostty.Notification.didResizeSplit, + object: nil) + // Listen for local events that we need to know of outside of // single surface handlers. self.eventMonitor = NSEvent.addLocalMonitorForEvents( @@ -134,20 +187,58 @@ class BaseTerminalController: NSWindowController, deinit { NotificationCenter.default.removeObserver(self) - + undoManager?.removeAllActions(withTarget: self) if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } } + // MARK: Methods + + /// Create a new split. + @discardableResult + func newSplit( + at oldView: Ghostty.SurfaceView, + direction: SplitTree.NewDirection, + baseConfig config: Ghostty.SurfaceConfiguration? = nil + ) -> Ghostty.SurfaceView? { + // We can only create new splits for surfaces in our tree. + guard surfaceTree.root?.node(view: oldView) != nil else { return nil } + + // Create a new surface view + guard let ghostty_app = ghostty.app else { return nil } + let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config) + + // Do the split + let newTree: SplitTree + do { + newTree = try surfaceTree.insert( + view: newView, + at: oldView, + direction: direction) + } catch { + // If splitting fails for any reason (it should not), then we just log + // and return. The new view we created will be deinitialized and its + // no big deal. + Ghostty.logger.warning("failed to insert split: \(error)") + return nil + } + + replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: oldView, + undoAction: "New Split") + + return newView + } + /// Called when the surfaceTree variable changed. /// /// Subclasses should call super first. - func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { - // If our surface tree becomes nil then ensure all surfaces - // in the old tree have closed. - if (to == nil) { - from?.close() + func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { + // If our surface tree becomes empty then we have no focused surface. + if (to.isEmpty) { focusedSurface = nil } } @@ -155,16 +246,14 @@ class BaseTerminalController: NSWindowController, /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about /// what surface is focused. This must be called whenever a surface OR window changes focus. func syncFocusToSurfaceTree() { - guard let tree = self.surfaceTree else { return } - - for leaf in tree { + for surfaceView in surfaceTree { // Our focus state requires that this window is key and our currently - // focused surface is the surface in this leaf. + // focused surface is the surface in this view. let focused: Bool = (window?.isKeyWindow ?? false) && !commandPaletteIsShowing && focusedSurface != nil && - leaf.surface == focusedSurface! - leaf.surface.focusDidChange(focused) + surfaceView == focusedSurface! + surfaceView.focusDidChange(focused) } } @@ -177,6 +266,164 @@ class BaseTerminalController: NSWindowController, savedFrame = .init(window: window.frame, screen: screen.visibleFrame) } + func confirmClose( + messageText: String, + informativeText: String, + completion: @escaping () -> Void + ) { + // If we already have an alert, we need to wait for that one. + guard alert == nil else { return } + + // If there is no window to attach the modal then we assume success + // since we'll never be able to show the modal. + guard let window else { + completion() + return + } + + // If we need confirmation by any, show one confirmation for all windows + // in the tab group. + let alert = NSAlert() + alert.messageText = messageText + alert.informativeText = informativeText + alert.addButton(withTitle: "Close") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: window) { response in + self.alert = nil + if response == .alertFirstButtonReturn { + completion() + } + } + + // Store our alert so we only ever show one. + self.alert = alert + } + + /// Close a surface from a view. + func closeSurface( + _ view: Ghostty.SurfaceView, + withConfirmation: Bool = true + ) { + guard let node = surfaceTree.root?.node(view: view) else { return } + closeSurface(node, withConfirmation: withConfirmation) + } + + /// Close a surface node (which may contain splits), requesting confirmation if necessary. + /// + /// This will also insert the proper undo stack information in. + func closeSurface( + _ node: SplitTree.Node, + withConfirmation: Bool = true + ) { + // This node must be part of our tree + guard surfaceTree.contains(node) else { return } + + // If the child process is not alive, then we exit immediately + guard withConfirmation else { + removeSurfaceNode(node) + return + } + + // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog + // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that + // confirmationDialog allows the user to Cmd-W close the alert, but when doing + // so SwiftUI does not update any of the bindings to note that window is no longer + // being shown, and provides no callback to detect this. + confirmClose( + messageText: "Close Terminal?", + informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." + ) { [weak self] in + if let self { + self.removeSurfaceNode(node) + } + } + } + + // MARK: Split Tree Management + + /// Find the next surface to focus when a node is being closed. + /// Goes to previous split unless we're the leftmost leaf, then goes to next. + private func findNextFocusTargetAfterClosing(node: SplitTree.Node) -> Ghostty.SurfaceView? { + guard let root = surfaceTree.root else { return nil } + + // If we're the leftmost, then we move to the next surface after closing. + // Otherwise, we move to the previous. + if root.leftmostLeaf() == node.leftmostLeaf() { + return surfaceTree.focusTarget(for: .next, from: node) + } else { + return surfaceTree.focusTarget(for: .previous, from: node) + } + } + + /// Remove a node from the surface tree and move focus appropriately. + /// + /// This also updates the undo manager to support restoring this node. + /// + /// This does no confirmation and assumes confirmation is already done. + private func removeSurfaceNode(_ node: SplitTree.Node) { + // Move focus if the closed surface was focused and we have a next target + let nextFocus: Ghostty.SurfaceView? = if node.contains( + where: { $0 == focusedSurface } + ) { + findNextFocusTargetAfterClosing(node: node) + } else { + nil + } + + replaceSurfaceTree( + surfaceTree.remove(node), + moveFocusTo: nextFocus, + moveFocusFrom: focusedSurface, + undoAction: "Close Terminal" + ) + } + + private func replaceSurfaceTree( + _ newTree: SplitTree, + moveFocusTo newView: Ghostty.SurfaceView? = nil, + moveFocusFrom oldView: Ghostty.SurfaceView? = nil, + undoAction: String? = nil + ) { + // Setup our new split tree + let oldTree = surfaceTree + surfaceTree = newTree + if let newView { + DispatchQueue.main.async { + Ghostty.moveFocus(to: newView, from: oldView) + } + } + + // Setup our undo + if let undoManager { + if let undoAction { + undoManager.setActionName(undoAction) + } + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration + ) { target in + target.surfaceTree = oldTree + if let oldView { + DispatchQueue.main.async { + Ghostty.moveFocus(to: oldView, from: target.focusedSurface) + } + } + + undoManager.registerUndo( + withTarget: target, + expiresAfter: target.undoExpiration + ) { target in + target.replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: target.focusedSurface, + undoAction: undoAction) + } + } + } + } + // MARK: Notifications @objc private func didChangeScreenParametersNotification(_ notification: Notification) { @@ -239,17 +486,158 @@ class BaseTerminalController: NSWindowController, @objc private func ghosttyCommandPaletteDidToggle(_ notification: Notification) { guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: surfaceView) ?? false else { return } + guard surfaceTree.contains(surfaceView) else { return } toggleCommandPalette(nil) } @objc private func ghosttyMaximizeDidToggle(_ notification: Notification) { guard let window else { return } guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: surfaceView) ?? false else { return } + guard surfaceTree.contains(surfaceView) else { return } window.zoom(nil) } + @objc private func ghosttyDidCloseSurface(_ notification: Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard let node = surfaceTree.root?.node(view: target) else { return } + closeSurface( + node, + withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false) + } + + @objc private func ghosttyDidNewSplit(_ notification: Notification) { + // The target must be within our tree + guard let oldView = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree.root?.node(view: oldView) != nil else { return } + + // Notification must contain our base config + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? Ghostty.SurfaceConfiguration + + // Determine our desired direction + guard let directionAny = notification.userInfo?["direction"] else { return } + guard let direction = directionAny as? ghostty_action_split_direction_e else { return } + let splitDirection: SplitTree.NewDirection + switch (direction) { + case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right + case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left + case GHOSTTY_SPLIT_DIRECTION_DOWN: splitDirection = .down + case GHOSTTY_SPLIT_DIRECTION_UP: splitDirection = .up + default: return + } + + newSplit(at: oldView, direction: splitDirection, baseConfig: config) + } + + @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + + // Check if target surface is in current controller's tree + guard surfaceTree.contains(target) else { return } + + // Equalize the splits + surfaceTree = surfaceTree.equalize() + } + + @objc private func ghosttyDidFocusSplit(_ notification: Notification) { + // The target must be within our tree + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree.root?.node(view: target) != nil else { return } + + // Get the direction from the notification + guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return } + guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return } + + // Convert Ghostty.SplitFocusDirection to our SplitTree.FocusDirection + let focusDirection: SplitTree.FocusDirection + switch direction { + case .previous: focusDirection = .previous + case .next: focusDirection = .next + case .up: focusDirection = .spatial(.up) + case .down: focusDirection = .spatial(.down) + case .left: focusDirection = .spatial(.left) + case .right: focusDirection = .spatial(.right) + } + + // Find the node for the target surface + guard let targetNode = surfaceTree.root?.node(view: target) else { return } + + // Find the next surface to focus + guard let nextSurface = surfaceTree.focusTarget(for: focusDirection, from: targetNode) else { + return + } + + // Remove the zoomed state for this surface tree. + if surfaceTree.zoomed != nil { + surfaceTree = .init(root: surfaceTree.root, zoomed: nil) + } + + // Move focus to the next surface + DispatchQueue.main.async { + Ghostty.moveFocus(to: nextSurface, from: target) + } + } + + @objc private func ghosttyDidToggleSplitZoom(_ notification: Notification) { + // The target must be within our tree + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard let targetNode = surfaceTree.root?.node(view: target) else { return } + + // Toggle the zoomed state + if surfaceTree.zoomed == targetNode { + // Already zoomed, unzoom it + surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil) + } else { + // We require that the split tree have splits + guard surfaceTree.isSplit else { return } + + // Not zoomed or different node zoomed, zoom this node + surfaceTree = SplitTree(root: surfaceTree.root, zoomed: targetNode) + } + + // Move focus to our window. Importantly this ensures that if we click the + // reset zoom button in a tab bar of an unfocused tab that we become focused. + window?.makeKeyAndOrderFront(nil) + + // Ensure focus stays on the target surface. We lose focus when we do + // this so we need to grab it again. + DispatchQueue.main.async { + Ghostty.moveFocus(to: target) + } + } + + @objc private func ghosttyDidResizeSplit(_ notification: Notification) { + // The target must be within our tree + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard let targetNode = surfaceTree.root?.node(view: target) else { return } + + // Extract direction and amount from notification + guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return } + guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return } + + guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return } + guard let amount = amountAny as? UInt16 else { return } + + // Convert Ghostty.SplitResizeDirection to SplitTree.Spatial.Direction + let spatialDirection: SplitTree.Spatial.Direction + switch direction { + case .up: spatialDirection = .up + case .down: spatialDirection = .down + case .left: spatialDirection = .left + case .right: spatialDirection = .right + } + + // Use viewBounds for the spatial calculation bounds + let bounds = CGRect(origin: .zero, size: surfaceTree.viewBounds()) + + // Perform the resize using the new SplitTree resize method + do { + surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds) + } catch { + Ghostty.logger.warning("failed to resize split: \(error)") + } + } + // MARK: Local Events private func localEventHandler(_ event: NSEvent) -> NSEvent? { @@ -263,20 +651,17 @@ class BaseTerminalController: NSWindowController, } private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? { - // Go through all our surfaces and notify it that the flags changed. - if let surfaceTree { - var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0.surface } + var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0 } - // If we're the main window receiving key input, then we want to avoid - // calling this on our focused surface because that'll trigger a double - // flagsChanged call. - if NSApp.mainWindow == window { - surfaces = surfaces.filter { $0 != focusedSurface } - } - - for surface in surfaces { - surface.flagsChanged(with: event) - } + // If we're the main window receiving key input, then we want to avoid + // calling this on our focused surface because that'll trigger a double + // flagsChanged call. + if NSApp.mainWindow == window { + surfaces = surfaces.filter { $0 != focusedSurface } + } + + for surface in surfaces { + surface.flagsChanged(with: event) } return event @@ -284,11 +669,6 @@ class BaseTerminalController: NSWindowController, // MARK: TerminalViewDelegate - // Note: this is different from surfaceDidTreeChange(from:,to:) because this is called - // when the currently set value changed in place and the from:to: variant is called - // when the variable was set. - func surfaceTreeDidChange() {} - func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { let lastFocusedSurface = focusedSurface focusedSurface = to @@ -301,7 +681,7 @@ class BaseTerminalController: NSWindowController, // want to care if the surface is in the tree so we don't listen to titles of // closed surfaces. if let titleSurface = focusedSurface ?? lastFocusedSurface, - surfaceTree?.contains(view: titleSurface) ?? false { + surfaceTree.contains(titleSurface) { // If we have a surface, we want to listen for title changes. titleSurface.$title .sink { [weak self] in self?.titleDidChange(to: $0) } @@ -336,7 +716,15 @@ class BaseTerminalController: NSWindowController, self.window?.contentResizeIncrements = to } - func zoomStateDidChange(to: Bool) {} + func splitDidResize(node: SplitTree.Node, to newRatio: Double) { + let resizedNode = node.resize(to: newRatio) + do { + surfaceTree = try surfaceTree.replace(node: node, with: resizedNode) + } catch { + Ghostty.logger.warning("failed to replace node during split resize: \(error)") + return + } + } func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) { guard let surface = surfaceView.surface else { return } @@ -396,6 +784,8 @@ class BaseTerminalController: NSWindowController, } } + func fullscreenDidChange() {} + // MARK: Clipboard Confirmation @objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) { @@ -462,6 +852,11 @@ class BaseTerminalController: NSWindowController, // MARK: NSWindowController override func windowDidLoad() { + super.windowDidLoad() + + // Setup our undo manager. + + // Everything beyond here is setting up the window guard let window else { return } // If there is a hardcoded title in the configuration, we set that @@ -491,35 +886,21 @@ class BaseTerminalController: NSWindowController, guard let window = self.window else { return true } // If we have no surfaces, close. - guard let node = self.surfaceTree else { return true } + if surfaceTree.isEmpty { return true } // If we already have an alert, continue with it guard alert == nil else { return false } // If our surfaces don't require confirmation, close. - if (!node.needsConfirmQuit()) { return true } + if !surfaceTree.contains(where: { $0.needsConfirmQuit }) { return true } // We require confirmation, so show an alert as long as we aren't already. - let alert = NSAlert() - alert.messageText = "Close Terminal?" - alert.informativeText = "The terminal still has a running process. If you close the " + - "terminal the process will be killed." - alert.addButton(withTitle: "Close the Terminal") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window, completionHandler: { response in - self.alert = nil - switch (response) { - case .alertFirstButtonReturn: - alert.window.orderOut(nil) - window.close() - - default: - break - } - }) - - self.alert = alert + confirmClose( + messageText: "Close Terminal?", + informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." + ) { + window.close() + } return false } @@ -531,6 +912,9 @@ class BaseTerminalController: NSWindowController, // the view and the window so we had to nil this out to break it but I think this // may now be resolved. We should verify that no memory leaks and we can remove this. window.contentView = nil + + // Make sure we clean up all our undos + window.undoManager?.removeAllActions(withTarget: self) } func windowDidBecomeKey(_ notification: Notification) { @@ -546,10 +930,9 @@ class BaseTerminalController: NSWindowController, } func windowDidChangeOcclusionState(_ notification: Notification) { - guard let surfaceTree = self.surfaceTree else { return } let visible = self.window?.occlusionState.contains(.visible) ?? false - for leaf in surfaceTree { - if let surface = leaf.surface.surface { + for view in surfaceTree { + if let surface = view.surface { ghostty_surface_set_occlusion(surface, visible) } } @@ -563,6 +946,11 @@ class BaseTerminalController: NSWindowController, windowFrameDidChange() } + func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? { + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } + return appDelegate.undoManager + } + // MARK: First Responder @IBAction func close(_ sender: Any) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index cf2dd3348..c5e1c413f 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -5,8 +5,34 @@ import Combine import GhosttyKit /// A classic, tabbed terminal experience. -class TerminalController: BaseTerminalController { - override var windowNibName: NSNib.Name? { "Terminal" } +class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Controller { + override var windowNibName: NSNib.Name? { + let defaultValue = "Terminal" + + guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue } + let config = appDelegate.ghostty.config + + // If we have no window decorations, there's no reason to do anything but + // the default titlebar (because there will be no titlebar). + if !config.windowDecorations { + return defaultValue + } + + let nib = switch config.macosTitlebarStyle { + case "native": "Terminal" + case "hidden": "TerminalHiddenTitlebar" + case "transparent": "TerminalTransparentTitlebar" + case "tabs": + if #available(macOS 26.0, *) { + "TerminalTabsTitlebarTahoe" + } else { + "TerminalTabsTitlebarVentura" + } + default: defaultValue + } + + return nib + } /// This is set to true when we care about frame changes. This is a small optimization since /// this controller registers a listener for ALL frame change notifications and this lets us bail @@ -32,7 +58,8 @@ class TerminalController: BaseTerminalController { init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: Ghostty.SplitNode? = nil + withSurfaceTree tree: SplitTree? = nil, + parent: NSWindow? = nil ) { // The window we manage is not restorable if we've specified a command // to execute. We do this because the restored window is meaningless at the @@ -85,12 +112,6 @@ class TerminalController: BaseTerminalController { selector: #selector(onFrameDidChange), name: NSView.frameDidChangeNotification, object: nil) - center.addObserver( - self, - selector: #selector(onEqualizeSplits), - name: Ghostty.Notification.didEqualizeSplits, - object: nil - ) center.addObserver( self, selector: #selector(onCloseWindow), @@ -111,29 +132,244 @@ class TerminalController: BaseTerminalController { // MARK: Base Controller Overrides - override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { super.surfaceTreeDidChange(from: from, to: to) + + // Whenever our surface tree changes in any way (new split, close split, etc.) + // we want to invalidate our state. + invalidateRestorableState() + + // Update our zoom state + if let window = window as? TerminalWindow { + window.surfaceIsZoomed = to.zoomed != nil + } // If our surface tree is now nil then we close our window. - if (to == nil) { + if (to.isEmpty) { self.window?.close() } } - func fullscreenDidChange() { + override func fullscreenDidChange() { + super.fullscreenDidChange() + // When our fullscreen state changes, we resync our appearance because some // properties change when fullscreen or not. guard let focusedSurface else { return } - if (!(fullscreenStyle?.isFullscreen ?? false) && - ghostty.config.macosTitlebarStyle == "hidden") - { - applyHiddenTitlebarStyle() - } syncAppearance(focusedSurface.derivedConfig) } + // MARK: Terminal Creation + + /// Returns all the available terminal controllers present in the app currently. + static var all: [TerminalController] { + return NSApplication.shared.windows.compactMap { + $0.windowController as? TerminalController + } + } + + // Keep track of the last point that our window was launched at so that new + // windows "cascade" over each other and don't just launch directly on top + // of each other. + private static var lastCascadePoint = NSPoint(x: 0, y: 0) + + // The preferred parent terminal controller. + static var preferredParent: TerminalController? { + all.first { + $0.window?.isMainWindow ?? false + } ?? all.last + } + + /// The "new window" action. + static func newWindow( + _ ghostty: Ghostty.App, + 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 + + if let parent { + if parent.styleMask.contains(.fullScreen) { + parent.toggleFullScreen(nil) + } else if ghostty.config.windowFullscreen { + switch (ghostty.config.windowFullscreenMode) { + case .native: + // Native has to be done immediately so that our stylemask contains + // fullscreen for the logic later in this method. + c.toggleFullscreen(mode: .native) + + case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch: + // If we're non-native then we have to do it on a later loop + // so that the content view is setup. + DispatchQueue.main.async { + c.toggleFullscreen(mode: ghostty.config.windowFullscreenMode) + } + } + } + } + + // 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 { + // Only cascade if we aren't fullscreen. + if let window = c.window { + if (!window.styleMask.contains(.fullScreen)) { + Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) + } + } + + c.showWindow(self) + } + + // Setup our undo + if let undoManager = c.undoManager { + undoManager.setActionName("New Window") + undoManager.registerUndo( + withTarget: c, + expiresAfter: c.undoExpiration + ) { target in + // Close the window when undoing + undoManager.disableUndoRegistration { + target.closeWindow(nil) + } + + // Register redo action + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: target.undoExpiration + ) { ghostty in + _ = TerminalController.newWindow( + ghostty, + withBaseConfig: baseConfig, + withParent: explicitParent) + } + } + } + + return c + } + + static func newTab( + _ ghostty: Ghostty.App, + from parent: NSWindow? = nil, + withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil + ) -> TerminalController? { + // Making sure that we're dealing with a TerminalController. If not, + // then we just create a new window. + guard let parent, + let parentController = parent.windowController as? TerminalController else { + return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent) + } + + // If our parent is in non-native fullscreen, then new tabs do not work. + // See: https://github.com/mitchellh/ghostty/issues/392 + if let fullscreenStyle = parentController.fullscreenStyle, + fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs { + let alert = NSAlert() + alert.messageText = "Cannot Create New Tab" + alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again." + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.beginSheetModal(for: parent) + return nil + } + + // Create a new window and add it to the 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 + // so we have to bring it back out. + if (parent.isMiniaturized) { parent.deminiaturize(self) } + + // If our parent tab group already has this window, macOS added it and + // we need to remove it so we can set the correct order in the next line. + // If we don't do this, macOS gets really confused and the tabbedWindows + // state becomes incorrect. + // + // At the time of writing this code, the only known case this happens + // is when the "+" button is clicked in the tab bar. + if let tg = parent.tabGroup, + tg.windows.firstIndex(of: window) != nil { + tg.removeWindow(window) + } + + // If we don't allow tabs then we create a new window instead. + if (window.tabbingMode != .disallowed) { + // Add the window to the tab group and show it. + switch ghostty.config.windowNewTabPosition { + case "end": + // If we already have a tab group and we want the new tab to open at the end, + // then we use the last window in the tab group as the parent. + if let last = parent.tabGroup?.windows.last { + last.addTabbedWindow(window, ordered: .above) + } else { + fallthrough + } + + case "current": fallthrough + default: + parent.addTabbedWindow(window, ordered: .above) + } + } + + // 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 { + // 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 { + Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) + } + + controller.showWindow(self) + window.makeKeyAndOrderFront(self) + } + + // It takes an event loop cycle until the macOS tabGroup state becomes + // consistent which causes our tab labeling to be off when the "+" button + // is used in the tab bar. This fixes that. If we can find a more robust + // solution we should do that. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + controller.relabelTabs() + } + + // Setup our undo + if let undoManager = parentController.undoManager { + undoManager.setActionName("New Tab") + undoManager.registerUndo( + withTarget: controller, + expiresAfter: controller.undoExpiration + ) { target in + // Close the tab when undoing + undoManager.disableUndoRegistration { + target.closeTab(nil) + } + + // Register redo action + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: target.undoExpiration + ) { ghostty in + _ = TerminalController.newTab( + ghostty, + from: parent, + withBaseConfig: baseConfig) + } + } + } + + return controller + } + //MARK: - Methods @objc private func ghosttyConfigDidChange(_ notification: Notification) { @@ -149,8 +385,8 @@ class TerminalController: BaseTerminalController { // If we have no surfaces in our window (is that possible?) then we update // our window appearance based on the root config. If we have surfaces, we - // don't call this because the TODO - if surfaceTree == nil { + // don't call this because focused surface changes will trigger appearance updates. + if surfaceTree.isEmpty { syncAppearance(.init(config)) } @@ -160,7 +396,7 @@ class TerminalController: BaseTerminalController { // This is a surface-level config update. If we have the surface, we // update our appearance based on it. guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: surfaceView) ?? false else { return } + guard surfaceTree.contains(surfaceView) else { return } // We can't use surfaceView.derivedConfig because it may not be updated // yet since it also responds to notifications. @@ -172,28 +408,25 @@ class TerminalController: BaseTerminalController { /// changes, when a window is closed, and when tabs are reordered /// with the mouse. func relabelTabs() { - // Reset this to false. It'll be set back to true later. - tabListenForFrame = false - - guard let windows = self.window?.tabbedWindows as? [TerminalWindow] else { return } - // We only listen for frame changes if we have more than 1 window, // otherwise the accessory view doesn't matter. - tabListenForFrame = windows.count > 1 + tabListenForFrame = window?.tabbedWindows?.count ?? 0 > 1 - for (tab, window) in zip(1..., windows) { - // We need to clear any windows beyond this because they have had - // a keyEquivalent set previously. - guard tab <= 9 else { - window.keyEquivalent = "" - continue - } + if let windows = window?.tabbedWindows as? [TerminalWindow] { + for (tab, window) in zip(1..., windows) { + // We need to clear any windows beyond this because they have had + // a keyEquivalent set previously. + guard tab <= 9 else { + window.keyEquivalent = "" + continue + } - let action = "goto_tab:\(tab)" - if let equiv = ghostty.config.keyboardShortcut(for: action) { - window.keyEquivalent = "\(equiv)" - } else { - window.keyEquivalent = "" + let action = "goto_tab:\(tab)" + if let equiv = ghostty.config.keyboardShortcut(for: action) { + window.keyEquivalent = "\(equiv)" + } else { + window.keyEquivalent = "" + } } } } @@ -226,18 +459,11 @@ class TerminalController: BaseTerminalController { } private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { - guard let window = self.window as? TerminalWindow else { return } + // Let our window handle its own appearance + guard let window = window as? TerminalWindow else { return } - // Set our explicit appearance if we need to based on the configuration. - window.appearance = surfaceConfig.windowAppearance - - // Update our window light/darkness based on our updated background color - window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor - - // 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 window.isVisible else { return } + // Sync our zoom state for splits + window.surfaceIsZoomed = surfaceTree.zoomed != nil // Set the font for the window and tab titles. if let titleFontName = surfaceConfig.windowTitleFontFamily { @@ -246,85 +472,8 @@ class TerminalController: BaseTerminalController { window.titlebarFont = nil } - // If we have window transparency then set it transparent. Otherwise set it opaque. - - // Window transparency only takes effect if our window is not native fullscreen. - // In native fullscreen we disable transparency/opacity because the background - // becomes gray and widgets show through. - if (!window.styleMask.contains(.fullScreen) && - surfaceConfig.backgroundOpacity < 1 - ) { - window.isOpaque = false - - // This is weird, but we don't use ".clear" because this creates a look that - // matches Terminal.app much more closer. This lets users transition from - // Terminal.app more easily. - window.backgroundColor = .white.withAlphaComponent(0.001) - - ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) - } else { - window.isOpaque = true - window.backgroundColor = .windowBackgroundColor - } - - window.hasShadow = surfaceConfig.macosWindowShadow - - guard window.hasStyledTabs else { return } - - // Our background color depends on if our focused surface borders the top or not. - // If it does, we match the focused surface. If it doesn't, we use the app - // configuration. - let backgroundColor: OSColor - if let surfaceTree { - if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) { - // Similar to above, an alpha component of "0" causes compositor issues, so - // we use 0.001. See: https://github.com/ghostty-org/ghostty/pull/4308 - backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.001) - } else { - // We don't have a focused surface or our surface doesn't border the - // top. We choose to match the color of the top-left most surface. - backgroundColor = OSColor(surfaceTree.topLeft().backgroundColor ?? derivedConfig.backgroundColor) - } - } else { - backgroundColor = OSColor(self.derivedConfig.backgroundColor) - } - window.titlebarColor = backgroundColor.withAlphaComponent(surfaceConfig.backgroundOpacity) - - if (window.isOpaque) { - // Bg color is only synced if we have no transparency. This is because - // the transparency is handled at the surface level (window.backgroundColor - // ignores alpha components) - window.backgroundColor = backgroundColor - - // If there is transparency, calling this will make the titlebar opaque - // so we only call this if we are opaque. - window.updateTabBar() - } - } - - private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { - guard let window else { return } - - // If we don't have an X/Y then we try to use the previously saved window pos. - guard let x, let y else { - if (!LastWindowPosition.shared.restore(window)) { - window.center() - } - - return - } - - // Prefer the screen our window is being placed on otherwise our primary screen. - guard let screen = window.screen ?? NSScreen.screens.first else { - window.center() - return - } - - // Orient based on the top left of the primary monitor - let frame = screen.visibleFrame - window.setFrameOrigin(.init( - x: frame.minX + CGFloat(x), - y: frame.maxY - (CGFloat(y) + window.frame.height))) + // Call this last in case it uses any of the properties above. + window.syncAppearance(surfaceConfig) } /// Returns the default size of the window. This is contextual based on the focused surface because @@ -376,6 +525,291 @@ class TerminalController: BaseTerminalController { return frame } + /// This is called anytime a node in the surface tree is being removed. + override func closeSurface( + _ node: SplitTree.Node, + withConfirmation: Bool = true + ) { + // If this isn't the root then we're dealing with a split closure. + if surfaceTree.root != node { + super.closeSurface(node, withConfirmation: withConfirmation) + return + } + + // More than 1 window means we have tabs and we're closing a tab + if window?.tabGroup?.windows.count ?? 0 > 1 { + closeTab(nil) + return + } + + // 1 window, closing the window + closeWindow(nil) + } + + private func closeTabImmediately() { + guard let window = window else { return } + guard let tabGroup = window.tabGroup, + tabGroup.windows.count > 1 else { + closeWindowImmediately() + return + } + + // Undo + if let undoManager, let undoState { + // Register undo action to restore the tab + undoManager.setActionName("Close Tab") + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: undoExpiration + ) { ghostty in + let newController = TerminalController(ghostty, with: undoState) + + // Register redo action + undoManager.registerUndo( + withTarget: newController, + expiresAfter: newController.undoExpiration + ) { target in + target.closeTabImmediately() + } + } + } + + window.close() + } + + /// Closes the current window (including any other tabs) immediately and without + /// confirmation. This will setup proper undo state so the action can be undone. + private func closeWindowImmediately() { + guard let window = window else { return } + + registerUndoForCloseWindow() + + if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 { + tabGroup.windows.forEach { window in + // Clear out the surfacetree to ensure there is no undo state. + // 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.surfaceTree = .init() + } + + window.close() + } + } else { + window.close() + } + } + + /// Registers undo for closing window(s), handling both single windows and tab groups. + private func registerUndoForCloseWindow() { + guard let undoManager, undoManager.isUndoRegistrationEnabled else { return } + guard let window else { return } + + // If we don't have a tab group or we don't have multiple tabs, then + // do a normal single window close. + guard let tabGroup = window.tabGroup, + tabGroup.windows.count > 1 else { + // No tabs, just save this window's state + if let undoState { + // Register undo action to restore the window + undoManager.setActionName("Close Window") + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: undoExpiration) { ghostty in + // Restore the undo state + let newController = TerminalController(ghostty, with: undoState) + + // Register redo action + undoManager.registerUndo( + withTarget: newController, + expiresAfter: newController.undoExpiration) { target in + target.closeWindowImmediately() + } + } + } + + return + } + + // Multiple windows in tab group - collect all undo states in sorted order + // by tab ordering. Also track which window was key. + let undoStates = tabGroup.windows + .compactMap { tabWindow -> UndoState? in + guard let controller = tabWindow.windowController as? TerminalController, + var undoState = controller.undoState else { return nil } + // Clear the tab group reference since it is unneeded. It should be + // garbage collected but we want to be extra sure we don't try to + // restore into it because we're going to recreate it. + undoState.tabGroup = nil + return undoState + } + .sorted { (lhs, rhs) in + switch (lhs.tabIndex, rhs.tabIndex) { + case let (l?, r?): return l < r + case (_?, nil): return true + case (nil, _?): return false + case (nil, nil): return true + } + } + + // Find the index of the key window in our sorted states. This is a bit verbose + // but we only need this for this style of undo so we don't want to add it to + // UndoState. + let keyWindowIndex: Int? + if let keyWindow = tabGroup.windows.first(where: { $0.isKeyWindow }), + let keyController = keyWindow.windowController as? TerminalController, + let keyUndoState = keyController.undoState { + keyWindowIndex = undoStates.firstIndex { + $0.tabIndex == keyUndoState.tabIndex } + } else { + keyWindowIndex = nil + } + + // Register undo action to restore all windows + guard !undoStates.isEmpty else { return } + + undoManager.setActionName("Close Window") + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: undoExpiration + ) { ghostty in + // Restore all windows in the tab group + let controllers = undoStates.map { undoState in + TerminalController(ghostty, with: undoState) + } + + // The first controller becomes the parent window for all tabs. + // If we don't have a first controller (shouldn't be possible?) + // then we can't restore tabs. + guard let firstController = controllers.first else { return } + + // Add all subsequent controllers as tabs to the first window + for controller in controllers.dropFirst() { + controller.showWindow(nil) + if let firstWindow = firstController.window, + let newWindow = controller.window { + firstWindow.addTabbedWindow(newWindow, ordered: .above) + } + } + + // Make the appropriate window key. If we had a key window, restore it. + // Otherwise, make the last window key. + if let keyWindowIndex, keyWindowIndex < controllers.count { + controllers[keyWindowIndex].window?.makeKeyAndOrderFront(nil) + } else { + controllers.last?.window?.makeKeyAndOrderFront(nil) + } + + // Register redo action on the first controller + undoManager.registerUndo( + withTarget: firstController, + expiresAfter: firstController.undoExpiration + ) { target in + target.closeWindowImmediately() + } + } + } + + /// Close all windows, asking for confirmation if necessary. + static func closeAllWindows() { + let needsConfirm: Bool = all.contains { + $0.surfaceTree.contains { $0.needsConfirmQuit } + } + + if (!needsConfirm) { + closeAllWindowsImmediately() + return + } + + // If we don't have a main window, we just close all windows because + // we have no window to show the modal on top of. I'm sure there's a way + // to do an app-level alert but I don't know how and this case should never + // really happen. + guard let alertWindow = preferredParent?.window else { + closeAllWindowsImmediately() + return + } + + // If we need confirmation by any, show one confirmation for all windows + let alert = NSAlert() + alert.messageText = "Close All Windows?" + alert.informativeText = "All terminal sessions will be terminated." + alert.addButton(withTitle: "Close All Windows") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: alertWindow, completionHandler: { response in + if (response == .alertFirstButtonReturn) { + closeAllWindowsImmediately() + } + }) + } + + static private func closeAllWindowsImmediately() { + let undoManager = (NSApp.delegate as? AppDelegate)?.undoManager + undoManager?.beginUndoGrouping() + all.forEach { $0.closeWindowImmediately() } + undoManager?.setActionName("Close All Windows") + undoManager?.endUndoGrouping() + } + + // MARK: Undo/Redo + + /// The state that we require to recreate a TerminalController from an undo. + struct UndoState { + let frame: NSRect + let surfaceTree: SplitTree + let focusedSurface: UUID? + let tabIndex: Int? + weak var tabGroup: NSWindowTabGroup? + } + + convenience init(_ ghostty: Ghostty.App, + with undoState: UndoState + ) { + self.init(ghostty, withSurfaceTree: undoState.surfaceTree) + + // Show the window and restore its frame + showWindow(nil) + if let window { + window.setFrame(undoState.frame, display: true) + + // If we have a tab group and index, restore the tab to its original position + if let tabGroup = undoState.tabGroup, + let tabIndex = undoState.tabIndex { + if tabIndex < tabGroup.windows.count { + // Find the window that is currently at that index + let currentWindow = tabGroup.windows[tabIndex] + currentWindow.addTabbedWindow(window, ordered: .below) + } else { + tabGroup.windows.last?.addTabbedWindow(window, ordered: .above) + } + + // Make it the key window + window.makeKeyAndOrderFront(nil) + } + + // Restore focus to the previously focused surface + if let focusedUUID = undoState.focusedSurface, + let focusTarget = surfaceTree.first(where: { $0.uuid == focusedUUID }) { + DispatchQueue.main.async { + Ghostty.moveFocus(to: focusTarget, from: nil) + } + } + } + } + + /// The current undo state for this controller + var undoState: UndoState? { + guard let window else { return nil } + guard !surfaceTree.isEmpty else { return nil } + return .init( + frame: window.frame, + surfaceTree: surfaceTree, + focusedSurface: focusedSurface?.uuid, + tabIndex: window.tabGroup?.windows.firstIndex(of: window), + tabGroup: window.tabGroup) + } + //MARK: - NSWindowController override func windowWillLoad() { @@ -383,46 +817,9 @@ class TerminalController: BaseTerminalController { shouldCascadeWindows = false } - fileprivate func applyHiddenTitlebarStyle() { - guard let window else { return } - - window.styleMask = [ - // We need `titled` in the mask to get the normal window frame - .titled, - - // Full size content view so we can extend - // content in to the hidden titlebar's area - .fullSizeContentView, - - .resizable, - .closable, - .miniaturizable, - ] - - // Hide the title - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - - // Hide the traffic lights (window control buttons) - window.standardWindowButton(.closeButton)?.isHidden = true - window.standardWindowButton(.miniaturizeButton)?.isHidden = true - window.standardWindowButton(.zoomButton)?.isHidden = true - - // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. - window.tabbingMode = .disallowed - - // Nuke it from orbit -- hide the titlebar container entirely, just in case. There are - // some operations that appear to bring back the titlebar visibility so this ensures - // it is gone forever. - if let themeFrame = window.contentView?.superview, - let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { - titleBarContainer.isHidden = true - } - } - override func windowDidLoad() { super.windowDidLoad() - guard let window = window as? TerminalWindow else { return } + guard let window else { return } // Store our initial frame so we can know our default later. initialFrame = window.frame @@ -440,55 +837,18 @@ class TerminalController: BaseTerminalController { window.identifier = .init(String(describing: TerminalWindowRestoration.self)) } - // If window decorations are disabled, remove our title - if (!config.windowDecorations) { window.styleMask.remove(.titled) } - // If we have only a single surface (no splits) and there is a default size then // we should resize to that default size. - if case let .leaf(leaf) = surfaceTree { + if case let .leaf(view) = surfaceTree.root { // If this is our first surface then our focused surface will be nil // so we force the focused surface to the leaf. - focusedSurface = leaf.surface + focusedSurface = view if let defaultSize { window.setFrame(defaultSize, display: true) } } - // Set our window positioning to coordinates if config value exists, otherwise - // fallback to original centering behavior - setInitialWindowPosition( - x: config.windowPositionX, - y: config.windowPositionY, - windowDecorations: config.windowDecorations) - - // Make sure our theme is set on the window so styling is correct. - if let windowTheme = config.windowTheme { - window.windowTheme = .init(rawValue: windowTheme) - } - - // Handle titlebar tabs config option. Something about what we do while setting up the - // titlebar tabs interferes with the window restore process unless window.tabbingMode - // is set to .preferred, so we set it, and switch back to automatic as soon as we can. - if (config.macosTitlebarStyle == "tabs") { - window.tabbingMode = .preferred - window.titlebarTabs = true - DispatchQueue.main.async { - window.tabbingMode = .automatic - } - } else if (config.macosTitlebarStyle == "transparent") { - window.transparentTabs = true - } - - if window.hasStyledTabs { - // Set the background color of the window - let backgroundColor = NSColor(config.backgroundColor) - window.backgroundColor = backgroundColor - - // This makes sure our titlebar renders correctly when there is a transparent background - window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity) - } - // Initialize our content view to the SwiftUI root window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, @@ -496,11 +856,6 @@ class TerminalController: BaseTerminalController { delegate: self )) - // If our titlebar style is "hidden" we adjust the style appropriately - if (config.macosTitlebarStyle == "hidden") { - applyHiddenTitlebarStyle() - } - // 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. @@ -534,11 +889,58 @@ class TerminalController: BaseTerminalController { ghostty.newTab(surface: surface) } - //MARK: - NSWindowDelegate + // MARK: NSWindowDelegate + + // TabGroupCloseCoordinator.Controller + lazy private(set) var tabGroupCloseCoordinator = TabGroupCloseCoordinator() + + override func windowShouldClose(_ sender: NSWindow) -> Bool { + tabGroupCloseCoordinator.windowShouldClose(sender) { [weak self] scope in + guard let self else { return } + switch (scope) { + case .tab: closeTab(nil) + case .window: + guard self.window?.isFirstWindowInTabGroup ?? false else { return } + closeWindow(nil) + } + } + + // We will always explicitly close the window using the above + return false + } override func windowWillClose(_ notification: Notification) { super.windowWillClose(notification) self.relabelTabs() + + // If we remove a window, we reset the cascade point to the key window so that + // the next window cascade's from that one. + if let focusedWindow = NSApplication.shared.keyWindow { + // If we are NOT the focused window, then we are a tabbed window. If we + // are closing a tabbed window, we want to set the cascade point to be + // the next cascade point from this window. + if focusedWindow != window { + // The cascadeTopLeft call below should NOT move the window. Starting with + // macOS 15, we found that specifically when used with the new window snapping + // features of macOS 15, this WOULD move the frame. So we keep track of the + // old frame and restore it if necessary. Issue: + // https://github.com/ghostty-org/ghostty/issues/2565 + let oldFrame = focusedWindow.frame + + Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint) + + if focusedWindow.frame != oldFrame { + focusedWindow.setFrame(oldFrame, display: true) + } + + return + } + + // If we are the focused window, then we set the last cascade point to + // our own frame so that it shows up in the same spot. + let frame = focusedWindow.frame + Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) + } } override func windowDidBecomeKey(_ notification: Notification) { @@ -585,47 +987,24 @@ class TerminalController: BaseTerminalController { ghostty.newTab(surface: surface) } - private func confirmClose( - window: NSWindow, - messageText: String, - informativeText: String, - completion: @escaping () -> Void - ) { - // If we need confirmation by any, show one confirmation for all windows - // in the tab group. - let alert = NSAlert() - alert.messageText = messageText - alert.informativeText = informativeText - alert.addButton(withTitle: "Close") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window) { response in - if response == .alertFirstButtonReturn { - completion() - } - } - } - @IBAction func closeTab(_ sender: Any?) { guard let window = window else { return } - guard window.tabGroup != nil else { - // No tabs, no tab group, just perform a normal close. - window.performClose(sender) + guard window.tabGroup?.windows.count ?? 0 > 1 else { + closeWindow(sender) return } - if surfaceTree?.needsConfirmQuit() ?? false { - confirmClose( - window: window, - messageText: "Close Tab?", - informativeText: "The terminal still has a running process. If you close the tab the process will be killed." - ) { - window.close() - } + guard surfaceTree.contains(where: { $0.needsConfirmQuit }) else { + closeTabImmediately() return } - window.close() + confirmClose( + messageText: "Close Tab?", + informativeText: "The terminal still has a running process. If you close the tab the process will be killed." + ) { + self.closeTabImmediately() + } } @IBAction func returnToDefaultSize(_ sender: Any?) { @@ -635,38 +1014,31 @@ class TerminalController: BaseTerminalController { @IBAction override func closeWindow(_ sender: Any?) { guard let window = window else { return } - guard let tabGroup = window.tabGroup else { - // No tabs, no tab group, just perform a normal close. - window.performClose(sender) - return - } - // If have one window then we just do a normal close - if tabGroup.windows.count == 1 { - window.performClose(sender) - return - } + // We need to check all the windows in our tab group for confirmation + // if we're closing the window. If we don't have a tabgroup for any + // reason we check ourselves. + let windows: [NSWindow] = window.tabGroup?.windows ?? [window] // Check if any windows require close confirmation. - let needsConfirm = tabGroup.windows.contains { tabWindow in + let needsConfirm = windows.contains { tabWindow in guard let controller = tabWindow.windowController as? TerminalController else { return false } - return controller.surfaceTree?.needsConfirmQuit() ?? false + return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) } // If none need confirmation then we can just close all the windows. if !needsConfirm { - tabGroup.windows.forEach { $0.close() } + closeWindowImmediately() return } confirmClose( - window: window, messageText: "Close Window?", informativeText: "All terminal sessions in this window will be terminated." ) { - tabGroup.windows.forEach { $0.close() } + self.closeWindowImmediately() } } @@ -681,35 +1053,7 @@ class TerminalController: BaseTerminalController { } //MARK: - TerminalViewDelegate - - override func titleDidChange(to: String) { - super.titleDidChange(to: to) - - guard let window = window as? TerminalWindow else { return } - - // Custom toolbar-based title used when titlebar tabs are enabled. - if let toolbar = window.toolbar as? TerminalToolbar { - if (window.titlebarTabs || derivedConfig.macosTitlebarStyle == "hidden") { - // Updating the title text as above automatically reveals the - // native title view in macOS 15.0 and above. Since we're using - // a custom view instead, we need to re-hide it. - window.titleVisibility = .hidden - } - toolbar.titleText = to - } - } - - override func surfaceTreeDidChange() { - // Whenever our surface tree changes in any way (new split, close split, etc.) - // we want to invalidate our state. - invalidateRestorableState() - } - - override func zoomStateDidChange(to: Bool) { - guard let window = window as? TerminalWindow else { return } - window.surfaceIsZoomed = to - } - + override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { super.focusedSurfaceDidChange(to: to) @@ -842,19 +1186,19 @@ class TerminalController: BaseTerminalController { @objc private func onCloseTab(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: target) ?? false else { return } + guard surfaceTree.contains(target) else { return } closeTab(self) } @objc private func onCloseWindow(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: target) ?? false else { return } + guard surfaceTree.contains(target) else { return } closeWindow(self) } @objc private func onResetWindowSize(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: target) ?? false else { return } + guard surfaceTree.contains(target) else { return } returnToDefaultSize(nil) } @@ -875,36 +1219,29 @@ class TerminalController: BaseTerminalController { toggleFullscreen(mode: fullscreenMode) } - @objc private func onEqualizeSplits(_ notification: Notification) { - guard let target = notification.object as? Ghostty.SurfaceView else { return } - - // Check if target surface is in current controller's tree - guard surfaceTree?.contains(view: target) ?? false else { return } - - if case .split(let container) = surfaceTree { - _ = container.equalize() - } - } - struct DerivedConfig { let backgroundColor: Color + let macosWindowButtons: Ghostty.MacOSWindowButtons let macosTitlebarStyle: String let maximize: Bool init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) + self.macosWindowButtons = .visible self.macosTitlebarStyle = "system" self.maximize = false } init(_ config: Ghostty.Config) { self.backgroundColor = config.backgroundColor + self.macosWindowButtons = config.macosWindowButtons self.macosTitlebarStyle = config.macosTitlebarStyle self.maximize = config.maximize } } } +// MARK: NSMenuItemValidation extension TerminalController: NSMenuItemValidation { func validateMenuItem(_ item: NSMenuItem) -> Bool { @@ -940,4 +1277,3 @@ extension TerminalController: NSMenuItemValidation { } } } - diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift deleted file mode 100644 index 07735cb58..000000000 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ /dev/null @@ -1,372 +0,0 @@ -import Cocoa -import SwiftUI -import GhosttyKit -import Combine - -/// Manages a set of terminal windows. This is effectively an array of TerminalControllers. -/// This abstraction helps manage tabs and multi-window scenarios. -class TerminalManager { - struct Window { - let controller: TerminalController - let closePublisher: AnyCancellable - } - - let ghostty: Ghostty.App - - /// The currently focused surface of the main window. - var focusedSurface: Ghostty.SurfaceView? { mainWindow?.controller.focusedSurface } - - /// The set of windows we currently have. - var windows: [Window] = [] - - // Keep track of the last point that our window was launched at so that new - // windows "cascade" over each other and don't just launch directly on top - // of each other. - private static var lastCascadePoint = NSPoint(x: 0, y: 0) - - /// Returns the main window of the managed window stack. If there is no window - /// then an arbitrary window will be chosen. - private var mainWindow: Window? { - for window in windows { - if (window.controller.window?.isMainWindow ?? false) { - return window - } - } - - // If we have no main window, just use the last window. - return windows.last - } - - /// The configuration derived from the Ghostty config so we don't need to rely on references. - private var derivedConfig: DerivedConfig - - init(_ ghostty: Ghostty.App) { - self.ghostty = ghostty - self.derivedConfig = DerivedConfig(ghostty.config) - - let center = NotificationCenter.default - center.addObserver( - self, - selector: #selector(onNewTab), - name: Ghostty.Notification.ghosttyNewTab, - object: nil) - center.addObserver( - self, - selector: #selector(onNewWindow), - name: Ghostty.Notification.ghosttyNewWindow, - object: nil) - center.addObserver( - self, - selector: #selector(ghosttyConfigDidChange(_:)), - name: .ghosttyConfigDidChange, - object: nil) - } - - deinit { - let center = NotificationCenter.default - center.removeObserver(self) - } - - // MARK: - Window Management - - /// Create a new terminal window. - func newWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { - let c = createWindow(withBaseConfig: base) - let window = c.window! - - // If the previous focused window was native fullscreen, the new window also - // becomes native fullscreen. - if let parent = focusedSurface?.window, - parent.styleMask.contains(.fullScreen) { - window.toggleFullScreen(nil) - } else if derivedConfig.windowFullscreen { - switch (derivedConfig.windowFullscreenMode) { - case .native: - // Native has to be done immediately so that our stylemask contains - // fullscreen for the logic later in this method. - c.toggleFullscreen(mode: .native) - - case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch: - // If we're non-native then we have to do it on a later loop - // so that the content view is setup. - DispatchQueue.main.async { - c.toggleFullscreen(mode: self.derivedConfig.windowFullscreenMode) - } - } - } - - // All new_window actions force our app to be active. - NSApp.activate(ignoringOtherApps: true) - - // 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 { - // Only cascade if we aren't fullscreen. - if (!window.styleMask.contains(.fullScreen)) { - Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) - } - - c.showWindow(self) - } - } - - /// Creates a new tab in the current main window. If there are no windows, a window - /// is created. - func newTab(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { - // If there is no main window, just create a new window - guard let parent = mainWindow?.controller.window else { - newWindow(withBaseConfig: base) - return - } - - // Create a new window and add it to the parent - newTab(to: parent, withBaseConfig: base) - } - - private func newTab(to parent: NSWindow, withBaseConfig base: Ghostty.SurfaceConfiguration?) { - // Making sure that we're dealing with a TerminalController - guard parent.windowController is TerminalController else { return } - - // If our parent is in non-native fullscreen, then new tabs do not work. - // See: https://github.com/mitchellh/ghostty/issues/392 - if let controller = parent.windowController as? TerminalController, - let fullscreenStyle = controller.fullscreenStyle, - fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs { - let alert = NSAlert() - alert.messageText = "Cannot Create New Tab" - alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again." - alert.addButton(withTitle: "OK") - alert.alertStyle = .warning - alert.beginSheetModal(for: parent) - return - } - - // Create a new window and add it to the parent - let controller = createWindow(withBaseConfig: base) - let window = controller.window! - - // If the parent is miniaturized, then macOS exhibits really strange behaviors - // so we have to bring it back out. - if (parent.isMiniaturized) { parent.deminiaturize(self) } - - // If our parent tab group already has this window, macOS added it and - // we need to remove it so we can set the correct order in the next line. - // If we don't do this, macOS gets really confused and the tabbedWindows - // state becomes incorrect. - // - // At the time of writing this code, the only known case this happens - // is when the "+" button is clicked in the tab bar. - if let tg = parent.tabGroup, tg.windows.firstIndex(of: window) != nil { - tg.removeWindow(window) - } - - // Our windows start out invisible. We need to make it visible. If we - // don't do this then various features such as window blur won't work because - // the macOS APIs only work on a visible window. - controller.showWindow(self) - - // If we have the "hidden" titlebar style we want to create new - // tabs as windows instead, so just skip adding it to the parent. - if (derivedConfig.macosTitlebarStyle != "hidden") { - // Add the window to the tab group and show it. - switch derivedConfig.windowNewTabPosition { - case "end": - // If we already have a tab group and we want the new tab to open at the end, - // then we use the last window in the tab group as the parent. - if let last = parent.tabGroup?.windows.last { - last.addTabbedWindow(window, ordered: .above) - } else { - fallthrough - } - case "current": fallthrough - default: - parent.addTabbedWindow(window, ordered: .above) - - } - } - - window.makeKeyAndOrderFront(self) - - // It takes an event loop cycle until the macOS tabGroup state becomes - // consistent which causes our tab labeling to be off when the "+" button - // is used in the tab bar. This fixes that. If we can find a more robust - // solution we should do that. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { controller.relabelTabs() } - } - - /// Creates a window controller, adds it to our managed list, and returns it. - func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: Ghostty.SplitNode? = nil) -> TerminalController { - // Initialize our controller to load the window - let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree) - - // Create a listener for when the window is closed so we can remove it. - let pubClose = NotificationCenter.default.publisher( - for: NSWindow.willCloseNotification, - object: c.window! - ).sink { notification in - guard let window = notification.object as? NSWindow else { return } - guard let c = window.windowController as? TerminalController else { return } - self.removeWindow(c) - } - - // Keep track of every window we manage - windows.append(Window( - controller: c, - closePublisher: pubClose - )) - - return c - } - - func removeWindow(_ controller: TerminalController) { - // Remove it from our managed set - guard let idx = self.windows.firstIndex(where: { $0.controller == controller }) else { return } - let w = self.windows[idx] - self.windows.remove(at: idx) - - // Ensure any publishers we have are cancelled - w.closePublisher.cancel() - - // If we remove a window, we reset the cascade point to the key window so that - // the next window cascade's from that one. - if let focusedWindow = NSApplication.shared.keyWindow { - // If we are NOT the focused window, then we are a tabbed window. If we - // are closing a tabbed window, we want to set the cascade point to be - // the next cascade point from this window. - if focusedWindow != controller.window { - // The cascadeTopLeft call below should NOT move the window. Starting with - // macOS 15, we found that specifically when used with the new window snapping - // features of macOS 15, this WOULD move the frame. So we keep track of the - // old frame and restore it if necessary. Issue: - // https://github.com/ghostty-org/ghostty/issues/2565 - let oldFrame = focusedWindow.frame - - Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint) - - if focusedWindow.frame != oldFrame { - focusedWindow.setFrame(oldFrame, display: true) - } - - return - } - - // If we are the focused window, then we set the last cascade point to - // our own frame so that it shows up in the same spot. - let frame = focusedWindow.frame - Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) - } - - // I don't think we strictly have to do this but if a window is - // closed I want to make sure that the app state is invalided so - // we don't reopen closed windows. - NSApplication.shared.invalidateRestorableState() - } - - /// Close all windows, asking for confirmation if necessary. - func closeAllWindows() { - var needsConfirm: Bool = false - for w in self.windows { - if (w.controller.surfaceTree?.needsConfirmQuit() ?? false) { - needsConfirm = true - break - } - } - - if (!needsConfirm) { - for w in self.windows { - w.controller.close() - } - - return - } - - // If we don't have a main window, we just close all windows because - // we have no window to show the modal on top of. I'm sure there's a way - // to do an app-level alert but I don't know how and this case should never - // really happen. - guard let alertWindow = mainWindow?.controller.window else { - for w in self.windows { - w.controller.close() - } - - return - } - - // If we need confirmation by any, show one confirmation for all windows - let alert = NSAlert() - alert.messageText = "Close All Windows?" - alert.informativeText = "All terminal sessions will be terminated." - alert.addButton(withTitle: "Close All Windows") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: alertWindow, completionHandler: { response in - if (response == .alertFirstButtonReturn) { - for w in self.windows { - w.controller.close() - } - } - }) - } - - /// Relabels all the tabs with the proper keyboard shortcut. - func relabelAllTabs() { - for w in windows { - w.controller.relabelTabs() - } - } - - // MARK: - Notifications - - @objc private func onNewWindow(notification: SwiftUI.Notification) { - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? Ghostty.SurfaceConfiguration - self.newWindow(withBaseConfig: config) - } - - @objc private func onNewTab(notification: SwiftUI.Notification) { - guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard let window = surfaceView.window else { return } - - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? Ghostty.SurfaceConfiguration - - self.newTab(to: window, withBaseConfig: config) - } - - @objc private func ghosttyConfigDidChange(_ notification: Notification) { - // We only care if the configuration is a global configuration, not a - // surface-specific one. - guard notification.object == nil else { return } - - // Get our managed configuration object out - guard let config = notification.userInfo?[ - Notification.Name.GhosttyConfigChangeKey - ] as? Ghostty.Config else { return } - - // Update our derived config - self.derivedConfig = DerivedConfig(config) - } - - private struct DerivedConfig { - let windowFullscreen: Bool - let windowFullscreenMode: FullscreenMode - let macosTitlebarStyle: String - let windowNewTabPosition: String - - init() { - self.windowFullscreen = false - self.windowFullscreenMode = .native - self.macosTitlebarStyle = "transparent" - self.windowNewTabPosition = "" - } - - init(_ config: Ghostty.Config) { - self.windowFullscreen = config.windowFullscreen - self.windowFullscreenMode = config.windowFullscreenMode - self.macosTitlebarStyle = config.macosTitlebarStyle - self.windowNewTabPosition = config.windowNewTabPosition - } - } -} diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index b9d9b0ac0..9d9b7ffb1 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,10 +4,10 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 2 + static let version: Int = 3 let focusedSurface: String? - let surfaceTree: Ghostty.SplitNode? + let surfaceTree: SplitTree init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.uuid.uuidString @@ -83,18 +83,29 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // can be found for events from libghostty. This uses the low-level // createWindow so that AppKit can place the window wherever it should // be. - let c = appDelegate.terminalManager.createWindow(withSurfaceTree: state.surfaceTree) + let c = TerminalController.init( + appDelegate.ghostty, + withSurfaceTree: state.surfaceTree) guard let window = c.window else { completionHandler(nil, TerminalRestoreError.windowDidNotLoad) return } // Setup our restored state on the controller - if let focusedStr = state.focusedSurface, - let focusedUUID = UUID(uuidString: focusedStr), - let view = c.surfaceTree?.findUUID(uuid: focusedUUID) { - c.focusedSurface = view - restoreFocus(to: view, inWindow: window) + // Find the focused surface in surfaceTree + if let focusedStr = state.focusedSurface { + var foundView: Ghostty.SurfaceView? + for view in c.surfaceTree { + if view.uuid.uuidString == focusedStr { + foundView = view + break + } + } + + if let view = foundView { + c.focusedSurface = view + restoreFocus(to: view, inWindow: window) + } } completionHandler(window, nil) diff --git a/macos/Sources/Features/Terminal/TerminalToolbar.swift b/macos/Sources/Features/Terminal/TerminalToolbar.swift deleted file mode 100644 index aa4ca31cd..000000000 --- a/macos/Sources/Features/Terminal/TerminalToolbar.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Cocoa - -// Custom NSToolbar subclass that displays a centered window title, -// in order to accommodate the titlebar tabs feature. -class TerminalToolbar: NSToolbar, NSToolbarDelegate { - private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty") - - var titleText: String { - get { - titleTextField.stringValue - } - - set { - titleTextField.stringValue = newValue - } - } - - var titleFont: NSFont? { - get { - titleTextField.font - } - - set { - titleTextField.font = newValue - } - } - - override init(identifier: NSToolbar.Identifier) { - super.init(identifier: identifier) - - delegate = self - centeredItemIdentifiers.insert(.titleText) - } - - func toolbar(_ toolbar: NSToolbar, - itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, - willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { - var item: NSToolbarItem - - switch itemIdentifier { - case .titleText: - item = NSToolbarItem(itemIdentifier: .titleText) - item.view = self.titleTextField - item.visibilityPriority = .user - - // This ensures the title text field doesn't disappear when shrinking the view - self.titleTextField.translatesAutoresizingMaskIntoConstraints = false - self.titleTextField.setContentHuggingPriority(.defaultLow, for: .horizontal) - self.titleTextField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - - // Add constraints to the toolbar item's view - NSLayoutConstraint.activate([ - // Set the height constraint to match the toolbar's height - self.titleTextField.heightAnchor.constraint(equalToConstant: 22), // Adjust as needed - ]) - - item.isEnabled = true - case .resetZoom: - item = NSToolbarItem(itemIdentifier: .resetZoom) - default: - item = NSToolbarItem(itemIdentifier: itemIdentifier) - } - - return item - } - - func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - return [.titleText, .flexibleSpace, .space, .resetZoom] - } - - func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - // These space items are here to ensure that the title remains centered when it starts - // getting smaller than the max size so starts clipping. Lucky for us, two of the - // built-in spacers plus the un-zoom button item seems to exactly match the space - // on the left that's reserved for the window buttons. - return [.flexibleSpace, .titleText, .flexibleSpace] - } -} - -/// A label that expands to fit whatever text you put in it and horizontally centers itself in the current window. -fileprivate class CenteredDynamicLabel: NSTextField { - override func viewDidMoveToSuperview() { - // Configure the text field - isEditable = false - isBordered = false - drawsBackground = false - alignment = .center - lineBreakMode = .byTruncatingTail - cell?.truncatesLastVisibleLine = true - - // Use Auto Layout - translatesAutoresizingMaskIntoConstraints = false - - // Set content hugging and compression resistance priorities - setContentHuggingPriority(.defaultLow, for: .horizontal) - setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - } - - // Vertically center the text - override func draw(_ dirtyRect: NSRect) { - guard let attributedString = self.attributedStringValue.mutableCopy() as? NSMutableAttributedString else { - super.draw(dirtyRect) - return - } - - let textSize = attributedString.size() - - let yOffset = (self.bounds.height - textSize.height) / 2 - 1 // -1 to center it better - - let centeredRect = NSRect(x: self.bounds.origin.x, y: self.bounds.origin.y + yOffset, - width: self.bounds.width, height: textSize.height) - - attributedString.draw(in: centeredRect) - } -} - -extension NSToolbarItem.Identifier { - static let resetZoom = NSToolbarItem.Identifier("ResetZoom") - static let titleText = NSToolbarItem.Identifier("TitleText") -} diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 7caceb071..b5be0ae42 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -14,15 +14,11 @@ protocol TerminalViewDelegate: AnyObject { /// The cell size changed. func cellSizeDidChange(to: NSSize) - /// The surface tree did change in some way, i.e. a split was added, removed, etc. This is - /// not called initially. - func surfaceTreeDidChange() - - /// This is called when a split is zoomed. - func zoomStateDidChange(to: Bool) - /// Perform an action. At the time of writing this is only triggered by the command palette. func performAction(_ action: String, on: Ghostty.SurfaceView) + + /// A split is resizing to a given value. + func splitDidResize(node: SplitTree.Node, to newRatio: Double) } /// The view model is a required implementation for TerminalView callers. This contains @@ -31,7 +27,7 @@ protocol TerminalViewDelegate: AnyObject { protocol TerminalViewModel: ObservableObject { /// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView /// and children. This should be @Published. - var surfaceTree: Ghostty.SplitNode? { get set } + var surfaceTree: SplitTree { get set } /// The command palette state. var commandPaletteIsShowing: Bool { get set } @@ -57,7 +53,6 @@ struct TerminalView: View { // Various state values sent back up from the currently focused terminals. @FocusedValue(\.ghosttySurfaceView) private var focusedSurface @FocusedValue(\.ghosttySurfacePwd) private var surfacePwd - @FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit @FocusedValue(\.ghosttySurfaceCellSize) private var cellSize // The pwd of the focused surface as a URL @@ -81,7 +76,9 @@ struct TerminalView: View { DebugBuildWarningView() } - Ghostty.TerminalSplit(node: $viewModel.surfaceTree) + TerminalSplitTreeView( + tree: viewModel.surfaceTree, + onResize: { delegate?.splitDidResize(node: $0, to: $1) }) .environmentObject(ghostty) .focused($focused) .onAppear { self.focused = true } @@ -100,15 +97,6 @@ struct TerminalView: View { guard let size = newValue else { return } self.delegate?.cellSizeDidChange(to: size) } - .onChange(of: viewModel.surfaceTree?.hashValue) { _ in - // This is funky, but its the best way I could think of to detect - // ANY CHANGE within the deeply nested surface tree -- detecting a change - // in the hash value. - self.delegate?.surfaceTreeDidChange() - } - .onChange(of: zoomedSplit) { newValue in - self.delegate?.zoomStateDidChange(to: newValue ?? false) - } } // 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 : []) @@ -151,6 +139,10 @@ struct DebugBuildWarningView: View { } .background(Color(.windowBackgroundColor)) .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + .accessibilityLabel("Debug build warning") + .accessibilityValue("Debug builds of Ghostty are very slow and you may experience performance problems. Debug builds are only recommended during development.") + .accessibilityAddTraits(.isStaticText) .onTapGesture { isPopover = true } diff --git a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift new file mode 100644 index 000000000..5f4d6b177 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift @@ -0,0 +1,89 @@ +import AppKit + +class HiddenTitlebarTerminalWindow: TerminalWindow { + override func awakeFromNib() { + super.awakeFromNib() + + // Setup our initial style + reapplyHiddenStyle() + + // Notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(fullscreenDidExit(_:)), + name: .fullscreenDidExit, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + /// Apply the hidden titlebar style. + private func reapplyHiddenStyle() { + styleMask = [ + // We need `titled` in the mask to get the normal window frame + .titled, + + // Full size content view so we can extend + // content in to the hidden titlebar's area + .fullSizeContentView, + + .resizable, + .closable, + .miniaturizable, + ] + + // Hide the title + titleVisibility = .hidden + titlebarAppearsTransparent = true + + // Hide the traffic lights (window control buttons) + standardWindowButton(.closeButton)?.isHidden = true + standardWindowButton(.miniaturizeButton)?.isHidden = true + standardWindowButton(.zoomButton)?.isHidden = true + + // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. + tabbingMode = .disallowed + + // Nuke it from orbit -- hide the titlebar container entirely, just in case. There are + // some operations that appear to bring back the titlebar visibility so this ensures + // it is gone forever. + if let themeFrame = contentView?.superview, + let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { + titleBarContainer.isHidden = true + } + } + + // MARK: NSWindow + + override var title: String { + didSet { + // Updating the title text as above automatically reveals the + // native title view in macOS 15.0 and above. Since we're using + // a custom view instead, we need to re-hide it. + reapplyHiddenStyle() + } + } + + // We override this so that with the hidden titlebar style the titlebar + // area is not draggable. + override var contentLayoutRect: CGRect { + var rect = super.contentLayoutRect + rect.origin.y = 0 + rect.size.height = self.frame.height + return rect + } + + // MARK: Notifications + + @objc private func fullscreenDidExit(_ notification: Notification) { + // Make sure they're talking about our window + guard let fullscreen = notification.object as? FullscreenBase else { return } + guard fullscreen.window == self else { return } + + // On exit we need to reapply the style because macOS breaks it usually. + // This is safe to call repeatedly so if its not broken its still safe. + reapplyHiddenStyle() + } +} diff --git a/macos/Sources/Features/Terminal/Terminal.xib b/macos/Sources/Features/Terminal/Window Styles/Terminal.xib similarity index 86% rename from macos/Sources/Features/Terminal/Terminal.xib rename to macos/Sources/Features/Terminal/Window Styles/Terminal.xib index 65b03b6eb..cfbb2221c 100644 --- a/macos/Sources/Features/Terminal/Terminal.xib +++ b/macos/Sources/Features/Terminal/Window Styles/Terminal.xib @@ -1,8 +1,8 @@ - + - + @@ -17,10 +17,10 @@ - + - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib new file mode 100644 index 000000000..eb4675657 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib new file mode 100644 index 000000000..deaeded9f --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib new file mode 100644 index 000000000..bf53a4510 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib new file mode 100644 index 000000000..25922e2f3 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift new file mode 100644 index 000000000..cec85f06e --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -0,0 +1,480 @@ +import AppKit +import SwiftUI +import GhosttyKit + +/// The base class for all standalone, "normal" terminal windows. This sets the basic +/// style and configuration of the window based on the app configuration. +class TerminalWindow: NSWindow { + /// This is the key in UserDefaults to use for the default `level` value. This is + /// used by the manual float on top menu item feature. + static let defaultLevelKey: String = "TerminalDefaultLevel" + + /// The view model for SwiftUI views + private var viewModel = ViewModel() + + /// Reset split zoom button in titlebar + private let resetZoomAccessory = NSTitlebarAccessoryViewController() + + /// The configuration derived from the Ghostty config so we don't need to rely on references. + private(set) var derivedConfig: DerivedConfig = .init() + + /// Gets the terminal controller from the window controller. + var terminalController: TerminalController? { + windowController as? TerminalController + } + + // MARK: NSWindow Overrides + + override var toolbar: NSToolbar? { + didSet { + DispatchQueue.main.async { + // When we have a toolbar, our SwiftUI view needs to know for layout + self.viewModel.hasToolbar = self.toolbar != nil + } + } + } + + override func awakeFromNib() { + guard let appDelegate = NSApp.delegate as? AppDelegate else { return } + + // All new windows are based on the app config at the time of creation. + let config = appDelegate.ghostty.config + + // Setup our initial config + derivedConfig = .init(config) + + // 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, + windowDecorations: config.windowDecorations) + + // If our traffic buttons should be hidden, then hide them + if config.macosWindowButtons == .hidden { + hideWindowButtons() + } + + // Create our reset zoom titlebar accessory. We have to have a title + // to do this or AppKit triggers an assertion. + if styleMask.contains(.titled) { + resetZoomAccessory.layoutAttribute = .right + resetZoomAccessory.view = NSHostingView(rootView: ResetZoomAccessoryView( + viewModel: viewModel, + action: { [weak self] in + guard let self else { return } + self.terminalController?.splitZoom(self) + })) + addTitlebarAccessoryViewController(resetZoomAccessory) + resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false + } + + // Setup the accessory view for tabs that shows our keyboard shortcuts, + // zoomed state, etc. Note I tried to use SwiftUI here but ran into issues + // where buttons were not clickable. + let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton]) + stackView.setHuggingPriority(.defaultHigh, for: .horizontal) + stackView.spacing = 3 + tab.accessoryView = stackView + + // Get our saved level + level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal + } + + // Both of these must be true for windows without decorations to be able to + // still become key/main and receive events. + override var canBecomeKey: Bool { return true } + override var canBecomeMain: Bool { return true } + + override func becomeKey() { + super.becomeKey() + resetZoomTabButton.contentTintColor = .controlAccentColor + } + + override func resignKey() { + super.resignKey() + resetZoomTabButton.contentTintColor = .secondaryLabelColor + } + + override func becomeMain() { + super.becomeMain() + + // Its possible we miss the accessory titlebar call so we check again + // whenever the window becomes main. Both of these are idempotent. + if hasTabBar { + tabBarDidAppear() + } else { + tabBarDidDisappear() + } + } + + override func mergeAllWindows(_ sender: Any?) { + super.mergeAllWindows(sender) + + // It takes an event loop cycle to merge all the windows so we set a + // short timer to relabel the tabs (issue #1902) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.terminalController?.relabelTabs() + } + } + + override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { + 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 isTabBar(childViewController) { + childViewController.identifier = Self.tabBarIdentifier + tabBarDidAppear() + } + } + + override func removeTitlebarAccessoryViewController(at index: Int) { + if let childViewController = titlebarAccessoryViewControllers[safe: index], isTabBar(childViewController) { + tabBarDidDisappear() + } + + super.removeTitlebarAccessoryViewController(at: index) + } + + // MARK: Tab Bar + + /// This identifier is attached to the tab bar view controller when we detect it being + /// added. + static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + + /// Returns true if there is a tab bar visible on this window. + var hasTabBar: Bool { + contentView?.firstViewFromRoot(withClassName: "NSTabBar") != nil + } + + func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool { + if childViewController.identifier == nil { + // The good case + if childViewController.view.contains(className: "NSTabBar") { + return true + } + + // When a new window is attached to an existing tab group, AppKit adds + // an empty NSView as an accessory view and adds the tab bar later. If + // we're at the bottom and are a single NSView we assume its a tab bar. + if childViewController.layoutAttribute == .bottom && + childViewController.view.className == "NSView" && + childViewController.view.subviews.isEmpty { + return true + } + + return false + } + + // View controllers should be tagged with this as soon as possible to + // increase our accuracy. We do this manually. + return childViewController.identifier == Self.tabBarIdentifier + } + + private func tabBarDidAppear() { + // Remove our reset zoom accessory. For some reason having a SwiftUI + // titlebar accessory causes our content view scaling to be wrong. + // Removing it fixes it, we just need to remember to add it again later. + if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) { + removeTitlebarAccessoryViewController(at: idx) + } + } + + private func tabBarDidDisappear() { + if styleMask.contains(.titled) { + if titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) == nil { + addTitlebarAccessoryViewController(resetZoomAccessory) + } + } + } + + // MARK: Tab Key Equivalents + + var keyEquivalent: String? = nil { + didSet { + // When our key equivalent is set, we must update the tab label. + guard let keyEquivalent else { + keyEquivalentLabel.attributedStringValue = NSAttributedString() + return + } + + keyEquivalentLabel.attributedStringValue = NSAttributedString( + string: "\(keyEquivalent) ", + attributes: [ + .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), + .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, + ]) + } + } + + /// The label that has the key equivalent for tab views. + private lazy var keyEquivalentLabel: NSTextField = { + let label = NSTextField(labelWithAttributedString: NSAttributedString()) + label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal) + label.postsFrameChangedNotifications = true + return label + }() + + // MARK: Surface Zoom + + /// Set to true if a surface is currently zoomed to show the reset zoom button. + var surfaceIsZoomed: Bool = false { + didSet { + // Show/hide our reset zoom button depending on if we're zoomed. + // We want to show it if we are zoomed. + resetZoomTabButton.isHidden = !surfaceIsZoomed + + DispatchQueue.main.async { + self.viewModel.isSurfaceZoomed = self.surfaceIsZoomed + } + } + } + + private lazy var resetZoomTabButton: NSButton = generateResetZoomButton() + + private func generateResetZoomButton() -> NSButton { + let button = NSButton() + button.isHidden = true + button.target = terminalController + button.action = #selector(TerminalController.splitZoom(_:)) + button.isBordered = false + button.allowsExpansionToolTips = true + button.toolTip = "Reset Zoom" + button.contentTintColor = .controlAccentColor + button.state = .on + button.image = NSImage(named:"ResetZoom") + button.frame = NSRect(x: 0, y: 0, width: 20, height: 20) + button.translatesAutoresizingMaskIntoConstraints = false + button.widthAnchor.constraint(equalToConstant: 20).isActive = true + button.heightAnchor.constraint(equalToConstant: 20).isActive = true + return button + } + + // MARK: Title Text + + override var title: String { + didSet { + // Whenever we change the window title we must also update our + // tab title if we're using custom fonts. + tab.attributedTitle = attributedTitle + } + } + + // Used to set the titlebar font. + var titlebarFont: NSFont? { + didSet { + let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) + + titlebarTextField?.font = font + tab.attributedTitle = attributedTitle + } + } + + // Find the NSTextField responsible for displaying the titlebar's title. + private var titlebarTextField: NSTextField? { + titlebarContainer? + .firstDescendant(withClassName: "NSTitlebarView")? + .firstDescendant(withClassName: "NSTextField") as? NSTextField + } + + // Return a styled representation of our title property. + var attributedTitle: NSAttributedString? { + guard let titlebarFont = titlebarFont else { return nil } + + let attributes: [NSAttributedString.Key: Any] = [ + .font: titlebarFont, + .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, + ] + return NSAttributedString(string: title, attributes: attributes) + } + + var titlebarContainer: NSView? { + // If we aren't fullscreen then the titlebar container is part of our window. + if !styleMask.contains(.fullScreen) { + return contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") + } + + // If we are fullscreen, the titlebar container view is part of a separate + // "fullscreen window", we need to find the window and then get the view. + for window in NSApplication.shared.windows { + // This is the private window class that contains the toolbar + guard window.className == "NSToolbarFullScreenWindow" else { continue } + + // The parent will match our window. This is used to filter the correct + // fullscreen window if we have multiple. + guard window.parent == self else { continue } + + return window.contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") + } + + return nil + } + + // MARK: Positioning And Styling + + /// 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 { return } + + // Basic properties + appearance = surfaceConfig.windowAppearance + hasShadow = surfaceConfig.macosWindowShadow + + // Window transparency only takes effect if our window is not native fullscreen. + // In native fullscreen we disable transparency/opacity because the background + // becomes gray and widgets show through. + if !styleMask.contains(.fullScreen) && + surfaceConfig.backgroundOpacity < 1 + { + isOpaque = false + + // This is weird, but we don't use ".clear" because this creates a look that + // matches Terminal.app much more closer. This lets users transition from + // Terminal.app more easily. + backgroundColor = .white.withAlphaComponent(0.001) + + if let appDelegate = NSApp.delegate as? AppDelegate { + ghostty_set_window_background_blur( + appDelegate.ghostty.app, + Unmanaged.passUnretained(self).toOpaque()) + } + } else { + isOpaque = true + + let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor) + self.backgroundColor = backgroundColor.withAlphaComponent(1) + } + } + + /// The preferred window background color. The current window background color may not be set + /// to this, since this is dynamic based on the state of the surface tree. + /// + /// This background color will include alpha transparency if set. If the caller doesn't want that, + /// change the alpha channel again manually. + var preferredBackgroundColor: NSColor? { + if let terminalController, !terminalController.surfaceTree.isEmpty { + let surface: Ghostty.SurfaceView? + + // If our focused surface borders the top then we prefer its background color + if let focusedSurface = terminalController.focusedSurface, + let treeRoot = terminalController.surfaceTree.root, + let focusedNode = treeRoot.node(view: focusedSurface), + treeRoot.spatial().doesBorder(side: .up, from: focusedNode) { + surface = focusedSurface + } else { + // If it doesn't border the top, we use the top-left leaf + surface = terminalController.surfaceTree.root?.leftmostLeaf() + } + + if let surface { + let backgroundColor = surface.backgroundColor ?? surface.derivedConfig.backgroundColor + let alpha = surface.derivedConfig.backgroundOpacity.clamped(to: 0.001...1) + return NSColor(backgroundColor).withAlphaComponent(alpha) + } + } + + let alpha = derivedConfig.backgroundOpacity.clamped(to: 0.001...1) + return derivedConfig.backgroundColor.withAlphaComponent(alpha) + } + + private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { + // If we don't have an X/Y then we try to use the previously saved window pos. + guard let x, let y else { + if (!LastWindowPosition.shared.restore(self)) { + center() + } + + return + } + + // Prefer the screen our window is being placed on otherwise our primary screen. + guard let screen = screen ?? NSScreen.screens.first else { + center() + return + } + + // Orient based on the top left of the primary monitor + let frame = screen.visibleFrame + setFrameOrigin(.init( + x: frame.minX + CGFloat(x), + y: frame.maxY - (CGFloat(y) + frame.height))) + } + + private func hideWindowButtons() { + standardWindowButton(.closeButton)?.isHidden = true + standardWindowButton(.miniaturizeButton)?.isHidden = true + standardWindowButton(.zoomButton)?.isHidden = true + } + + // MARK: Config + + struct DerivedConfig { + let backgroundColor: NSColor + let backgroundOpacity: Double + let macosWindowButtons: Ghostty.MacOSWindowButtons + + init() { + self.backgroundColor = NSColor.windowBackgroundColor + self.backgroundOpacity = 1 + self.macosWindowButtons = .visible + } + + init(_ config: Ghostty.Config) { + self.backgroundColor = NSColor(config.backgroundColor) + self.backgroundOpacity = config.backgroundOpacity + self.macosWindowButtons = config.macosWindowButtons + } + } +} + +// MARK: SwiftUI View + +extension TerminalWindow { + class ViewModel: ObservableObject { + @Published var isSurfaceZoomed: Bool = false + @Published var hasToolbar: Bool = false + } + + struct ResetZoomAccessoryView: View { + @ObservedObject var viewModel: ViewModel + let action: () -> Void + + // The padding from the top that the view appears. This was all just manually + // measured based on the OS. + var topPadding: CGFloat { + if #available(macOS 26.0, *) { + return viewModel.hasToolbar ? 10 : 5 + } else { + return viewModel.hasToolbar ? 9 : 4 + } + } + + var body: some View { + if viewModel.isSurfaceZoomed { + VStack { + Button(action: action) { + Image("ResetZoom") + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .help("Reset Split Zoom") + .frame(width: 20, height: 20) + Spacer() + } + // With a toolbar, the window title is taller, so we need more padding + // to properly align. + .padding(.top, topPadding) + // We always need space at the end of the titlebar + .padding(.trailing, 10) + } + } + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift new file mode 100644 index 000000000..9381f7329 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -0,0 +1,262 @@ +import AppKit +import SwiftUI + +/// `macos-titlebar-style = tabs` for macOS 26 (Tahoe) and later. +/// +/// This inherits from transparent styling so that the titlebar matches the background color +/// of the window. +class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { + /// The view model for SwiftUI views + private var viewModel = ViewModel() + + deinit { + tabBarObserver = nil + } + + // MARK: NSWindow + + override var title: String { + didSet { + viewModel.title = title + } + } + + override func awakeFromNib() { + super.awakeFromNib() + + // We must hide the title since we're going to be moving tabs into + // the titlebar which have their own title. + titleVisibility = .hidden + + // Create a toolbar + let toolbar = NSToolbar(identifier: "TerminalToolbar") + toolbar.delegate = self + toolbar.centeredItemIdentifiers.insert(.title) + self.toolbar = toolbar + toolbarStyle = .unifiedCompact + } + + override func becomeMain() { + super.becomeMain() + + // Check if we have a tab bar and set it up if we have to. See the comment + // on this function to learn why we need to check this here. + setupTabBar() + } + + // 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) { + // If this is the tab bar then we need to set it up for the titlebar + guard isTabBar(childViewController) else { + super.addTitlebarAccessoryViewController(childViewController) + return + } + + // Some setup needs to happen BEFORE it is added, such as layout. If + // we don't do this before the call below, we'll trigger an AppKit + // assertion. + childViewController.layoutAttribute = .right + + super.addTitlebarAccessoryViewController(childViewController) + + // Setup the tab bar to go into the titlebar. + DispatchQueue.main.async { + // HACK: wait a tick before doing anything, to avoid edge cases during startup... :/ + // If we don't do this then on launch windows with restored state with tabs will end + // up with messed up tab bars that don't show all tabs. + self.setupTabBar() + } + } + + override func removeTitlebarAccessoryViewController(at index: Int) { + guard let childViewController = titlebarAccessoryViewControllers[safe: index], + isTabBar(childViewController) else { + super.removeTitlebarAccessoryViewController(at: index) + return + } + + super.removeTitlebarAccessoryViewController(at: index) + + removeTabBar() + } + + // MARK: Tab Bar Setup + + private var tabBarObserver: NSObjectProtocol? { + didSet { + // When we change this we want to clear our old observer + guard let oldValue else { return } + NotificationCenter.default.removeObserver(oldValue) + } + } + + /// Take the NSTabBar that is on the window and convert it into titlebar tabs. + /// + /// Let me explain more background on what is happening here. When a tab bar is created, only the + /// main window actually has an NSTabBar. When an NSWindow in the tab group gains main, AppKit + /// creates/moves (unsure which) the NSTabBar for it and shows it. When it loses main, the tab bar + /// is removed from the view hierarchy. + /// + /// We can't reliably detect this via `addTitlebarAccessoryViewController` because AppKit + /// creates an accessory view controller for every window in the tab group, but only attaches + /// the actual NSTabBar to the main window's accessory view. + /// + /// The best way I've found to detect this is to search for and setup the tab bar anytime the + /// window gains focus. There are probably edge cases to check but to resolve all this I made + /// this function which is idempotent to call. + /// + /// There are more scenarios to look out for and they're documented within the method. + func setupTabBar() { + // We only want to setup the observer once + guard tabBarObserver == nil else { return } + + // Find our tab bar. If it doesn't exist we don't do anything. + guard let tabBar = contentView?.rootView.firstDescendant(withClassName: "NSTabBar") else { return } + + // View model updates must happen on their own ticks. + DispatchQueue.main.async { + self.viewModel.hasTabBar = true + } + + // Find our clip view + guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } + guard let accessoryView = clipView.subviews[safe: 0] else { return } + guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return } + guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } + + // The container is the view that we'll constrain our tab bar within. + let container = toolbarView + + // The padding for the tab bar. If we're showing window buttons then + // we need to offset the window buttons. + let leftPadding: CGFloat = switch(self.derivedConfig.macosWindowButtons) { + case .hidden: 0 + case .visible: 70 + } + + // Constrain the accessory clip view (the parent of the accessory view + // usually that clips the children) to the container view. + clipView.translatesAutoresizingMaskIntoConstraints = false + accessoryView.translatesAutoresizingMaskIntoConstraints = false + + // Setup all our constraints + 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), + accessoryView.leftAnchor.constraint(equalTo: clipView.leftAnchor), + accessoryView.rightAnchor.constraint(equalTo: clipView.rightAnchor), + accessoryView.topAnchor.constraint(equalTo: clipView.topAnchor), + accessoryView.heightAnchor.constraint(equalTo: clipView.heightAnchor), + ]) + + clipView.needsLayout = true + accessoryView.needsLayout = true + + // Setup an observer for the NSTabBar frame. When system appearance changes or + // other events occur, the tab bar can temporarily become zero-sized. When this + // happens, we need to remove our custom constraints and re-apply them once the + // tab bar has proper dimensions again to avoid constraint conflicts. + tabBar.postsFrameChangedNotifications = true + tabBarObserver = NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: tabBar, + queue: .main + ) { [weak self] _ in + guard let self else { return } + + // Check if either width or height is zero + guard tabBar.frame.size.width == 0 || tabBar.frame.size.height == 0 else { return } + + // Remove the observer so we can call setup again. + self.tabBarObserver = nil + + // Wait a tick to let the new tab bars appear and then set them up. + DispatchQueue.main.async { + self.setupTabBar() + } + } + } + + func removeTabBar() { + // View model needs to be updated on another tick because it + // triggers view updates. + DispatchQueue.main.async { + self.viewModel.hasTabBar = false + } + + // Clear our observations + self.tabBarObserver = nil + } + + // MARK: NSToolbarDelegate + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.title, .flexibleSpace, .space] + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.flexibleSpace, .title, .flexibleSpace] + } + + func toolbar(_ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + switch itemIdentifier { + case .title: + let item = NSToolbarItem(itemIdentifier: .title) + item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel)) + item.visibilityPriority = .user + item.isEnabled = true + + // 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 + default: + return NSToolbarItem(itemIdentifier: itemIdentifier) + } + } + + // MARK: SwiftUI + + class ViewModel: ObservableObject { + @Published var title: String = "👻 Ghostty" + @Published var hasTabBar: Bool = false + } +} + +extension NSToolbarItem.Identifier { + /// Displays the title of the window + static let title = NSToolbarItem.Identifier("Title") +} + +extension TitlebarTabsTahoeTerminalWindow { + /// Displays the window title + struct TitleItem: View { + @ObservedObject var viewModel: ViewModel + + var title: String { + // An empty title makes this view zero-sized and NSToolbar on macOS + // tahoe just deletes the item when that happens. So we use a space + // instead to ensure there's always some size. + return viewModel.title.isEmpty ? " " : viewModel.title + } + + var body: some View { + if !viewModel.hasTabBar { + Text(title) + .lineLimit(1) + .truncationMode(.tail) + } else { + // 1x1.gif strikes again! For real: if we render a zero-sized + // view here then the toolbar just disappears our view. I don't + // know. + Color.clear.frame(width: 1, height: 1) + } + } + } +} diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift similarity index 69% rename from macos/Sources/Features/Terminal/TerminalWindow.swift rename to macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 62b8dc5bf..99111b55b 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -1,14 +1,10 @@ import Cocoa -class TerminalWindow: NSWindow { - /// This is the key in UserDefaults to use for the default `level` value. - static let defaultLevelKey: String = "TerminalDefaultLevel" - - @objc dynamic var keyEquivalent: String = "" - +/// Titlebar tabs for macOS 13 to 15. +class TitlebarTabsVenturaTerminalWindow: TerminalWindow { /// This is used to determine if certain elements should be drawn light or dark and should /// be updated whenever the window background color or surrounding elements changes. - var isLightTheme: Bool = false + fileprivate var isLightTheme: Bool = false lazy var titlebarColor: NSColor = backgroundColor { didSet { @@ -18,131 +14,39 @@ class TerminalWindow: NSWindow { } } - private lazy var keyEquivalentLabel: NSTextField = { - let label = NSTextField(labelWithAttributedString: NSAttributedString()) - label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal) - label.postsFrameChangedNotifications = true + // false if all three traffic lights are missing/hidden, otherwise true + private var hasWindowButtons: Bool { + get { + // if standardWindowButton(.theButton) == nil, the button isn't there, so coalesce to true + let closeIsHidden = standardWindowButton(.closeButton)?.isHiddenOrHasHiddenAncestor ?? true + let miniaturizeIsHidden = standardWindowButton(.miniaturizeButton)?.isHiddenOrHasHiddenAncestor ?? true + let zoomIsHidden = standardWindowButton(.zoomButton)?.isHiddenOrHasHiddenAncestor ?? true + return !(closeIsHidden && miniaturizeIsHidden && zoomIsHidden) + } + } - return label - }() - - private lazy var bindings = [ - observe(\.surfaceIsZoomed, options: [.initial, .new]) { [weak self] window, _ in - guard let tabGroup = self?.tabGroup else { return } - - self?.resetZoomTabButton.isHidden = !window.surfaceIsZoomed - self?.updateResetZoomTitlebarButtonVisibility() - }, - - observe(\.keyEquivalent, options: [.initial, .new]) { [weak self] window, _ in - let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), - .foregroundColor: window.isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, - ] - let attributedString = NSAttributedString(string: " \(window.keyEquivalent) ", attributes: attributes) - - self?.keyEquivalentLabel.attributedStringValue = attributedString - }, - ] - - // Both of these must be true for windows without decorations to be able to - // still become key/main and receive events. - override var canBecomeKey: Bool { return true } - override var canBecomeMain: Bool { return true } - - // MARK: - Lifecycle + // MARK: NSWindow override func awakeFromNib() { super.awakeFromNib() - _ = bindings - - // Create the tab accessory view that houses the key-equivalent label and optional un-zoom button - let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton]) - stackView.setHuggingPriority(.defaultHigh, for: .horizontal) - stackView.spacing = 3 - tab.accessoryView = stackView - - if titlebarTabs { - generateToolbar() - } - - level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal - } - - deinit { - bindings.forEach() { $0.invalidate() } - } - - // MARK: Titlebar Helpers - // These helpers are generic to what we're trying to achieve (i.e. titlebar - // style tabs, titlebar styling, etc.). They're just here to make it easier. - - private var titlebarContainer: NSView? { - // If we aren't fullscreen then the titlebar container is part of our window. - if !styleMask.contains(.fullScreen) { - guard let view = contentView?.superview ?? contentView else { return nil } - return titlebarContainerView(in: view) + // Handle titlebar tabs config option. Something about what we do while setting up the + // titlebar tabs interferes with the window restore process unless window.tabbingMode + // is set to .preferred, so we set it, and switch back to automatic as soon as we can. + tabbingMode = .preferred + DispatchQueue.main.async { + self.tabbingMode = .automatic } - // If we are fullscreen, the titlebar container view is part of a separate - // "fullscreen window", we need to find the window and then get the view. - for window in NSApplication.shared.windows { - // This is the private window class that contains the toolbar - guard window.className == "NSToolbarFullScreenWindow" else { continue } + titlebarTabs = true - // The parent will match our window. This is used to filter the correct - // fullscreen window if we have multiple. - guard window.parent == self else { continue } + // Set the background color of the window + backgroundColor = derivedConfig.backgroundColor - guard let view = window.contentView else { continue } - return titlebarContainerView(in: view) - } - - return nil + // This makes sure our titlebar renders correctly when there is a transparent background + titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) } - private func titlebarContainerView(in view: NSView) -> NSView? { - if view.className == "NSTitlebarContainerView" { - return view - } - - for subview in view.subviews { - if let found = titlebarContainerView(in: subview) { - return found - } - } - - return nil - } - - // MARK: - NSWindow - - override var title: String { - didSet { - tab.attributedTitle = attributedTitle - } - } - - // We override this so that with the hidden titlebar style the titlebar - // area is not draggable. - override var contentLayoutRect: CGRect { - var rect = super.contentLayoutRect - - // If we are using a hidden titlebar style, the content layout is the - // full frame making it so that it is not draggable. - if let controller = windowController as? TerminalController, - controller.derivedConfig.macosTitlebarStyle == "hidden" { - rect.origin.y = 0 - rect.size.height = self.frame.height - } - return rect - } - - // The window theme configuration from Ghostty. This is used to control some - // behaviors that don't look quite right in certain situations. - var windowTheme: TerminalWindowTheme? - // We only need to set this once, but need to do it after the window has been created in order // to determine if the theme is using a very dark background, in which case we don't want to // remove the effect view if the default tab bar is being used since the effect created in @@ -153,13 +57,12 @@ class TerminalWindow: NSWindow { // This is required because the removeTitlebarAccessoryViewController hook does not // catch the creation of a new window by "tearing off" a tab from a tabbed window. if let tabGroup = self.tabGroup, tabGroup.windows.count < 2 { - hideCustomTabBarViews() + resetCustomTabBarViews() } super.becomeKey() updateNewTabButtonOpacity() - resetZoomTabButton.contentTintColor = .controlAccentColor resetZoomToolbarButton.contentTintColor = .controlAccentColor tab.attributedTitle = attributedTitle } @@ -168,7 +71,6 @@ class TerminalWindow: NSWindow { super.resignKey() updateNewTabButtonOpacity() - resetZoomTabButton.contentTintColor = .secondaryLabelColor resetZoomToolbarButton.contentTintColor = .tertiaryLabelColor tab.attributedTitle = attributedTitle } @@ -197,11 +99,6 @@ class TerminalWindow: NSWindow { } } - updateResetZoomTitlebarButtonVisibility() - - // The remainder of this function only applies to styled tabs. - guard hasStyledTabs else { return } - titlebarSeparatorStyle = tabbedWindows != nil && !titlebarTabs ? .line : .none if titlebarTabs { hideToolbarOverflowButton() @@ -246,20 +143,29 @@ class TerminalWindow: NSWindow { } } - // MARK: - Tab Bar Styling + // MARK: Appearance - // This is true if we should apply styles to the titlebar or tab bar. - var hasStyledTabs: Bool { - // If we have titlebar tabs then we always style. - guard !titlebarTabs else { return true } + override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + super.syncAppearance(surfaceConfig) - // We style the tabs if they're transparent - return transparentTabs + // Update our window light/darkness based on our updated background color + isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor + + // Update our titlebar color + if let preferredBackgroundColor { + titlebarColor = preferredBackgroundColor + } else { + titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) + } + + if (isOpaque) { + // If there is transparency, calling this will make the titlebar opaque + // so we only call this if we are opaque. + updateTabBar() + } } - // Set to true if the background color should bleed through the titlebar/tab bar. - // This only applies to non-titlebar tabs. - var transparentTabs: Bool = false + // MARK: Tab Bar Styling var hasVeryDarkBackground: Bool { backgroundColor.luminance < 0.05 @@ -274,8 +180,7 @@ class TerminalWindow: NSWindow { // We can only update titlebar tabs if there is a titlebar. Without the // styleMask check the app will crash (issue #1876) if titlebarTabs && styleMask.contains(.titled) { - guard let tabBarAccessoryViewController = titlebarAccessoryViewControllers.first(where: { $0.identifier == Self.TabBarController}) else { return } - + guard let tabBarAccessoryViewController = titlebarAccessoryViewControllers.first(where: { $0.identifier == Self.tabBarIdentifier}) else { return } tabBarAccessoryViewController.layoutAttribute = .right pushTabsToTitlebar(tabBarAccessoryViewController) } @@ -342,53 +247,8 @@ class TerminalWindow: NSWindow { // MARK: - Split Zoom Button - @objc dynamic var surfaceIsZoomed: Bool = false - private lazy var resetZoomToolbarButton: NSButton = generateResetZoomButton() - private lazy var resetZoomTabButton: NSButton = { - let button = generateResetZoomButton() - button.action = #selector(selectTabAndZoom(_:)) - return button - }() - - private lazy var resetZoomTitlebarAccessoryViewController: NSTitlebarAccessoryViewController? = { - guard let titlebarContainer else { return nil } - let size = NSSize(width: titlebarContainer.bounds.height, height: titlebarContainer.bounds.height) - let view = NSView(frame: NSRect(origin: .zero, size: size)) - - let button = generateResetZoomButton() - button.frame.origin.x = size.width/2 - button.bounds.width/2 - button.frame.origin.y = size.height/2 - button.bounds.height/2 - view.addSubview(button) - - let titlebarAccessoryViewController = NSTitlebarAccessoryViewController() - titlebarAccessoryViewController.view = view - titlebarAccessoryViewController.layoutAttribute = .right - - return titlebarAccessoryViewController - }() - - private func updateResetZoomTitlebarButtonVisibility() { - guard let tabGroup, let resetZoomTitlebarAccessoryViewController else { return } - - let isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed - - if titlebarTabs { - resetZoomToolbarButton.isHidden = isHidden - - for (index, vc) in titlebarAccessoryViewControllers.enumerated() { - guard vc == resetZoomTitlebarAccessoryViewController else { return } - removeTitlebarAccessoryViewController(at: index) - } - } else { - if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) { - addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController) - } - resetZoomTitlebarAccessoryViewController.view.isHidden = isHidden - } - } - private func generateResetZoomButton() -> NSButton { let button = NSButton() button.target = nil @@ -424,46 +284,19 @@ class TerminalWindow: NSWindow { // MARK: - Titlebar Font // Used to set the titlebar font. - var titlebarFont: NSFont? { + override var titlebarFont: NSFont? { didSet { - let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) - - titlebarTextField?.font = font - tab.attributedTitle = attributedTitle - - if let toolbar = toolbar as? TerminalToolbar { - toolbar.titleFont = font - } + guard let toolbar = toolbar as? TerminalToolbar else { return } + toolbar.titleFont = titlebarFont ?? .titleBarFont(ofSize: NSFont.systemFontSize) } } - // Find the NSTextField responsible for displaying the titlebar's title. - private var titlebarTextField: NSTextField? { - guard let titlebarView = titlebarContainer?.subviews - .first(where: { $0.className == "NSTitlebarView" }) else { return nil } - return titlebarView.subviews.first(where: { $0 is NSTextField }) as? NSTextField - } - - // Return a styled representation of our title property. - private var attributedTitle: NSAttributedString? { - guard let titlebarFont else { return nil } - - let attributes: [NSAttributedString.Key: Any] = [ - .font: titlebarFont, - .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, - ] - return NSAttributedString(string: title, attributes: attributes) - } - // MARK: - Titlebar Tabs private var windowButtonsBackdrop: WindowButtonsBackdropView? = nil private var windowDragHandle: WindowDragView? = nil - // The tab bar controller ID from macOS - static private let TabBarController = NSUserInterfaceItemIdentifier("_tabBarController") - // Used by the window controller to enable/disable titlebar tabs. var titlebarTabs = false { didSet { @@ -476,6 +309,18 @@ class TerminalWindow: NSWindow { } } + override var title: String { + didSet { + // Updating the title text as above automatically reveals the + // native title view in macOS 15.0 and above. Since we're using + // a custom view instead, we need to re-hide it. + titleVisibility = .hidden + if let toolbar = toolbar as? TerminalToolbar { + toolbar.titleText = title + } + } + } + // We have to regenerate a toolbar when the titlebar tabs setting changes since our // custom toolbar conditionally generates the items based on this setting. I tried to // invalidate the toolbar items and force a refresh, but as far as I can tell that @@ -491,7 +336,6 @@ class TerminalWindow: NSWindow { resetZoomItem.view!.widthAnchor.constraint(equalToConstant: 22).isActive = true resetZoomItem.view!.heightAnchor.constraint(equalToConstant: 20).isActive = true } - updateResetZoomTitlebarButtonVisibility() } // For titlebar tabs, we want to hide the separator view so that we get rid @@ -520,10 +364,7 @@ class TerminalWindow: NSWindow { // 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) { - let isTabBar = self.titlebarTabs && ( - childViewController.layoutAttribute == .bottom || - childViewController.identifier == Self.TabBarController - ) + let isTabBar = self.titlebarTabs && isTabBar(childViewController) if (isTabBar) { // Ensure it has the right layoutAttribute to force it next to our titlebar @@ -535,7 +376,7 @@ class TerminalWindow: NSWindow { // Mark the controller for future reference so we can easily find it. Otherwise // the tab bar has no ID by default. - childViewController.identifier = Self.TabBarController + childViewController.identifier = Self.tabBarIdentifier } super.addTitlebarAccessoryViewController(childViewController) @@ -546,20 +387,25 @@ class TerminalWindow: NSWindow { } override func removeTitlebarAccessoryViewController(at index: Int) { - let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.TabBarController + let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.tabBarIdentifier super.removeTitlebarAccessoryViewController(at: index) if (isTabBar) { - hideCustomTabBarViews() + resetCustomTabBarViews() } } // To be called immediately after the tab bar is disabled. - private func hideCustomTabBarViews() { + private func resetCustomTabBarViews() { // Hide the window buttons backdrop. windowButtonsBackdrop?.isHidden = true // Hide the window drag handle. windowDragHandle?.isHidden = true + + // Reenable the main toolbar title + if let toolbar = toolbar as? TerminalToolbar { + toolbar.titleIsHidden = false + } } private func pushTabsToTitlebar(_ tabBarController: NSTitlebarAccessoryViewController) { @@ -568,6 +414,11 @@ class TerminalWindow: NSWindow { generateToolbar() } + // The main title conflicts with titlebar tabs, so hide it + if let toolbar = toolbar as? TerminalToolbar { + toolbar.titleIsHidden = true + } + // HACK: wait a tick before doing anything, to avoid edge cases during startup... :/ // If we don't do this then on launch windows with restored state with tabs will end // up with messed up tab bars that don't show all tabs. @@ -614,7 +465,7 @@ class TerminalWindow: NSWindow { view.translatesAutoresizingMaskIntoConstraints = false view.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true - view.rightAnchor.constraint(equalTo: toolbarView.leftAnchor, constant: 78).isActive = true + view.rightAnchor.constraint(equalTo: toolbarView.leftAnchor, constant: hasWindowButtons ? 78 : 0).isActive = true view.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true view.heightAnchor.constraint(equalTo: toolbarView.heightAnchor).isActive = true @@ -692,7 +543,7 @@ fileprivate class WindowDragView: NSView { fileprivate class WindowButtonsBackdropView: NSView { // This must be weak because the window has this view. Otherwise // a retain cycle occurs. - private weak var terminalWindow: TerminalWindow? + private weak var terminalWindow: TitlebarTabsVenturaTerminalWindow? private let isLightTheme: Bool private let overlayLayer = VibrantLayer() @@ -720,7 +571,7 @@ fileprivate class WindowButtonsBackdropView: NSView { fatalError("init(coder:) has not been implemented") } - init(window: TerminalWindow) { + init(window: TitlebarTabsVenturaTerminalWindow) { self.terminalWindow = window self.isLightTheme = window.isLightTheme @@ -736,9 +587,133 @@ fileprivate class WindowButtonsBackdropView: NSView { } } -enum TerminalWindowTheme: String { - case auto - case system - case light - case dark +// MARK: Toolbar + +// Custom NSToolbar subclass that displays a centered window title, +// in order to accommodate the titlebar tabs feature. +fileprivate class TerminalToolbar: NSToolbar, NSToolbarDelegate { + private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty") + + var titleText: String { + get { + titleTextField.stringValue + } + + set { + titleTextField.stringValue = newValue + } + } + + var titleFont: NSFont? { + get { + titleTextField.font + } + + set { + titleTextField.font = newValue + } + } + + var titleIsHidden: Bool { + get { + titleTextField.isHidden + } + + set { + titleTextField.isHidden = newValue + } + } + + override init(identifier: NSToolbar.Identifier) { + super.init(identifier: identifier) + + delegate = self + centeredItemIdentifiers.insert(.titleText) + } + + func toolbar(_ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + var item: NSToolbarItem + + switch itemIdentifier { + case .titleText: + item = NSToolbarItem(itemIdentifier: .titleText) + item.view = self.titleTextField + item.visibilityPriority = .user + + // This ensures the title text field doesn't disappear when shrinking the view + self.titleTextField.translatesAutoresizingMaskIntoConstraints = false + self.titleTextField.setContentHuggingPriority(.defaultLow, for: .horizontal) + self.titleTextField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + // Add constraints to the toolbar item's view + NSLayoutConstraint.activate([ + // Set the height constraint to match the toolbar's height + self.titleTextField.heightAnchor.constraint(equalToConstant: 22), // Adjust as needed + ]) + + item.isEnabled = true + case .resetZoom: + item = NSToolbarItem(itemIdentifier: .resetZoom) + default: + item = NSToolbarItem(itemIdentifier: itemIdentifier) + } + + return item + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.titleText, .flexibleSpace, .space, .resetZoom] + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + // These space items are here to ensure that the title remains centered when it starts + // getting smaller than the max size so starts clipping. Lucky for us, two of the + // built-in spacers plus the un-zoom button item seems to exactly match the space + // on the left that's reserved for the window buttons. + return [.flexibleSpace, .titleText, .flexibleSpace] + } +} + +/// A label that expands to fit whatever text you put in it and horizontally centers itself in the current window. +fileprivate class CenteredDynamicLabel: NSTextField { + override func viewDidMoveToSuperview() { + // Configure the text field + isEditable = false + isBordered = false + drawsBackground = false + alignment = .center + lineBreakMode = .byTruncatingTail + cell?.truncatesLastVisibleLine = true + + // Use Auto Layout + translatesAutoresizingMaskIntoConstraints = false + + // Set content hugging and compression resistance priorities + setContentHuggingPriority(.defaultLow, for: .horizontal) + setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + } + + // Vertically center the text + override func draw(_ dirtyRect: NSRect) { + guard let attributedString = self.attributedStringValue.mutableCopy() as? NSMutableAttributedString else { + super.draw(dirtyRect) + return + } + + let textSize = attributedString.size() + + let yOffset = (self.bounds.height - textSize.height) / 2 - 1 // -1 to center it better + + let centeredRect = NSRect(x: self.bounds.origin.x, y: self.bounds.origin.y + yOffset, + width: self.bounds.width, height: textSize.height) + + attributedString.draw(in: centeredRect) + } +} + +extension NSToolbarItem.Identifier { + static let resetZoom = NSToolbarItem.Identifier("ResetZoom") + static let titleText = NSToolbarItem.Identifier("TitleText") } diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift new file mode 100644 index 000000000..f6ad6e56c --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -0,0 +1,198 @@ +import AppKit + +/// A terminal window style that provides a transparent titlebar effect. With this effect, the titlebar +/// matches the background color of the window. +class TransparentTitlebarTerminalWindow: TerminalWindow { + /// Stores the last surface configuration to reapply appearance when needed. + /// This is necessary because various macOS operations (tab switching, tab bar + /// visibility changes) can reset the titlebar appearance. + private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig? + + /// KVO observation for tab group window changes. + private var tabGroupWindowsObservation: NSKeyValueObservation? + private var tabBarVisibleObservation: NSKeyValueObservation? + + deinit { + tabGroupWindowsObservation?.invalidate() + tabBarVisibleObservation?.invalidate() + } + + // MARK: NSWindow + + override func awakeFromNib() { + super.awakeFromNib() + + // Setup all the KVO we will use, see the docs for the respective functions + // to learn why we need KVO. + setupKVO() + } + + override func becomeMain() { + super.becomeMain() + + guard let lastSurfaceConfig else { return } + syncAppearance(lastSurfaceConfig) + + // This is a nasty edge case. If we're going from 2 to 1 tab and the tab bar + // automatically disappears, then we need to resync our appearance because + // at some point macOS replaces the tab views. + if tabGroup?.windows.count ?? 0 == 2 { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in + self?.syncAppearance(self?.lastSurfaceConfig ?? lastSurfaceConfig) + } + } + } + + override func update() { + super.update() + + // On macOS 13 to 15, we need to hide the NSVisualEffectView in order to allow our + // titlebar to be truly transparent. + if #unavailable(macOS 26) { + if !effectViewIsHidden { + hideEffectView() + } + } + } + + // MARK: Appearance + + override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + super.syncAppearance(surfaceConfig) + + // Save our config in case we need to reapply + lastSurfaceConfig = surfaceConfig + + // Everytime we change appearance, set KVO up again in case any of our + // references changed (e.g. tabGroup is new). + setupKVO() + + if #available(macOS 26.0, *) { + syncAppearanceTahoe(surfaceConfig) + } else { + syncAppearanceVentura(surfaceConfig) + } + } + + @available(macOS 26.0, *) + private func syncAppearanceTahoe(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + // When we have transparency, we need to set the titlebar background to match the + // window background but with opacity. The window background is set using the + // "preferred background color" property. + // + // As an inverse, if we don't have transparency, we don't bother with this because + // the window background will be set to the correct color so we can just hide the + // titlebar completely and we're good to go. + if !isOpaque { + if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") { + titlebarView.wantsLayer = true + titlebarView.layer?.backgroundColor = preferredBackgroundColor?.cgColor + } + } + + // In all cases, we have to hide the background view since this has multiple subviews + // that force a background color. + titlebarBackgroundView?.isHidden = true + } + + @available(macOS 13.0, *) + private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + guard let titlebarContainer else { return } + + // Setup the titlebar background color to match ours + titlebarContainer.wantsLayer = true + titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor + + // See the docs for the function that sets this to true on why + effectViewIsHidden = false + + // Necessary to not draw the border around the title + titlebarAppearsTransparent = true + } + + // MARK: View Finders + + private var titlebarBackgroundView: NSView? { + titlebarContainer?.firstDescendant(withClassName: "NSTitlebarBackgroundView") + } + + // MARK: Tab Group Observation + + private func setupKVO() { + // See the docs for the respective setup functions for why. + setupTabGroupObservation() + setupTabBarVisibleObservation() + } + + /// Monitors the tabGroup windows value for any changes and resyncs the appearance on change. + /// This is necessary because when the windows change, the tab bar and titlebar are recreated + /// which breaks our changes. + private func setupTabGroupObservation() { + // Remove existing observation if any + tabGroupWindowsObservation?.invalidate() + tabGroupWindowsObservation = nil + + // Check if tabGroup is available + guard let tabGroup else { return } + + // Set up KVO observation for the windows array. Whenever it changes + // we resync the appearance because it can cause macOS to redraw the + // tab bar. + tabGroupWindowsObservation = tabGroup.observe( + \.windows, + options: [.new] + ) { [weak self] _, change in + // NOTE: At one point, I guarded this on only if we went from 0 to N + // or N to 0 under the assumption that the tab bar would only get + // replaced on those cases. This turned out to be false (Tahoe). + // It's cheap enough to always redraw this so we should just do it + // unconditionally. + + guard let self else { return } + guard let lastSurfaceConfig else { return } + self.syncAppearance(lastSurfaceConfig) + } + } + + /// Monitors the tab bar for visibility. This lets the "Show/Hide Tab Bar" manual menu item + /// to not break our appearance. + private func setupTabBarVisibleObservation() { + // Remove existing observation if any + tabBarVisibleObservation?.invalidate() + tabBarVisibleObservation = nil + + // Set up KVO observation for isTabBarVisible + tabBarVisibleObservation = tabGroup?.observe( + \.isTabBarVisible, + options: [.new] + ) { [weak self] _, change in + guard let self else { return } + guard let lastSurfaceConfig else { return } + self.syncAppearance(lastSurfaceConfig) + } + } + + // MARK: macOS 13 to 15 + + // We only need to set this once, but need to do it after the window has been created in order + // to determine if the theme is using a very dark background, in which case we don't want to + // remove the effect view if the default tab bar is being used since the effect created in + // `updateTabsForVeryDarkBackgrounds` creates a confusing visual design. + private var effectViewIsHidden = false + + private func hideEffectView() { + guard !effectViewIsHidden else { return } + + // By hiding the visual effect view, we allow the window's (or titlebar's in this case) + // background color to show through. If we were to set `titlebarAppearsTransparent` to true + // the selected tab would look fine, but the unselected ones and new tab button backgrounds + // would be an opaque color. When the titlebar isn't transparent, however, the system applies + // a compositing effect to the unselected tab backgrounds, which makes them blend with the + // titlebar's/window's background. + if let effectView = titlebarContainer?.descendants(withClassName: "NSVisualEffectView").first { + effectView.isHidden = true + } + + effectViewIsHidden = true + } +} diff --git a/macos/Sources/Ghostty/AppError.swift b/macos/Sources/Ghostty/AppError.swift deleted file mode 100644 index 55f191d3d..000000000 --- a/macos/Sources/Ghostty/AppError.swift +++ /dev/null @@ -1,3 +0,0 @@ -enum AppError: Error { - case surfaceCreateError -} diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 6736449a4..ba0b95212 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -553,6 +553,12 @@ extension Ghostty { case GHOSTTY_ACTION_CHECK_FOR_UPDATES: checkForUpdates(app) + case GHOSTTY_ACTION_UNDO: + return undo(app, target: target) + + case GHOSTTY_ACTION_REDO: + return redo(app, target: target) + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: @@ -599,6 +605,48 @@ extension Ghostty { } } + private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool { + let undoManager: UndoManager? + switch (target.tag) { + case GHOSTTY_TARGET_APP: + undoManager = (NSApp.delegate as? AppDelegate)?.undoManager + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + undoManager = surfaceView.undoManager + + default: + assertionFailure() + return false + } + + guard let undoManager, undoManager.canUndo else { return false } + undoManager.undo() + return true + } + + private static func redo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool { + let undoManager: UndoManager? + switch (target.tag) { + case GHOSTTY_TARGET_APP: + undoManager = (NSApp.delegate as? AppDelegate)?.undoManager + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + undoManager = surfaceView.undoManager + + default: + assertionFailure() + return false + } + + guard let undoManager, undoManager.canRedo else { return false } + undoManager.redo() + return true + } + private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) { switch (target.tag) { case GHOSTTY_TARGET_APP: @@ -745,7 +793,7 @@ extension Ghostty { guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } guard let mode = FullscreenMode.from(ghostty: raw) else { - Ghostty.logger.warning("unknow fullscreen mode raw=\(raw.rawValue)") + Ghostty.logger.warning("unknown fullscreen mode raw=\(raw.rawValue)") return } NotificationCenter.default.post( @@ -921,7 +969,7 @@ extension Ghostty { // we should only be returning true if we actually performed the action, // but this handles the most common case of caring about goto_split performability // which is the no-split case. - guard controller.surfaceTree?.isSplit ?? false else { return false } + guard controller.surfaceTree.isSplit else { return false } NotificationCenter.default.post( name: Notification.ghosttyFocusSplit, @@ -1082,7 +1130,7 @@ extension Ghostty { guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } guard let window = surfaceView.window as? TerminalWindow else { return } - + switch (mode) { case .on: window.level = .floating diff --git a/macos/Sources/Ghostty/Ghostty.Command.swift b/macos/Sources/Ghostty/Ghostty.Command.swift new file mode 100644 index 000000000..1479ae92d --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Command.swift @@ -0,0 +1,46 @@ +import GhosttyKit + +extension Ghostty { + /// `ghostty_command_s` + struct Command: Sendable { + private let cValue: ghostty_command_s + + /// The title of the command. + var title: String { + String(cString: cValue.title) + } + + /// Human-friendly description of what this command will do. + var description: String { + String(cString: cValue.description) + } + + /// The full action that must be performed to invoke this command. + var action: String { + String(cString: cValue.action) + } + + /// Only the key portion of the action so you can compare action types, e.g. `goto_split` + /// instead of `goto_split:left`. + var actionKey: String { + String(cString: cValue.action_key) + } + + /// True if this can be performed on this target. + var isSupported: Bool { + !Self.unsupportedActionKeys.contains(actionKey) + } + + /// Unsupported action keys, because they either don't make sense in the context of our + /// target platform or they just aren't implemented yet. + static let unsupportedActionKeys: [String] = [ + "toggle_tab_overview", + "toggle_window_decorations", + "show_gtk_inspector", + ] + + init(cValue: ghostty_command_s) { + self.cValue = cValue + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index d7be4eb5b..241c10632 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -250,6 +250,17 @@ extension Ghostty { return String(cString: ptr) } + var macosWindowButtons: MacOSWindowButtons { + let defaultValue = MacOSWindowButtons.visible + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = nil + let key = "macos-window-buttons" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard let ptr = v else { return defaultValue } + let str = String(cString: ptr) + return MacOSWindowButtons(rawValue: str) ?? defaultValue + } + var macosTitlebarStyle: String { let defaultValue = "transparent" guard let config = self.config else { return defaultValue } @@ -495,6 +506,14 @@ extension Ghostty { return v; } + var undoTimeout: Duration { + guard let config = self.config else { return .seconds(5) } + var v: UInt = 0 + let key = "undo-timeout" + _ = ghostty_config_get(config, &v, key, UInt(key.count)) + return .milliseconds(v) + } + var autoUpdate: AutoUpdate? { guard let config = self.config else { return nil } var v: UnsafePointer? = nil @@ -539,6 +558,17 @@ extension Ghostty { _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v } + + var macosShortcuts: MacShortcuts { + let defaultValue = MacShortcuts.ask + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = nil + let key = "macos-shortcuts" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard let ptr = v else { return defaultValue } + let str = String(cString: ptr) + return MacShortcuts(rawValue: str) ?? defaultValue + } } } @@ -555,6 +585,9 @@ extension Ghostty.Config { let rawValue: CUnsignedInt static let system = BellFeatures(rawValue: 1 << 0) + static let audio = BellFeatures(rawValue: 1 << 1) + static let attention = BellFeatures(rawValue: 1 << 2) + static let title = BellFeatures(rawValue: 1 << 3) } enum MacHidden : String { @@ -562,6 +595,12 @@ extension Ghostty.Config { case always } + enum MacShortcuts: String { + case allow + case deny + case ask + } + enum ResizeOverlay : String { case always case never diff --git a/macos/Sources/Ghostty/Ghostty.Error.swift b/macos/Sources/Ghostty/Ghostty.Error.swift new file mode 100644 index 000000000..66f6857bf --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Error.swift @@ -0,0 +1,12 @@ +extension Ghostty { + /// Possible errors from internal Ghostty calls. + enum Error: Swift.Error, CustomLocalizedStringResourceConvertible { + case apiFailed + + var localizedStringResource: LocalizedStringResource { + switch self { + case .apiFailed: return "libghostty API call failed" + } + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 942ca5973..e05911c06 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -1,8 +1,11 @@ +import AppIntents import Cocoa import SwiftUI import GhosttyKit extension Ghostty { + struct Input {} + // MARK: Keyboard Shortcuts /// Return the key equivalent for the given trigger. @@ -91,121 +94,1156 @@ extension Ghostty { GHOSTTY_KEY_BACKSPACE: .delete, GHOSTTY_KEY_SPACE: .space, ] - - // Mapping of event keyCode to ghostty input key values. This is cribbed from - // glfw mostly since we started as a glfw-based app way back in the day! - static let keycodeToKey: [UInt16 : ghostty_input_key_e] = [ - 0x1D: GHOSTTY_KEY_DIGIT_0, - 0x12: GHOSTTY_KEY_DIGIT_1, - 0x13: GHOSTTY_KEY_DIGIT_2, - 0x14: GHOSTTY_KEY_DIGIT_3, - 0x15: GHOSTTY_KEY_DIGIT_4, - 0x17: GHOSTTY_KEY_DIGIT_5, - 0x16: GHOSTTY_KEY_DIGIT_6, - 0x1A: GHOSTTY_KEY_DIGIT_7, - 0x1C: GHOSTTY_KEY_DIGIT_8, - 0x19: GHOSTTY_KEY_DIGIT_9, - 0x00: GHOSTTY_KEY_A, - 0x0B: GHOSTTY_KEY_B, - 0x08: GHOSTTY_KEY_C, - 0x02: GHOSTTY_KEY_D, - 0x0E: GHOSTTY_KEY_E, - 0x03: GHOSTTY_KEY_F, - 0x05: GHOSTTY_KEY_G, - 0x04: GHOSTTY_KEY_H, - 0x22: GHOSTTY_KEY_I, - 0x26: GHOSTTY_KEY_J, - 0x28: GHOSTTY_KEY_K, - 0x25: GHOSTTY_KEY_L, - 0x2E: GHOSTTY_KEY_M, - 0x2D: GHOSTTY_KEY_N, - 0x1F: GHOSTTY_KEY_O, - 0x23: GHOSTTY_KEY_P, - 0x0C: GHOSTTY_KEY_Q, - 0x0F: GHOSTTY_KEY_R, - 0x01: GHOSTTY_KEY_S, - 0x11: GHOSTTY_KEY_T, - 0x20: GHOSTTY_KEY_U, - 0x09: GHOSTTY_KEY_V, - 0x0D: GHOSTTY_KEY_W, - 0x07: GHOSTTY_KEY_X, - 0x10: GHOSTTY_KEY_Y, - 0x06: GHOSTTY_KEY_Z, - - 0x27: GHOSTTY_KEY_QUOTE, - 0x2A: GHOSTTY_KEY_BACKSLASH, - 0x2B: GHOSTTY_KEY_COMMA, - 0x18: GHOSTTY_KEY_EQUAL, - 0x32: GHOSTTY_KEY_BACKQUOTE, - 0x21: GHOSTTY_KEY_BRACKET_LEFT, - 0x1B: GHOSTTY_KEY_MINUS, - 0x2F: GHOSTTY_KEY_PERIOD, - 0x1E: GHOSTTY_KEY_BRACKET_RIGHT, - 0x29: GHOSTTY_KEY_SEMICOLON, - 0x2C: GHOSTTY_KEY_SLASH, - - 0x33: GHOSTTY_KEY_BACKSPACE, - 0x39: GHOSTTY_KEY_CAPS_LOCK, - 0x75: GHOSTTY_KEY_DELETE, - 0x7D: GHOSTTY_KEY_ARROW_DOWN, - 0x77: GHOSTTY_KEY_END, - 0x24: GHOSTTY_KEY_ENTER, - 0x35: GHOSTTY_KEY_ESCAPE, - 0x7A: GHOSTTY_KEY_F1, - 0x78: GHOSTTY_KEY_F2, - 0x63: GHOSTTY_KEY_F3, - 0x76: GHOSTTY_KEY_F4, - 0x60: GHOSTTY_KEY_F5, - 0x61: GHOSTTY_KEY_F6, - 0x62: GHOSTTY_KEY_F7, - 0x64: GHOSTTY_KEY_F8, - 0x65: GHOSTTY_KEY_F9, - 0x6D: GHOSTTY_KEY_F10, - 0x67: GHOSTTY_KEY_F11, - 0x6F: GHOSTTY_KEY_F12, - 0x69: GHOSTTY_KEY_PRINT_SCREEN, - 0x6B: GHOSTTY_KEY_F14, - 0x71: GHOSTTY_KEY_F15, - 0x6A: GHOSTTY_KEY_F16, - 0x40: GHOSTTY_KEY_F17, - 0x4F: GHOSTTY_KEY_F18, - 0x50: GHOSTTY_KEY_F19, - 0x5A: GHOSTTY_KEY_F20, - 0x73: GHOSTTY_KEY_HOME, - 0x72: GHOSTTY_KEY_INSERT, - 0x7B: GHOSTTY_KEY_ARROW_LEFT, - 0x3A: GHOSTTY_KEY_ALT_LEFT, - 0x3B: GHOSTTY_KEY_CONTROL_LEFT, - 0x38: GHOSTTY_KEY_SHIFT_LEFT, - 0x37: GHOSTTY_KEY_META_LEFT, - 0x47: GHOSTTY_KEY_NUM_LOCK, - 0x79: GHOSTTY_KEY_PAGE_DOWN, - 0x74: GHOSTTY_KEY_PAGE_UP, - 0x7C: GHOSTTY_KEY_ARROW_RIGHT, - 0x3D: GHOSTTY_KEY_ALT_RIGHT, - 0x3E: GHOSTTY_KEY_CONTROL_RIGHT, - 0x3C: GHOSTTY_KEY_SHIFT_RIGHT, - 0x36: GHOSTTY_KEY_META_RIGHT, - 0x31: GHOSTTY_KEY_SPACE, - 0x30: GHOSTTY_KEY_TAB, - 0x7E: GHOSTTY_KEY_ARROW_UP, - - 0x52: GHOSTTY_KEY_NUMPAD_0, - 0x53: GHOSTTY_KEY_NUMPAD_1, - 0x54: GHOSTTY_KEY_NUMPAD_2, - 0x55: GHOSTTY_KEY_NUMPAD_3, - 0x56: GHOSTTY_KEY_NUMPAD_4, - 0x57: GHOSTTY_KEY_NUMPAD_5, - 0x58: GHOSTTY_KEY_NUMPAD_6, - 0x59: GHOSTTY_KEY_NUMPAD_7, - 0x5B: GHOSTTY_KEY_NUMPAD_8, - 0x5C: GHOSTTY_KEY_NUMPAD_9, - 0x45: GHOSTTY_KEY_NUMPAD_ADD, - 0x41: GHOSTTY_KEY_NUMPAD_DECIMAL, - 0x4B: GHOSTTY_KEY_NUMPAD_DIVIDE, - 0x4C: GHOSTTY_KEY_NUMPAD_ENTER, - 0x51: GHOSTTY_KEY_NUMPAD_EQUAL, - 0x43: GHOSTTY_KEY_NUMPAD_MULTIPLY, - 0x4E: GHOSTTY_KEY_NUMPAD_SUBTRACT, - ]; +} + +// MARK: Ghostty.Input.KeyEvent + +extension Ghostty.Input { + /// `ghostty_input_key_s` + struct KeyEvent { + let action: Action + let key: Key + let text: String? + let composing: Bool + let mods: Mods + let consumedMods: Mods + let unshiftedCodepoint: UInt32 + + init( + key: Key, + action: Action = .press, + text: String? = nil, + composing: Bool = false, + mods: Mods = [], + consumedMods: Mods = [], + unshiftedCodepoint: UInt32 = 0 + ) { + self.key = key + self.action = action + self.text = text + self.composing = composing + self.mods = mods + self.consumedMods = consumedMods + self.unshiftedCodepoint = unshiftedCodepoint + } + + init?(cValue: ghostty_input_key_s) { + // Convert action + switch cValue.action { + case GHOSTTY_ACTION_PRESS: self.action = .press + case GHOSTTY_ACTION_RELEASE: self.action = .release + case GHOSTTY_ACTION_REPEAT: self.action = .repeat + default: self.action = .press + } + + // Convert key from keycode + guard let key = Key(keyCode: UInt16(cValue.keycode)) else { return nil } + self.key = key + + // Convert text + if let textPtr = cValue.text { + self.text = String(cString: textPtr) + } else { + self.text = nil + } + + // Set composing state + self.composing = cValue.composing + + // Convert modifiers + self.mods = Mods(cMods: cValue.mods) + self.consumedMods = Mods(cMods: cValue.consumed_mods) + + // Set unshifted codepoint + self.unshiftedCodepoint = cValue.unshifted_codepoint + } + + /// Executes a closure with a temporary C representation of this KeyEvent. + /// + /// This method safely converts the Swift KeyEntity to a C `ghostty_input_key_s` struct + /// and passes it to the provided closure. The C struct is only valid within the closure's + /// execution scope. The text field's C string pointer is managed automatically and will + /// be invalid after the closure returns. + /// + /// - Parameter execute: A closure that receives the C struct and returns a value + /// - Returns: The value returned by the closure + @discardableResult + func withCValue(execute: (ghostty_input_key_s) -> T) -> T { + var keyEvent = ghostty_input_key_s() + keyEvent.action = action.cAction + keyEvent.keycode = UInt32(key.keyCode ?? 0) + keyEvent.composing = composing + keyEvent.mods = mods.cMods + keyEvent.consumed_mods = consumedMods.cMods + keyEvent.unshifted_codepoint = unshiftedCodepoint + + // Handle text with proper memory management + if let text = text { + return text.withCString { textPtr in + keyEvent.text = textPtr + return execute(keyEvent) + } + } else { + keyEvent.text = nil + return execute(keyEvent) + } + } + } +} + +// MARK: Ghostty.Input.Action + +extension Ghostty.Input { + /// `ghostty_input_action_e` + enum Action: String, CaseIterable { + case release + case press + case `repeat` + + var cAction: ghostty_input_action_e { + switch self { + case .release: GHOSTTY_ACTION_RELEASE + case .press: GHOSTTY_ACTION_PRESS + case .repeat: GHOSTTY_ACTION_REPEAT + } + } + } +} + +extension Ghostty.Input.Action: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Key Action") + + static var caseDisplayRepresentations: [Ghostty.Input.Action : DisplayRepresentation] = [ + .release: "Release", + .press: "Press", + .repeat: "Repeat" + ] +} + +// MARK: Ghostty.Input.MouseEvent + +extension Ghostty.Input { + /// Represents a mouse input event with button state, button type, and modifier keys. + struct MouseButtonEvent { + let action: MouseState + let button: MouseButton + let mods: Mods + + init( + action: MouseState, + button: MouseButton, + mods: Mods = [] + ) { + self.action = action + self.button = button + self.mods = mods + } + + /// Creates a MouseEvent from C enum values. + /// + /// This initializer converts C-style mouse input enums to Swift types. + /// Returns nil if any of the C enum values are invalid or unsupported. + /// + /// - Parameters: + /// - state: The mouse button state (press/release) + /// - button: The mouse button that was pressed/released + /// - mods: The modifier keys held during the mouse event + init?(state: ghostty_input_mouse_state_e, button: ghostty_input_mouse_button_e, mods: ghostty_input_mods_e) { + // Convert state + switch state { + case GHOSTTY_MOUSE_RELEASE: self.action = .release + case GHOSTTY_MOUSE_PRESS: self.action = .press + default: return nil + } + + // Convert button + switch button { + case GHOSTTY_MOUSE_UNKNOWN: self.button = .unknown + case GHOSTTY_MOUSE_LEFT: self.button = .left + case GHOSTTY_MOUSE_RIGHT: self.button = .right + case GHOSTTY_MOUSE_MIDDLE: self.button = .middle + default: return nil + } + + // Convert modifiers + self.mods = Mods(cMods: mods) + } + } + + /// Represents a mouse position/movement event with coordinates and modifier keys. + struct MousePosEvent { + let x: Double + let y: Double + let mods: Mods + + init( + x: Double, + y: Double, + mods: Mods = [] + ) { + self.x = x + self.y = y + self.mods = mods + } + } + + /// Represents a mouse scroll event with scroll deltas and modifier keys. + struct MouseScrollEvent { + let x: Double + let y: Double + let mods: ScrollMods + + init( + x: Double, + y: Double, + mods: ScrollMods = .init(rawValue: 0) + ) { + self.x = x + self.y = y + self.mods = mods + } + } +} + +// MARK: Ghostty.Input.MouseState + +extension Ghostty.Input { + /// `ghostty_input_mouse_state_e` + enum MouseState: String, CaseIterable { + case release + case press + + var cMouseState: ghostty_input_mouse_state_e { + switch self { + case .release: GHOSTTY_MOUSE_RELEASE + case .press: GHOSTTY_MOUSE_PRESS + } + } + } +} + +extension Ghostty.Input.MouseState: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Mouse State") + + static var caseDisplayRepresentations: [Ghostty.Input.MouseState : DisplayRepresentation] = [ + .release: "Release", + .press: "Press" + ] +} + +// MARK: Ghostty.Input.MouseButton + +extension Ghostty.Input { + /// `ghostty_input_mouse_button_e` + enum MouseButton: String, CaseIterable { + case unknown + case left + case right + case middle + + var cMouseButton: ghostty_input_mouse_button_e { + switch self { + case .unknown: GHOSTTY_MOUSE_UNKNOWN + case .left: GHOSTTY_MOUSE_LEFT + case .right: GHOSTTY_MOUSE_RIGHT + case .middle: GHOSTTY_MOUSE_MIDDLE + } + } + } +} + +extension Ghostty.Input.MouseButton: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Mouse Button") + + static var caseDisplayRepresentations: [Ghostty.Input.MouseButton : DisplayRepresentation] = [ + .unknown: "Unknown", + .left: "Left", + .right: "Right", + .middle: "Middle" + ] + + static var allCases: [Ghostty.Input.MouseButton] = [ + .left, + .right, + .middle, + ] +} + +// MARK: Ghostty.Input.ScrollMods + +extension Ghostty.Input { + /// `ghostty_input_scroll_mods_t` - Scroll event modifiers + /// + /// This is a packed bitmask that contains precision and momentum information + /// for scroll events, matching the Zig `ScrollMods` packed struct. + struct ScrollMods { + let rawValue: Int32 + + /// True if this is a high-precision scroll event (e.g., trackpad, Magic Mouse) + var precision: Bool { + rawValue & 0b0000_0001 != 0 + } + + /// The momentum phase of the scroll event for inertial scrolling + var momentum: Momentum { + let momentumBits = (rawValue >> 1) & 0b0000_0111 + return Momentum(rawValue: UInt8(momentumBits)) ?? .none + } + + init(precision: Bool = false, momentum: Momentum = .none) { + var value: Int32 = 0 + if precision { + value |= 0b0000_0001 + } + value |= Int32(momentum.rawValue) << 1 + self.rawValue = value + } + + init(rawValue: Int32) { + self.rawValue = rawValue + } + + var cScrollMods: ghostty_input_scroll_mods_t { + rawValue + } + } +} + +// MARK: Ghostty.Input.Momentum + +extension Ghostty.Input { + /// `ghostty_input_mouse_momentum_e` - Momentum phase for scroll events + enum Momentum: UInt8, CaseIterable { + case none = 0 + case began = 1 + case stationary = 2 + case changed = 3 + case ended = 4 + case cancelled = 5 + case mayBegin = 6 + + var cMomentum: ghostty_input_mouse_momentum_e { + switch self { + case .none: GHOSTTY_MOUSE_MOMENTUM_NONE + case .began: GHOSTTY_MOUSE_MOMENTUM_BEGAN + case .stationary: GHOSTTY_MOUSE_MOMENTUM_STATIONARY + case .changed: GHOSTTY_MOUSE_MOMENTUM_CHANGED + case .ended: GHOSTTY_MOUSE_MOMENTUM_ENDED + case .cancelled: GHOSTTY_MOUSE_MOMENTUM_CANCELLED + case .mayBegin: GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN + } + } + } +} + +extension Ghostty.Input.Momentum: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scroll Momentum") + + static var caseDisplayRepresentations: [Ghostty.Input.Momentum : DisplayRepresentation] = [ + .none: "None", + .began: "Began", + .stationary: "Stationary", + .changed: "Changed", + .ended: "Ended", + .cancelled: "Cancelled", + .mayBegin: "May Begin" + ] +} + +#if canImport(AppKit) +import AppKit + +extension Ghostty.Input.Momentum { + /// Create a Momentum from an NSEvent.Phase + init(_ phase: NSEvent.Phase) { + switch phase { + case .began: self = .began + case .stationary: self = .stationary + case .changed: self = .changed + case .ended: self = .ended + case .cancelled: self = .cancelled + case .mayBegin: self = .mayBegin + default: self = .none + } + } +} +#endif + +// MARK: Ghostty.Input.Mods + +extension Ghostty.Input { + /// `ghostty_input_mods_e` + struct Mods: OptionSet { + let rawValue: UInt32 + + static let none = Mods(rawValue: GHOSTTY_MODS_NONE.rawValue) + static let shift = Mods(rawValue: GHOSTTY_MODS_SHIFT.rawValue) + static let ctrl = Mods(rawValue: GHOSTTY_MODS_CTRL.rawValue) + static let alt = Mods(rawValue: GHOSTTY_MODS_ALT.rawValue) + static let `super` = Mods(rawValue: GHOSTTY_MODS_SUPER.rawValue) + static let caps = Mods(rawValue: GHOSTTY_MODS_CAPS.rawValue) + static let shiftRight = Mods(rawValue: GHOSTTY_MODS_SHIFT_RIGHT.rawValue) + static let ctrlRight = Mods(rawValue: GHOSTTY_MODS_CTRL_RIGHT.rawValue) + static let altRight = Mods(rawValue: GHOSTTY_MODS_ALT_RIGHT.rawValue) + static let superRight = Mods(rawValue: GHOSTTY_MODS_SUPER_RIGHT.rawValue) + + var cMods: ghostty_input_mods_e { + ghostty_input_mods_e(rawValue) + } + + init(rawValue: UInt32) { + self.rawValue = rawValue + } + + init(cMods: ghostty_input_mods_e) { + self.rawValue = cMods.rawValue + } + + init(nsFlags: NSEvent.ModifierFlags) { + self.init(cMods: Ghostty.ghosttyMods(nsFlags)) + } + + var nsFlags: NSEvent.ModifierFlags { + Ghostty.eventModifierFlags(mods: cMods) + } + } +} + +// MARK: Ghostty.Input.Key + +extension Ghostty.Input { + /// `ghostty_input_key_e` + enum Key: String { + // Writing System Keys + case backquote + case backslash + case bracketLeft + case bracketRight + case comma + case digit0 + case digit1 + case digit2 + case digit3 + case digit4 + case digit5 + case digit6 + case digit7 + case digit8 + case digit9 + case equal + case intlBackslash + case intlRo + case intlYen + case a + case b + case c + case d + case e + case f + case g + case h + case i + case j + case k + case l + case m + case n + case o + case p + case q + case r + case s + case t + case u + case v + case w + case x + case y + case z + case minus + case period + case quote + case semicolon + case slash + + // Functional Keys + case altLeft + case altRight + case backspace + case capsLock + case contextMenu + case controlLeft + case controlRight + case enter + case metaLeft + case metaRight + case shiftLeft + case shiftRight + case space + case tab + case convert + case kanaMode + case nonConvert + + // Control Pad Section + case delete + case end + case help + case home + case insert + case pageDown + case pageUp + + // Arrow Pad Section + case arrowDown + case arrowLeft + case arrowRight + case arrowUp + + // Numpad Section + case numLock + case numpad0 + case numpad1 + case numpad2 + case numpad3 + case numpad4 + case numpad5 + case numpad6 + case numpad7 + case numpad8 + case numpad9 + case numpadAdd + case numpadBackspace + case numpadClear + case numpadClearEntry + case numpadComma + case numpadDecimal + case numpadDivide + case numpadEnter + case numpadEqual + case numpadMemoryAdd + case numpadMemoryClear + case numpadMemoryRecall + case numpadMemoryStore + case numpadMemorySubtract + case numpadMultiply + case numpadParenLeft + case numpadParenRight + case numpadSubtract + case numpadSeparator + case numpadUp + case numpadDown + case numpadRight + case numpadLeft + case numpadBegin + case numpadHome + case numpadEnd + case numpadInsert + case numpadDelete + case numpadPageUp + case numpadPageDown + + // Function Section + case escape + case f1 + case f2 + case f3 + case f4 + case f5 + case f6 + case f7 + case f8 + case f9 + case f10 + case f11 + case f12 + case f13 + case f14 + case f15 + case f16 + case f17 + case f18 + case f19 + case f20 + case f21 + case f22 + case f23 + case f24 + case f25 + case fn + case fnLock + case printScreen + case scrollLock + case pause + + // Media Keys + case browserBack + case browserFavorites + case browserForward + case browserHome + case browserRefresh + case browserSearch + case browserStop + case eject + case launchApp1 + case launchApp2 + case launchMail + case mediaPlayPause + case mediaSelect + case mediaStop + case mediaTrackNext + case mediaTrackPrevious + case power + case sleep + case audioVolumeDown + case audioVolumeMute + case audioVolumeUp + case wakeUp + + // Legacy, Non-standard, and Special Keys + case copy + case cut + case paste + + /// Get a key from a keycode + init?(keyCode: UInt16) { + if let key = Key.allCases.first(where: { $0.keyCode == keyCode }) { + self = key + return + } + + return nil + } + + var cKey: ghostty_input_key_e { + switch self { + // Writing System Keys + case .backquote: GHOSTTY_KEY_BACKQUOTE + case .backslash: GHOSTTY_KEY_BACKSLASH + case .bracketLeft: GHOSTTY_KEY_BRACKET_LEFT + case .bracketRight: GHOSTTY_KEY_BRACKET_RIGHT + case .comma: GHOSTTY_KEY_COMMA + case .digit0: GHOSTTY_KEY_DIGIT_0 + case .digit1: GHOSTTY_KEY_DIGIT_1 + case .digit2: GHOSTTY_KEY_DIGIT_2 + case .digit3: GHOSTTY_KEY_DIGIT_3 + case .digit4: GHOSTTY_KEY_DIGIT_4 + case .digit5: GHOSTTY_KEY_DIGIT_5 + case .digit6: GHOSTTY_KEY_DIGIT_6 + case .digit7: GHOSTTY_KEY_DIGIT_7 + case .digit8: GHOSTTY_KEY_DIGIT_8 + case .digit9: GHOSTTY_KEY_DIGIT_9 + case .equal: GHOSTTY_KEY_EQUAL + case .intlBackslash: GHOSTTY_KEY_INTL_BACKSLASH + case .intlRo: GHOSTTY_KEY_INTL_RO + case .intlYen: GHOSTTY_KEY_INTL_YEN + case .a: GHOSTTY_KEY_A + case .b: GHOSTTY_KEY_B + case .c: GHOSTTY_KEY_C + case .d: GHOSTTY_KEY_D + case .e: GHOSTTY_KEY_E + case .f: GHOSTTY_KEY_F + case .g: GHOSTTY_KEY_G + case .h: GHOSTTY_KEY_H + case .i: GHOSTTY_KEY_I + case .j: GHOSTTY_KEY_J + case .k: GHOSTTY_KEY_K + case .l: GHOSTTY_KEY_L + case .m: GHOSTTY_KEY_M + case .n: GHOSTTY_KEY_N + case .o: GHOSTTY_KEY_O + case .p: GHOSTTY_KEY_P + case .q: GHOSTTY_KEY_Q + case .r: GHOSTTY_KEY_R + case .s: GHOSTTY_KEY_S + case .t: GHOSTTY_KEY_T + case .u: GHOSTTY_KEY_U + case .v: GHOSTTY_KEY_V + case .w: GHOSTTY_KEY_W + case .x: GHOSTTY_KEY_X + case .y: GHOSTTY_KEY_Y + case .z: GHOSTTY_KEY_Z + case .minus: GHOSTTY_KEY_MINUS + case .period: GHOSTTY_KEY_PERIOD + case .quote: GHOSTTY_KEY_QUOTE + case .semicolon: GHOSTTY_KEY_SEMICOLON + case .slash: GHOSTTY_KEY_SLASH + + // Functional Keys + case .altLeft: GHOSTTY_KEY_ALT_LEFT + case .altRight: GHOSTTY_KEY_ALT_RIGHT + case .backspace: GHOSTTY_KEY_BACKSPACE + case .capsLock: GHOSTTY_KEY_CAPS_LOCK + case .contextMenu: GHOSTTY_KEY_CONTEXT_MENU + case .controlLeft: GHOSTTY_KEY_CONTROL_LEFT + case .controlRight: GHOSTTY_KEY_CONTROL_RIGHT + case .enter: GHOSTTY_KEY_ENTER + case .metaLeft: GHOSTTY_KEY_META_LEFT + case .metaRight: GHOSTTY_KEY_META_RIGHT + case .shiftLeft: GHOSTTY_KEY_SHIFT_LEFT + case .shiftRight: GHOSTTY_KEY_SHIFT_RIGHT + case .space: GHOSTTY_KEY_SPACE + case .tab: GHOSTTY_KEY_TAB + case .convert: GHOSTTY_KEY_CONVERT + case .kanaMode: GHOSTTY_KEY_KANA_MODE + case .nonConvert: GHOSTTY_KEY_NON_CONVERT + + // Control Pad Section + case .delete: GHOSTTY_KEY_DELETE + case .end: GHOSTTY_KEY_END + case .help: GHOSTTY_KEY_HELP + case .home: GHOSTTY_KEY_HOME + case .insert: GHOSTTY_KEY_INSERT + case .pageDown: GHOSTTY_KEY_PAGE_DOWN + case .pageUp: GHOSTTY_KEY_PAGE_UP + + // Arrow Pad Section + case .arrowDown: GHOSTTY_KEY_ARROW_DOWN + case .arrowLeft: GHOSTTY_KEY_ARROW_LEFT + case .arrowRight: GHOSTTY_KEY_ARROW_RIGHT + case .arrowUp: GHOSTTY_KEY_ARROW_UP + + // Numpad Section + case .numLock: GHOSTTY_KEY_NUM_LOCK + case .numpad0: GHOSTTY_KEY_NUMPAD_0 + case .numpad1: GHOSTTY_KEY_NUMPAD_1 + case .numpad2: GHOSTTY_KEY_NUMPAD_2 + case .numpad3: GHOSTTY_KEY_NUMPAD_3 + case .numpad4: GHOSTTY_KEY_NUMPAD_4 + case .numpad5: GHOSTTY_KEY_NUMPAD_5 + case .numpad6: GHOSTTY_KEY_NUMPAD_6 + case .numpad7: GHOSTTY_KEY_NUMPAD_7 + case .numpad8: GHOSTTY_KEY_NUMPAD_8 + case .numpad9: GHOSTTY_KEY_NUMPAD_9 + case .numpadAdd: GHOSTTY_KEY_NUMPAD_ADD + case .numpadBackspace: GHOSTTY_KEY_NUMPAD_BACKSPACE + case .numpadClear: GHOSTTY_KEY_NUMPAD_CLEAR + case .numpadClearEntry: GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY + case .numpadComma: GHOSTTY_KEY_NUMPAD_COMMA + case .numpadDecimal: GHOSTTY_KEY_NUMPAD_DECIMAL + case .numpadDivide: GHOSTTY_KEY_NUMPAD_DIVIDE + case .numpadEnter: GHOSTTY_KEY_NUMPAD_ENTER + case .numpadEqual: GHOSTTY_KEY_NUMPAD_EQUAL + case .numpadMemoryAdd: GHOSTTY_KEY_NUMPAD_MEMORY_ADD + case .numpadMemoryClear: GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR + case .numpadMemoryRecall: GHOSTTY_KEY_NUMPAD_MEMORY_RECALL + case .numpadMemoryStore: GHOSTTY_KEY_NUMPAD_MEMORY_STORE + case .numpadMemorySubtract: GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT + case .numpadMultiply: GHOSTTY_KEY_NUMPAD_MULTIPLY + case .numpadParenLeft: GHOSTTY_KEY_NUMPAD_PAREN_LEFT + case .numpadParenRight: GHOSTTY_KEY_NUMPAD_PAREN_RIGHT + case .numpadSubtract: GHOSTTY_KEY_NUMPAD_SUBTRACT + case .numpadSeparator: GHOSTTY_KEY_NUMPAD_SEPARATOR + case .numpadUp: GHOSTTY_KEY_NUMPAD_UP + case .numpadDown: GHOSTTY_KEY_NUMPAD_DOWN + case .numpadRight: GHOSTTY_KEY_NUMPAD_RIGHT + case .numpadLeft: GHOSTTY_KEY_NUMPAD_LEFT + case .numpadBegin: GHOSTTY_KEY_NUMPAD_BEGIN + case .numpadHome: GHOSTTY_KEY_NUMPAD_HOME + case .numpadEnd: GHOSTTY_KEY_NUMPAD_END + case .numpadInsert: GHOSTTY_KEY_NUMPAD_INSERT + case .numpadDelete: GHOSTTY_KEY_NUMPAD_DELETE + case .numpadPageUp: GHOSTTY_KEY_NUMPAD_PAGE_UP + case .numpadPageDown: GHOSTTY_KEY_NUMPAD_PAGE_DOWN + + // Function Section + case .escape: GHOSTTY_KEY_ESCAPE + case .f1: GHOSTTY_KEY_F1 + case .f2: GHOSTTY_KEY_F2 + case .f3: GHOSTTY_KEY_F3 + case .f4: GHOSTTY_KEY_F4 + case .f5: GHOSTTY_KEY_F5 + case .f6: GHOSTTY_KEY_F6 + case .f7: GHOSTTY_KEY_F7 + case .f8: GHOSTTY_KEY_F8 + case .f9: GHOSTTY_KEY_F9 + case .f10: GHOSTTY_KEY_F10 + case .f11: GHOSTTY_KEY_F11 + case .f12: GHOSTTY_KEY_F12 + case .f13: GHOSTTY_KEY_F13 + case .f14: GHOSTTY_KEY_F14 + case .f15: GHOSTTY_KEY_F15 + case .f16: GHOSTTY_KEY_F16 + case .f17: GHOSTTY_KEY_F17 + case .f18: GHOSTTY_KEY_F18 + case .f19: GHOSTTY_KEY_F19 + case .f20: GHOSTTY_KEY_F20 + case .f21: GHOSTTY_KEY_F21 + case .f22: GHOSTTY_KEY_F22 + case .f23: GHOSTTY_KEY_F23 + case .f24: GHOSTTY_KEY_F24 + case .f25: GHOSTTY_KEY_F25 + case .fn: GHOSTTY_KEY_FN + case .fnLock: GHOSTTY_KEY_FN_LOCK + case .printScreen: GHOSTTY_KEY_PRINT_SCREEN + case .scrollLock: GHOSTTY_KEY_SCROLL_LOCK + case .pause: GHOSTTY_KEY_PAUSE + + // Media Keys + case .browserBack: GHOSTTY_KEY_BROWSER_BACK + case .browserFavorites: GHOSTTY_KEY_BROWSER_FAVORITES + case .browserForward: GHOSTTY_KEY_BROWSER_FORWARD + case .browserHome: GHOSTTY_KEY_BROWSER_HOME + case .browserRefresh: GHOSTTY_KEY_BROWSER_REFRESH + case .browserSearch: GHOSTTY_KEY_BROWSER_SEARCH + case .browserStop: GHOSTTY_KEY_BROWSER_STOP + case .eject: GHOSTTY_KEY_EJECT + case .launchApp1: GHOSTTY_KEY_LAUNCH_APP_1 + case .launchApp2: GHOSTTY_KEY_LAUNCH_APP_2 + case .launchMail: GHOSTTY_KEY_LAUNCH_MAIL + case .mediaPlayPause: GHOSTTY_KEY_MEDIA_PLAY_PAUSE + case .mediaSelect: GHOSTTY_KEY_MEDIA_SELECT + case .mediaStop: GHOSTTY_KEY_MEDIA_STOP + case .mediaTrackNext: GHOSTTY_KEY_MEDIA_TRACK_NEXT + case .mediaTrackPrevious: GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS + case .power: GHOSTTY_KEY_POWER + case .sleep: GHOSTTY_KEY_SLEEP + case .audioVolumeDown: GHOSTTY_KEY_AUDIO_VOLUME_DOWN + case .audioVolumeMute: GHOSTTY_KEY_AUDIO_VOLUME_MUTE + case .audioVolumeUp: GHOSTTY_KEY_AUDIO_VOLUME_UP + case .wakeUp: GHOSTTY_KEY_WAKE_UP + + // Legacy, Non-standard, and Special Keys + case .copy: GHOSTTY_KEY_COPY + case .cut: GHOSTTY_KEY_CUT + case .paste: GHOSTTY_KEY_PASTE + } + } + + // Based on src/input/keycodes.zig + var keyCode: UInt16? { + switch self { + // Writing System Keys + case .backquote: return 0x0032 + case .backslash: return 0x002a + case .bracketLeft: return 0x0021 + case .bracketRight: return 0x001e + case .comma: return 0x002b + case .digit0: return 0x001d + case .digit1: return 0x0012 + case .digit2: return 0x0013 + case .digit3: return 0x0014 + case .digit4: return 0x0015 + case .digit5: return 0x0017 + case .digit6: return 0x0016 + case .digit7: return 0x001a + case .digit8: return 0x001c + case .digit9: return 0x0019 + case .equal: return 0x0018 + case .intlBackslash: return 0x000a + case .intlRo: return 0x005e + case .intlYen: return 0x005d + case .a: return 0x0000 + case .b: return 0x000b + case .c: return 0x0008 + case .d: return 0x0002 + case .e: return 0x000e + case .f: return 0x0003 + case .g: return 0x0005 + case .h: return 0x0004 + case .i: return 0x0022 + case .j: return 0x0026 + case .k: return 0x0028 + case .l: return 0x0025 + case .m: return 0x002e + case .n: return 0x002d + case .o: return 0x001f + case .p: return 0x0023 + case .q: return 0x000c + case .r: return 0x000f + case .s: return 0x0001 + case .t: return 0x0011 + case .u: return 0x0020 + case .v: return 0x0009 + case .w: return 0x000d + case .x: return 0x0007 + case .y: return 0x0010 + case .z: return 0x0006 + case .minus: return 0x001b + case .period: return 0x002f + case .quote: return 0x0027 + case .semicolon: return 0x0029 + case .slash: return 0x002c + + // Functional Keys + case .altLeft: return 0x003a + case .altRight: return 0x003d + case .backspace: return 0x0033 + case .capsLock: return 0x0039 + case .contextMenu: return 0x006e + case .controlLeft: return 0x003b + case .controlRight: return 0x003e + case .enter: return 0x0024 + case .metaLeft: return 0x0037 + case .metaRight: return 0x0036 + case .shiftLeft: return 0x0038 + case .shiftRight: return 0x003c + case .space: return 0x0031 + case .tab: return 0x0030 + case .convert: return nil // No Mac keycode + case .kanaMode: return nil // No Mac keycode + case .nonConvert: return nil // No Mac keycode + + // Control Pad Section + case .delete: return 0x0075 + case .end: return 0x0077 + case .help: return nil // No Mac keycode + case .home: return 0x0073 + case .insert: return 0x0072 + case .pageDown: return 0x0079 + case .pageUp: return 0x0074 + + // Arrow Pad Section + case .arrowDown: return 0x007d + case .arrowLeft: return 0x007b + case .arrowRight: return 0x007c + case .arrowUp: return 0x007e + + // Numpad Section + case .numLock: return 0x0047 + case .numpad0: return 0x0052 + case .numpad1: return 0x0053 + case .numpad2: return 0x0054 + case .numpad3: return 0x0055 + case .numpad4: return 0x0056 + case .numpad5: return 0x0057 + case .numpad6: return 0x0058 + case .numpad7: return 0x0059 + case .numpad8: return 0x005b + case .numpad9: return 0x005c + case .numpadAdd: return 0x0045 + case .numpadBackspace: return nil // No Mac keycode + case .numpadClear: return nil // No Mac keycode + case .numpadClearEntry: return nil // No Mac keycode + case .numpadComma: return 0x005f + case .numpadDecimal: return 0x0041 + case .numpadDivide: return 0x004b + case .numpadEnter: return 0x004c + case .numpadEqual: return 0x0051 + case .numpadMemoryAdd: return nil // No Mac keycode + case .numpadMemoryClear: return nil // No Mac keycode + case .numpadMemoryRecall: return nil // No Mac keycode + case .numpadMemoryStore: return nil // No Mac keycode + case .numpadMemorySubtract: return nil // No Mac keycode + case .numpadMultiply: return 0x0043 + case .numpadParenLeft: return nil // No Mac keycode + case .numpadParenRight: return nil // No Mac keycode + case .numpadSubtract: return 0x004e + case .numpadSeparator: return nil // No Mac keycode + case .numpadUp: return nil // No Mac keycode + case .numpadDown: return nil // No Mac keycode + case .numpadRight: return nil // No Mac keycode + case .numpadLeft: return nil // No Mac keycode + case .numpadBegin: return nil // No Mac keycode + case .numpadHome: return nil // No Mac keycode + case .numpadEnd: return nil // No Mac keycode + case .numpadInsert: return nil // No Mac keycode + case .numpadDelete: return nil // No Mac keycode + case .numpadPageUp: return nil // No Mac keycode + case .numpadPageDown: return nil // No Mac keycode + + // Function Section + case .escape: return 0x0035 + case .f1: return 0x007a + case .f2: return 0x0078 + case .f3: return 0x0063 + case .f4: return 0x0076 + case .f5: return 0x0060 + case .f6: return 0x0061 + case .f7: return 0x0062 + case .f8: return 0x0064 + case .f9: return 0x0065 + case .f10: return 0x006d + case .f11: return 0x0067 + case .f12: return 0x006f + case .f13: return 0x0069 + case .f14: return 0x006b + case .f15: return 0x0071 + case .f16: return 0x006a + case .f17: return 0x0040 + case .f18: return 0x004f + case .f19: return 0x0050 + case .f20: return 0x005a + case .f21: return nil // No Mac keycode + case .f22: return nil // No Mac keycode + case .f23: return nil // No Mac keycode + case .f24: return nil // No Mac keycode + case .f25: return nil // No Mac keycode + case .fn: return nil // No Mac keycode + case .fnLock: return nil // No Mac keycode + case .printScreen: return nil // No Mac keycode + case .scrollLock: return nil // No Mac keycode + case .pause: return nil // No Mac keycode + + // Media Keys + case .browserBack: return nil // No Mac keycode + case .browserFavorites: return nil // No Mac keycode + case .browserForward: return nil // No Mac keycode + case .browserHome: return nil // No Mac keycode + case .browserRefresh: return nil // No Mac keycode + case .browserSearch: return nil // No Mac keycode + case .browserStop: return nil // No Mac keycode + case .eject: return nil // No Mac keycode + case .launchApp1: return nil // No Mac keycode + case .launchApp2: return nil // No Mac keycode + case .launchMail: return nil // No Mac keycode + case .mediaPlayPause: return nil // No Mac keycode + case .mediaSelect: return nil // No Mac keycode + case .mediaStop: return nil // No Mac keycode + case .mediaTrackNext: return nil // No Mac keycode + case .mediaTrackPrevious: return nil // No Mac keycode + case .power: return nil // No Mac keycode + case .sleep: return nil // No Mac keycode + case .audioVolumeDown: return 0x0049 + case .audioVolumeMute: return 0x004a + case .audioVolumeUp: return 0x0048 + case .wakeUp: return nil // No Mac keycode + + // Legacy, Non-standard, and Special Keys + case .copy: return nil // No Mac keycode + case .cut: return nil // No Mac keycode + case .paste: return nil // No Mac keycode + } + } + } +} + +extension Ghostty.Input.Key: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Key") + + // Only include keys that have Mac keycodes for App Intents + static var allCases: [Ghostty.Input.Key] { + return [ + // Letters (A-Z) + .a, .b, .c, .d, .e, .f, .g, .h, .i, .j, .k, .l, .m, .n, .o, .p, .q, .r, .s, .t, .u, .v, .w, .x, .y, .z, + + // Numbers (0-9) + .digit0, .digit1, .digit2, .digit3, .digit4, .digit5, .digit6, .digit7, .digit8, .digit9, + + // Common Control Keys + .space, .enter, .tab, .backspace, .escape, .delete, + + // Arrow Keys + .arrowUp, .arrowDown, .arrowLeft, .arrowRight, + + // Navigation Keys + .home, .end, .pageUp, .pageDown, .insert, + + // Function Keys (F1-F20) + .f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10, .f11, .f12, + .f13, .f14, .f15, .f16, .f17, .f18, .f19, .f20, + + // Modifier Keys + .shiftLeft, .shiftRight, .controlLeft, .controlRight, .altLeft, .altRight, + .metaLeft, .metaRight, .capsLock, + + // Punctuation & Symbols + .minus, .equal, .backquote, .bracketLeft, .bracketRight, .backslash, + .semicolon, .quote, .comma, .period, .slash, + + // Numpad + .numLock, .numpad0, .numpad1, .numpad2, .numpad3, .numpad4, .numpad5, + .numpad6, .numpad7, .numpad8, .numpad9, .numpadAdd, .numpadSubtract, + .numpadMultiply, .numpadDivide, .numpadDecimal, .numpadEqual, + .numpadEnter, .numpadComma, + + // Media Keys + .audioVolumeUp, .audioVolumeDown, .audioVolumeMute, + + // International Keys + .intlBackslash, .intlRo, .intlYen, + + // Other + .contextMenu + ] + } + + static var caseDisplayRepresentations: [Ghostty.Input.Key : DisplayRepresentation] = [ + // Letters (A-Z) + .a: "A", .b: "B", .c: "C", .d: "D", .e: "E", .f: "F", .g: "G", .h: "H", .i: "I", .j: "J", + .k: "K", .l: "L", .m: "M", .n: "N", .o: "O", .p: "P", .q: "Q", .r: "R", .s: "S", .t: "T", + .u: "U", .v: "V", .w: "W", .x: "X", .y: "Y", .z: "Z", + + // Numbers (0-9) + .digit0: "0", .digit1: "1", .digit2: "2", .digit3: "3", .digit4: "4", + .digit5: "5", .digit6: "6", .digit7: "7", .digit8: "8", .digit9: "9", + + // Common Control Keys + .space: "Space", + .enter: "Enter", + .tab: "Tab", + .backspace: "Backspace", + .escape: "Escape", + .delete: "Delete", + + // Arrow Keys + .arrowUp: "Up Arrow", + .arrowDown: "Down Arrow", + .arrowLeft: "Left Arrow", + .arrowRight: "Right Arrow", + + // Navigation Keys + .home: "Home", + .end: "End", + .pageUp: "Page Up", + .pageDown: "Page Down", + .insert: "Insert", + + // Function Keys (F1-F20) + .f1: "F1", .f2: "F2", .f3: "F3", .f4: "F4", .f5: "F5", .f6: "F6", + .f7: "F7", .f8: "F8", .f9: "F9", .f10: "F10", .f11: "F11", .f12: "F12", + .f13: "F13", .f14: "F14", .f15: "F15", .f16: "F16", .f17: "F17", + .f18: "F18", .f19: "F19", .f20: "F20", + + // Modifier Keys + .shiftLeft: "Left Shift", + .shiftRight: "Right Shift", + .controlLeft: "Left Control", + .controlRight: "Right Control", + .altLeft: "Left Alt", + .altRight: "Right Alt", + .metaLeft: "Left Command", + .metaRight: "Right Command", + .capsLock: "Caps Lock", + + // Punctuation & Symbols + .minus: "Minus (-)", + .equal: "Equal (=)", + .backquote: "Backtick (`)", + .bracketLeft: "Left Bracket ([)", + .bracketRight: "Right Bracket (])", + .backslash: "Backslash (\\)", + .semicolon: "Semicolon (;)", + .quote: "Quote (')", + .comma: "Comma (,)", + .period: "Period (.)", + .slash: "Slash (/)", + + // Numpad + .numLock: "Num Lock", + .numpad0: "Numpad 0", .numpad1: "Numpad 1", .numpad2: "Numpad 2", + .numpad3: "Numpad 3", .numpad4: "Numpad 4", .numpad5: "Numpad 5", + .numpad6: "Numpad 6", .numpad7: "Numpad 7", .numpad8: "Numpad 8", .numpad9: "Numpad 9", + .numpadAdd: "Numpad Add (+)", + .numpadSubtract: "Numpad Subtract (-)", + .numpadMultiply: "Numpad Multiply (×)", + .numpadDivide: "Numpad Divide (÷)", + .numpadDecimal: "Numpad Decimal", + .numpadEqual: "Numpad Equal", + .numpadEnter: "Numpad Enter", + .numpadComma: "Numpad Comma", + + // Media Keys + .audioVolumeUp: "Volume Up", + .audioVolumeDown: "Volume Down", + .audioVolumeMute: "Volume Mute", + + // International Keys + .intlBackslash: "International Backslash", + .intlRo: "International Ro", + .intlYen: "International Yen", + + // Other + .contextMenu: "Context Menu" + ] } diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift deleted file mode 100644 index 95c019b1f..000000000 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ /dev/null @@ -1,494 +0,0 @@ -import SwiftUI -import Combine -import GhosttyKit - -extension Ghostty { - /// This enum represents the possible states that a node in the split tree can be in. It is either: - /// - /// - noSplit - This is an unsplit, single pane. This contains only a "leaf" which has a single - /// terminal surface to render. - /// - horizontal/vertical - This is split into the horizontal or vertical direction. This contains a - /// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These - /// values can further be split infinitely. - /// - enum SplitNode: Equatable, Hashable, Codable, Sequence { - case leaf(Leaf) - case split(Container) - - /// The parent of this node. - var parent: Container? { - get { - switch (self) { - case .leaf(let leaf): - return leaf.parent - - case .split(let container): - return container.parent - } - } - - set { - switch (self) { - case .leaf(let leaf): - leaf.parent = newValue - - case .split(let container): - container.parent = newValue - } - } - } - - /// Returns true if the tree is split. - var isSplit: Bool { - return if case .leaf = self { - false - } else { - true - } - } - - func topLeft() -> SurfaceView { - switch (self) { - case .leaf(let leaf): - return leaf.surface - - case .split(let container): - return container.topLeft.topLeft() - } - } - - /// Returns the view that would prefer receiving focus in this tree. This is always the - /// top-left-most view. This is used when creating a split or closing a split to find the - /// next view to send focus to. - func preferredFocus(_ direction: SplitFocusDirection = .up) -> SurfaceView { - let container: Container - switch (self) { - case .leaf(let leaf): - // noSplit is easy because there is only one thing to focus - return leaf.surface - - case .split(let c): - container = c - } - - let node: SplitNode - switch (direction) { - case .previous, .up, .left: - node = container.bottomRight - - case .next, .down, .right: - node = container.topLeft - } - - return node.preferredFocus(direction) - } - - /// When direction is either next or previous, return the first or last - /// leaf. This can be used when the focus needs to move to a leaf even - /// after hitting the bottom-right-most or top-left-most surface. - /// When the direction is not next or previous (such as top, bottom, - /// left, right), it will be ignored and no leaf will be returned. - func firstOrLast(_ direction: SplitFocusDirection) -> Leaf? { - // If there is no parent, simply ignore. - guard let root = self.parent?.rootContainer() else { return nil } - - switch (direction) { - case .next: - return root.firstLeaf() - case .previous: - return root.lastLeaf() - default: - return nil - } - } - - /// Close the surface associated with this node. This will likely deinitialize the - /// surface. At this point, the surface view in this node tree can never be used again. - func close() { - switch (self) { - case .leaf(let leaf): - leaf.surface.close() - - case .split(let container): - container.topLeft.close() - container.bottomRight.close() - } - } - - /// Returns true if any surface in the split stack requires quit confirmation. - func needsConfirmQuit() -> Bool { - switch (self) { - case .leaf(let leaf): - return leaf.surface.needsConfirmQuit - - case .split(let container): - return container.topLeft.needsConfirmQuit() || - container.bottomRight.needsConfirmQuit() - } - } - - /// Returns true if the split tree contains the given view. - func contains(view: SurfaceView) -> Bool { - return leaf(for: view) != nil - } - - /// Find a surface view by UUID. - func findUUID(uuid: UUID) -> SurfaceView? { - switch (self) { - case .leaf(let leaf): - if (leaf.surface.uuid == uuid) { - return leaf.surface - } - - return nil - - case .split(let container): - return container.topLeft.findUUID(uuid: uuid) ?? - container.bottomRight.findUUID(uuid: uuid) - } - } - - /// Returns true if the surface borders the top. Assumes the view is in the tree. - func doesBorderTop(view: SurfaceView) -> Bool { - switch (self) { - case .leaf(let leaf): - return leaf.surface == view - - case .split(let container): - switch (container.direction) { - case .vertical: - return container.topLeft.doesBorderTop(view: view) - - case .horizontal: - return container.topLeft.doesBorderTop(view: view) || - container.bottomRight.doesBorderTop(view: view) - } - } - } - - /// Return the node for the given view if its in the tree. - func leaf(for view: SurfaceView) -> Leaf? { - switch (self) { - case .leaf(let leaf): - if leaf.surface == view { - return leaf - } else { - return nil - } - - case .split(let container): - return container.topLeft.leaf(for: view) ?? - container.bottomRight.leaf(for: view) - } - } - - // MARK: - Sequence - - func makeIterator() -> IndexingIterator<[Leaf]> { - return leaves().makeIterator() - } - - /// Return all the leaves in this split node. This isn't very efficient but our split trees are never super - /// deep so its not an issue. - private func leaves() -> [Leaf] { - switch (self) { - case .leaf(let leaf): - return [leaf] - - case .split(let container): - return container.topLeft.leaves() + container.bottomRight.leaves() - } - } - - // MARK: - Equatable - - static func == (lhs: SplitNode, rhs: SplitNode) -> Bool { - switch (lhs, rhs) { - case (.leaf(let lhs_v), .leaf(let rhs_v)): - return lhs_v === rhs_v - case (.split(let lhs_v), .split(let rhs_v)): - return lhs_v === rhs_v - default: - return false - } - } - - class Leaf: ObservableObject, Equatable, Hashable, Codable { - let app: ghostty_app_t - @Published var surface: SurfaceView - - weak var parent: SplitNode.Container? - - /// Initialize a new leaf which creates a new terminal surface. - init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { - self.app = app - self.surface = SurfaceView(app, baseConfig: baseConfig, uuid: uuid) - } - - // MARK: - Hashable - - func hash(into hasher: inout Hasher) { - hasher.combine(app) - hasher.combine(surface) - } - - // MARK: - Equatable - - static func == (lhs: Leaf, rhs: Leaf) -> Bool { - return lhs.app == rhs.app && lhs.surface === rhs.surface - } - - // MARK: - Codable - - enum CodingKeys: String, CodingKey { - case pwd - case uuid - } - - required convenience init(from decoder: Decoder) throws { - // Decoding uses the global Ghostty app - guard let del = NSApplication.shared.delegate, - let appDel = del as? AppDelegate, - let app = appDel.ghostty.app else { - throw TerminalRestoreError.delegateInvalid - } - - let container = try decoder.container(keyedBy: CodingKeys.self) - let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid)) - var config = SurfaceConfiguration() - config.workingDirectory = try container.decode(String?.self, forKey: .pwd) - - self.init(app, baseConfig: config, uuid: uuid) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(surface.pwd, forKey: .pwd) - try container.encode(surface.uuid.uuidString, forKey: .uuid) - } - } - - class Container: ObservableObject, Equatable, Hashable, Codable { - let app: ghostty_app_t - let direction: SplitViewDirection - - @Published var topLeft: SplitNode - @Published var bottomRight: SplitNode - @Published var split: CGFloat = 0.5 - - var resizeEvent: PassthroughSubject = .init() - - weak var parent: SplitNode.Container? - - /// A container is always initialized from some prior leaf because a split has to originate - /// from a non-split value. When initializing, we inherit the leaf's surface and then - /// initialize a new surface for the new pane. - init(from: Leaf, direction: SplitViewDirection, baseConfig: SurfaceConfiguration? = nil) { - self.app = from.app - self.direction = direction - self.parent = from.parent - - // Initially, both topLeft and bottomRight are in the "nosplit" - // state since this is a new split. - self.topLeft = .leaf(from) - - let bottomRight: Leaf = .init(app, baseConfig: baseConfig) - self.bottomRight = .leaf(bottomRight) - - from.parent = self - bottomRight.parent = self - } - - // Move the top left node to the bottom right and vice versa, - // preserving the size. - func swap() { - let topLeft: SplitNode = self.topLeft - self.topLeft = bottomRight - self.bottomRight = topLeft - self.split = 1 - self.split - } - - /// Resize the split by moving the split divider in the given - /// direction by the given amount. If this container is not split - /// in the given direction, navigate up the tree until we find a - /// container that is - func resize(direction: SplitResizeDirection, amount: UInt16) { - // We send a resize event to our publisher which will be - // received by the SplitView. - switch (self.direction) { - case .horizontal: - switch (direction) { - case .left: resizeEvent.send(-Double(amount)) - case .right: resizeEvent.send(Double(amount)) - default: parent?.resize(direction: direction, amount: amount) - } - case .vertical: - switch (direction) { - case .up: resizeEvent.send(-Double(amount)) - case .down: resizeEvent.send(Double(amount)) - default: parent?.resize(direction: direction, amount: amount) - } - } - } - - /// Equalize the splits in this container. Each split is equalized - /// based on its weight, i.e. the number of leaves it contains. - /// This function returns the weight of this container. - func equalize() -> UInt { - let topLeftWeight: UInt - switch (topLeft) { - case .leaf: - topLeftWeight = 1 - case .split(let c): - topLeftWeight = c.equalize() - } - - let bottomRightWeight: UInt - switch (bottomRight) { - case .leaf: - bottomRightWeight = 1 - case .split(let c): - bottomRightWeight = c.equalize() - } - - let weight = topLeftWeight + bottomRightWeight - split = Double(topLeftWeight) / Double(weight) - return weight - } - - /// Returns the top most parent, or this container. Because this - /// would fall back to use to self, the return value is guaranteed. - func rootContainer() -> Container { - guard let parent = self.parent else { return self } - return parent.rootContainer() - } - - /// Returns the first leaf from the given container. This is most - /// useful for root container, so that we can find the top-left-most - /// leaf. - func firstLeaf() -> Leaf { - switch (self.topLeft) { - case .leaf(let leaf): - return leaf - case .split(let s): - return s.firstLeaf() - } - } - - /// Returns the last leaf from the given container. This is most - /// useful for root container, so that we can find the bottom-right- - /// most leaf. - func lastLeaf() -> Leaf { - switch (self.bottomRight) { - case .leaf(let leaf): - return leaf - case .split(let s): - return s.lastLeaf() - } - } - - // MARK: - Hashable - - func hash(into hasher: inout Hasher) { - hasher.combine(app) - hasher.combine(direction) - hasher.combine(topLeft) - hasher.combine(bottomRight) - } - - // MARK: - Equatable - - static func == (lhs: Container, rhs: Container) -> Bool { - return lhs.app == rhs.app && - lhs.direction == rhs.direction && - lhs.topLeft == rhs.topLeft && - lhs.bottomRight == rhs.bottomRight - } - - // MARK: - Codable - - enum CodingKeys: String, CodingKey { - case direction - case split - case topLeft - case bottomRight - } - - required init(from decoder: Decoder) throws { - // Decoding uses the global Ghostty app - guard let del = NSApplication.shared.delegate, - let appDel = del as? AppDelegate, - let app = appDel.ghostty.app else { - throw TerminalRestoreError.delegateInvalid - } - - let container = try decoder.container(keyedBy: CodingKeys.self) - self.app = app - self.direction = try container.decode(SplitViewDirection.self, forKey: .direction) - self.split = try container.decode(CGFloat.self, forKey: .split) - self.topLeft = try container.decode(SplitNode.self, forKey: .topLeft) - self.bottomRight = try container.decode(SplitNode.self, forKey: .bottomRight) - - // Fix up the parent references - self.topLeft.parent = self - self.bottomRight.parent = self - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(direction, forKey: .direction) - try container.encode(split, forKey: .split) - try container.encode(topLeft, forKey: .topLeft) - try container.encode(bottomRight, forKey: .bottomRight) - } - } - - /// This keeps track of the "neighbors" of a split: the immediately above/below/left/right - /// nodes. This is purposely weak so we don't have to worry about memory management - /// with this (although, it should always be correct). - struct Neighbors { - var left: SplitNode? - var right: SplitNode? - var up: SplitNode? - var down: SplitNode? - - /// These are the previous/next nodes. It will certainly be one of the above as well - /// but we keep track of these separately because depending on the split direction - /// of the containing node, previous may be left OR up (same for next). - var previous: SplitNode? - var next: SplitNode? - - /// No neighbors, used by the root node. - static let empty: Self = .init() - - /// Get the node for a given direction. - func get(direction: SplitFocusDirection) -> SplitNode? { - let map: [SplitFocusDirection : KeyPath] = [ - .previous: \.previous, - .next: \.next, - .up: \.up, - .down: \.down, - .left: \.left, - .right: \.right, - ] - - guard let path = map[direction] else { return nil } - return self[keyPath: path] - } - - /// Update multiple keys and return a new copy. - func update(_ attrs: [WritableKeyPath: SplitNode?]) -> Self { - var clone = self - attrs.forEach { (key, value) in - clone[keyPath: key] = value - } - return clone - } - - /// True if there are no neighbors - func isEmpty() -> Bool { - return self.previous == nil && self.next == nil - } - } - } -} diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift new file mode 100644 index 000000000..c7198e147 --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -0,0 +1,149 @@ +import GhosttyKit + +extension Ghostty { + /// Represents a single surface within Ghostty. + /// + /// NOTE(mitchellh): This is a work-in-progress class as part of a general refactor + /// of our Ghostty data model. At the time of writing there's still a ton of surface + /// functionality that is not encapsulated in this class. It is planned to migrate that + /// all over. + /// + /// Wraps a `ghostty_surface_t` + final class Surface: Sendable { + private let surface: ghostty_surface_t + + /// Read the underlying C value for this surface. This is unsafe because the value will be + /// freed when the Surface class is deinitialized. + var unsafeCValue: ghostty_surface_t { + surface + } + + /// Initialize from the C structure. + init(cSurface: ghostty_surface_t) { + self.surface = cSurface + } + + deinit { + // deinit is not guaranteed to happen on the main actor and our API + // calls into libghostty must happen there so we capture the surface + // value so we don't capture `self` and then we detach it in a task. + // We can't wait for the task to succeed so this will happen sometime + // but that's okay. + let surface = self.surface + Task.detached { @MainActor in + ghostty_surface_free(surface) + } + } + + /// Send text to the terminal as if it was typed. This doesn't send the key events so keyboard + /// shortcuts and other encodings do not take effect. + @MainActor + func sendText(_ text: String) { + let len = text.utf8CString.count + if (len == 0) { return } + + text.withCString { ptr in + // len includes the null terminator so we do len - 1 + ghostty_surface_text(surface, ptr, UInt(len - 1)) + } + } + + /// Send a key event to the terminal. + /// + /// This sends the full key event including modifiers, action type, and text to the terminal. + /// Unlike `sendText`, this method processes keyboard shortcuts, key bindings, and terminal + /// encoding based on the complete key event information. + /// + /// - Parameter event: The key event to send to the terminal + @MainActor + func sendKeyEvent(_ event: Input.KeyEvent) { + event.withCValue { cEvent in + ghostty_surface_key(surface, cEvent) + } + } + + /// Whether the terminal has captured mouse input. + /// + /// When the mouse is captured, the terminal application is receiving mouse events + /// directly rather than the host system handling them. This typically occurs when + /// a terminal application enables mouse reporting mode. + @MainActor + var mouseCaptured: Bool { + ghostty_surface_mouse_captured(surface) + } + + /// Send a mouse button event to the terminal. + /// + /// This sends a complete mouse button event including the button state (press/release), + /// which button was pressed, and any modifier keys that were held during the event. + /// The terminal processes this event according to its mouse handling configuration. + /// + /// - Parameter event: The mouse button event to send to the terminal + @MainActor + func sendMouseButton(_ event: Input.MouseButtonEvent) { + ghostty_surface_mouse_button( + surface, + event.action.cMouseState, + event.button.cMouseButton, + event.mods.cMods) + } + + /// Send a mouse position event to the terminal. + /// + /// This reports the current mouse position to the terminal, which may be used + /// for mouse tracking, hover effects, or other position-dependent features. + /// The terminal will only receive these events if mouse reporting is enabled. + /// + /// - Parameter event: The mouse position event to send to the terminal + @MainActor + func sendMousePos(_ event: Input.MousePosEvent) { + ghostty_surface_mouse_pos( + surface, + event.x, + event.y, + event.mods.cMods) + } + + /// Send a mouse scroll event to the terminal. + /// + /// This sends scroll wheel input to the terminal with delta values for both + /// horizontal and vertical scrolling, along with precision and momentum information. + /// The terminal processes this according to its scroll handling configuration. + /// + /// - Parameter event: The mouse scroll event to send to the terminal + @MainActor + func sendMouseScroll(_ event: Input.MouseScrollEvent) { + ghostty_surface_mouse_scroll( + surface, + event.x, + event.y, + event.mods.cScrollMods) + } + + /// Perform a keybinding action. + /// + /// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4` + /// you can perform `goto_tab:4` with this. + /// + /// Returns true if the action was performed. Invalid actions return false. + @MainActor + func perform(action: String) -> Bool { + let len = action.utf8CString.count + if (len == 0) { return false } + return action.withCString { cString in + ghostty_surface_binding_action(surface, cString, UInt(len - 1)) + } + } + + /// Command options for this surface. + @MainActor + func commands() throws -> [Command] { + var ptr: UnsafeMutablePointer? = nil + var count: Int = 0 + ghostty_surface_commands(surface, &ptr, &count) + guard let ptr else { throw Error.apiFailed } + let buffer = UnsafeBufferPointer(start: ptr, count: count) + return Array(buffer).map { Command(cValue: $0) }.filter { $0.isSupported } + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift deleted file mode 100644 index 3e942d774..000000000 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ /dev/null @@ -1,472 +0,0 @@ -import SwiftUI -import GhosttyKit - -extension Ghostty { - /// A spittable terminal view is one where the terminal allows for "splits" (vertical and horizontal) within the - /// view. The terminal starts in the unsplit state (a plain ol' TerminalView) but responds to changes to the - /// split direction by splitting the terminal. - /// - /// This also allows one split to be "zoomed" at any time. - struct TerminalSplit: View { - /// The current state of the root node. This can be set to nil when all surfaces are closed. - @Binding var node: SplitNode? - - /// Non-nil if one of the surfaces in the split tree is currently "zoomed." A zoomed surface - /// becomes "full screen" on the split tree. - @State private var zoomedSurface: SurfaceView? = nil - - var body: some View { - ZStack { - TerminalSplitRoot( - node: $node, - zoomedSurface: $zoomedSurface - ) - - // If we have a zoomed surface, we overlay that on top of our split - // root. Our split root will become clear when there is a zoomed - // surface. We need to keep the split root around so that we don't - // lose all of the surface state so this must be a ZStack. - if let surfaceView = zoomedSurface { - InspectableSurface(surfaceView: surfaceView) - } - } - .focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != nil) - } - } - - /// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever - /// one of these in a split tree. - private struct TerminalSplitRoot: View { - /// The root node that we're rendering. This will be set to nil if all the surfaces in this tree close. - @Binding var node: SplitNode? - - /// Keeps track of whether we're in a zoomed split state or not. If one of the splits we own - /// is in the zoomed state, we clear our body since we expect a zoomed split to overlay - /// this one. - @Binding var zoomedSurface: SurfaceView? - - var body: some View { - let center = NotificationCenter.default - let pubZoom = center.publisher(for: Notification.didToggleSplitZoom) - - // If we're zoomed, we don't render anything, we are transparent. This - // ensures that the View stays around so we don't lose our state, but - // also that the zoomed view on top can see through if background transparency - // is enabled. - if (zoomedSurface == nil) { - ZStack { - switch (node) { - case nil: - Color(.clear) - - case .leaf(let leaf): - TerminalSplitLeaf( - leaf: leaf, - neighbors: .empty, - node: $node - ) - - case .split(let container): - TerminalSplitContainer( - neighbors: .empty, - node: $node, - container: container - ) - .onReceive(pubZoom) { onZoom(notification: $0) } - } - } - .id(node) // Needed for change detection on node - } else { - // On these events we want to reset the split state and call it. - let pubSplit = center.publisher(for: Notification.ghosttyNewSplit, object: zoomedSurface!) - let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: zoomedSurface!) - let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: zoomedSurface!) - - ZStack {} - .onReceive(pubZoom) { onZoomReset(notification: $0) } - .onReceive(pubSplit) { onZoomReset(notification: $0) } - .onReceive(pubClose) { onZoomReset(notification: $0) } - .onReceive(pubFocus) { onZoomReset(notification: $0) } - } - } - - func onZoom(notification: SwiftUI.Notification) { - // Our node must be split to receive zooms. You can't zoom an unsplit terminal. - if case .leaf = node { - preconditionFailure("TerminalSplitRoom must not be zoom-able if no splits exist") - } - - // Make sure the notification has a surface and that this window owns the surface. - guard let surfaceView = notification.object as? SurfaceView else { return } - guard node?.contains(view: surfaceView) ?? false else { return } - - // We are in the zoomed state. - zoomedSurface = surfaceView - - // See onZoomReset, same logic. - DispatchQueue.main.async { Ghostty.moveFocus(to: surfaceView) } - } - - func onZoomReset(notification: SwiftUI.Notification) { - // Make sure the notification has a surface and that this window owns the surface. - guard let surfaceView = notification.object as? SurfaceView else { return } - guard zoomedSurface == surfaceView else { return } - - // We are now unzoomed - zoomedSurface = nil - - // We need to stay focused on this view, but the view is going to change - // superviews. We need to do this async so it happens on the next event loop - // tick. - DispatchQueue.main.async { - Ghostty.moveFocus(to: surfaceView) - - // If the notification is not a toggle zoom notification, we want to re-publish - // it after a short delay so that the split tree has a chance to re-establish - // so the proper view gets this notification. - if (notification.name != Notification.didToggleSplitZoom) { - // We have to wait ANOTHER tick since we just established. - DispatchQueue.main.async { - NotificationCenter.default.post(notification) - } - } - } - } - } - - /// A noSplit leaf node of a split tree. - private struct TerminalSplitLeaf: View { - /// The leaf to draw the surface for. - let leaf: SplitNode.Leaf - - /// The neighbors, used for navigation. - let neighbors: SplitNode.Neighbors - - /// The SplitNode that the leaf belongs to. This will be set to nil when leaf is closed. - @Binding var node: SplitNode? - - var body: some View { - let center = NotificationCenter.default - let pub = center.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface) - let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface) - let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: leaf.surface) - let pubResize = center.publisher(for: Notification.didResizeSplit, object: leaf.surface) - - InspectableSurface(surfaceView: leaf.surface, isSplit: !neighbors.isEmpty()) - .onReceive(pub) { onNewSplit(notification: $0) } - .onReceive(pubClose) { onClose(notification: $0) } - .onReceive(pubFocus) { onMoveFocus(notification: $0) } - .onReceive(pubResize) { onResize(notification: $0) } - } - - private func onClose(notification: SwiftUI.Notification) { - var processAlive = false - if let valueAny = notification.userInfo?["process_alive"] { - if let value = valueAny as? Bool { - processAlive = value - } - } - - // If the child process is not alive, then we exit immediately - guard processAlive else { - node = nil - return - } - - // If we don't have a window to attach our modal to, we also exit immediately. - // This should NOT happen. - guard let window = leaf.surface.window else { - node = nil - return - } - - // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog - // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that - // confirmationDialog allows the user to Cmd-W close the alert, but when doing - // so SwiftUI does not update any of the bindings to note that window is no longer - // being shown, and provides no callback to detect this. - let alert = NSAlert() - alert.messageText = "Close Terminal?" - alert.informativeText = "The terminal still has a running process. If you close the " + - "terminal the process will be killed." - alert.addButton(withTitle: "Close the Terminal") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window, completionHandler: { response in - switch (response) { - case .alertFirstButtonReturn: - alert.window.orderOut(nil) - node = nil - - default: - break - } - }) - } - - private func onNewSplit(notification: SwiftUI.Notification) { - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? SurfaceConfiguration - - // Determine our desired direction - guard let directionAny = notification.userInfo?["direction"] else { return } - guard let direction = directionAny as? ghostty_action_split_direction_e else { return } - let splitDirection: SplitViewDirection - let swap: Bool - switch (direction) { - case GHOSTTY_SPLIT_DIRECTION_RIGHT: - splitDirection = .horizontal - swap = false - case GHOSTTY_SPLIT_DIRECTION_LEFT: - splitDirection = .horizontal - swap = true - case GHOSTTY_SPLIT_DIRECTION_DOWN: - splitDirection = .vertical - swap = false - case GHOSTTY_SPLIT_DIRECTION_UP: - splitDirection = .vertical - swap = true - - default: - return - } - - // Setup our new container since we are now split - let container = SplitNode.Container(from: leaf, direction: splitDirection, baseConfig: config) - - // Change the parent node. This will trigger the parent to relayout our views. - node = .split(container) - - // See moveFocus comment, we have to run this whenever split changes. - Ghostty.moveFocus(to: container.bottomRight.preferredFocus(), from: node!.preferredFocus()) - - // If we are swapping, swap now. We do this after our focus event - // so that focus is in the right place. - if swap { - container.swap() - } - } - - /// This handles the event to move the split focus (i.e. previous/next) from a keyboard event. - private func onMoveFocus(notification: SwiftUI.Notification) { - // Determine our desired direction - guard let directionAny = notification.userInfo?[Notification.SplitDirectionKey] else { return } - guard let direction = directionAny as? SplitFocusDirection else { return } - - // Find the next surface to move to. In most cases this should be - // finding the neighbor in provided direction, and focus it. When - // the neighbor cannot be found based on next or previous direction, - // this would instead search for first or last leaf and focus it - // instead, giving the wrap around effect. - // When other directions are provided, this can be nil, and early - // returned. - guard let nextSurface = neighbors.get(direction: direction)?.preferredFocus(direction) - ?? node?.firstOrLast(direction)?.surface else { return } - - Ghostty.moveFocus( - to: nextSurface - ) - } - - /// Handle a resize event. - private func onResize(notification: SwiftUI.Notification) { - // If this leaf is not part of a split then there is nothing to do - guard let parent = leaf.parent else { return } - - guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return } - guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return } - - guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return } - guard let amount = amountAny as? UInt16 else { return } - - parent.resize(direction: direction, amount: amount) - } - } - - /// This represents a split view that is in the horizontal or vertical split state. - private struct TerminalSplitContainer: View { - @EnvironmentObject var ghostty: Ghostty.App - - let neighbors: SplitNode.Neighbors - @Binding var node: SplitNode? - @StateObject var container: SplitNode.Container - - var body: some View { - SplitView( - container.direction, - $container.split, - dividerColor: ghostty.config.splitDividerColor, - resizeIncrements: .init(width: 1, height: 1), - resizePublisher: container.resizeEvent, - left: { - let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.down - - TerminalSplitNested( - node: closeableTopLeft(), - neighbors: neighbors.update([ - neighborKey: container.bottomRight, - \.next: container.bottomRight, - ]) - ) - }, right: { - let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.left : \.up - - TerminalSplitNested( - node: closeableBottomRight(), - neighbors: neighbors.update([ - neighborKey: container.topLeft, - \.previous: container.topLeft, - ]) - ) - }) - } - - private func closeableTopLeft() -> Binding { - return .init(get: { - container.topLeft - }, set: { newValue in - if let newValue { - container.topLeft = newValue - return - } - - // Closing - container.topLeft.close() - node = container.bottomRight - - switch (node) { - case .leaf(let l): - l.parent = container.parent - case .split(let c): - c.parent = container.parent - case .none: - break - } - - DispatchQueue.main.async { - Ghostty.moveFocus( - to: container.bottomRight.preferredFocus(), - from: container.topLeft.preferredFocus() - ) - } - }) - } - - private func closeableBottomRight() -> Binding { - return .init(get: { - container.bottomRight - }, set: { newValue in - if let newValue { - container.bottomRight = newValue - return - } - - // Closing - container.bottomRight.close() - node = container.topLeft - - switch (node) { - case .leaf(let l): - l.parent = container.parent - case .split(let c): - c.parent = container.parent - case .none: - break - } - - DispatchQueue.main.async { - Ghostty.moveFocus( - to: container.topLeft.preferredFocus(), - from: container.bottomRight.preferredFocus() - ) - } - }) - } - } - - - /// This is like TerminalSplitRoot, but... not the root. This renders a SplitNode in any state but - /// requires there be a binding to the parent node. - private struct TerminalSplitNested: View { - @Binding var node: SplitNode? - let neighbors: SplitNode.Neighbors - - var body: some View { - Group { - switch (node) { - case nil: - Color(.clear) - - case .leaf(let leaf): - TerminalSplitLeaf( - leaf: leaf, - neighbors: neighbors, - node: $node - ) - - case .split(let container): - TerminalSplitContainer( - neighbors: neighbors, - node: $node, - container: container - ) - } - } - .id(node) - } - } - - /// When changing the split state, or going full screen (native or non), the terminal view - /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't - /// figure it out so we're going to do this hacky thing to bring focus back to the terminal - /// that should have it. - static func moveFocus( - to: SurfaceView, - from: SurfaceView? = nil, - delay: TimeInterval? = nil - ) { - // The whole delay machinery is a bit of a hack to work around a - // situation where the window is destroyed and the surface view - // will never be attached to a window. Realistically, we should - // handle this upstream but we also don't want this function to be - // a source of infinite loops. - - // Our max delay before we give up - let maxDelay: TimeInterval = 0.5 - guard (delay ?? 0) < maxDelay else { return } - - // We start at a 50 millisecond delay and do a doubling backoff - let nextDelay: TimeInterval = if let delay { - delay * 2 - } else { - // 100 milliseconds - 0.05 - } - - let work: DispatchWorkItem = .init { - // If the callback runs before the surface is attached to a view - // then the window will be nil. We just reschedule in that case. - guard let window = to.window else { - moveFocus(to: to, from: from, delay: nextDelay) - return - } - - // If we had a previously focused node and its not where we're sending - // focus, make sure that we explicitly tell it to lose focus. In theory - // we should NOT have to do this but the focus callback isn't getting - // called for some reason. - if let from = from { - _ = from.resignFirstResponder() - } - - window.makeFirstResponder(to) - } - - let queue = DispatchQueue.main - if let delay { - queue.asyncAfter(deadline: .now() + delay, execute: work) - } else { - queue.async(execute: work) - } - } -} diff --git a/macos/Sources/Ghostty/InspectorView.swift b/macos/Sources/Ghostty/InspectorView.swift index a6e80bd47..8008e49c2 100644 --- a/macos/Sources/Ghostty/InspectorView.swift +++ b/macos/Sources/Ghostty/InspectorView.swift @@ -337,9 +337,9 @@ extension Ghostty { private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { guard let inspector = self.inspector else { return } - guard let key = Ghostty.keycodeToKey[event.keyCode] else { return } + guard let key = Ghostty.Input.Key(keyCode: event.keyCode) else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_inspector_key(inspector, action, key, mods) + ghostty_inspector_key(inspector, action, key.cKey, mods) } // MARK: NSTextInputClient diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 30d5573df..125a09825 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -19,6 +19,15 @@ struct Ghostty { static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show" } +// MARK: C Extensions + +/// A command is fully self-contained so it is Sendable. +extension ghostty_command_s: @unchecked @retroactive Sendable {} + +/// A surface is sendable because it is just a reference type. Using the surface in parameters +/// may be unsafe but the value itself is safe to send across threads. +extension ghostty_surface_t: @unchecked @retroactive Sendable {} + // MARK: Build Info extension Ghostty { @@ -239,6 +248,12 @@ extension Ghostty { case chrome } + /// Enum for the macos-window-buttons config option + enum MacOSWindowButtons: String { + case visible + case hidden + } + /// Enum for the macos-titlebar-proxy-icon config option enum MacOSTitlebarProxyIcon: String { case visible diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 1e9a4cfef..aa4de5178 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -59,7 +59,7 @@ extension Ghostty { var title: String { var result = surfaceView.title - if (surfaceView.bell) { + if (surfaceView.bell && ghostty.config.bellFeatures.contains(.title)) { result = "🔔 \(result)" } @@ -79,7 +79,7 @@ extension Ghostty { let pubResign = center.publisher(for: NSWindow.didResignKeyNotification) #endif - Surface(view: surfaceView, size: geo.size) + SurfaceRepresentable(view: surfaceView, size: geo.size) .focused($surfaceFocus) .focusedValue(\.ghosttySurfacePwd, surfaceView.pwd) .focusedValue(\.ghosttySurfaceView, surfaceView) @@ -301,8 +301,12 @@ extension Ghostty { if let instant = focusInstant { let d = instant.duration(to: ContinuousClock.now) if (d < .milliseconds(500)) { - // Avoid this size completely. - lastSize = geoSize + // Avoid this size completely. We can't set values during + // view updates so we have to defer this to another tick. + DispatchQueue.main.async { + lastSize = geoSize + } + return true; } } @@ -377,7 +381,7 @@ extension Ghostty { /// We just wrap an AppKit NSView here at the moment so that we can behave as low level as possible /// since that is what the Metal renderer in Ghostty expects. In the future, it may make more sense to /// wrap an MTKView and use that, but for legacy reasons we didn't do that to begin with. - struct Surface: OSViewRepresentable { + struct SurfaceRepresentable: OSViewRepresentable { /// The view to render for the terminal surface. let view: SurfaceView @@ -414,28 +418,48 @@ extension Ghostty { /// Explicit command to set var command: String? = nil + + /// Environment variables to set for the terminal + var environmentVariables: [String: String] = [:] + + /// Extra input to send as stdin + var initialInput: String? = nil init() {} init(from config: ghostty_surface_config_s) { self.fontSize = config.font_size - self.workingDirectory = String.init(cString: config.working_directory, encoding: .utf8) - self.command = String.init(cString: config.command, encoding: .utf8) + if let workingDirectory = config.working_directory { + self.workingDirectory = String.init(cString: workingDirectory, encoding: .utf8) + } + if let command = config.command { + self.command = String.init(cString: command, encoding: .utf8) + } + + // Convert the C env vars to Swift dictionary + if config.env_var_count > 0, let envVars = config.env_vars { + for i in 0.. ghostty_surface_config_s { + /// Provides a C-compatible ghostty configuration within a closure. The configuration + /// and all its string pointers are only valid within the closure. + func withCValue(view: SurfaceView, _ body: (inout ghostty_surface_config_s) throws -> T) rethrows -> T { var config = ghostty_surface_config_new() config.userdata = Unmanaged.passUnretained(view).toOpaque() - #if os(macOS) +#if os(macOS) config.platform_tag = GHOSTTY_PLATFORM_MACOS config.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( nsview: Unmanaged.passUnretained(view).toOpaque() )) config.scale_factor = NSScreen.main!.backingScaleFactor - - #elseif os(iOS) +#elseif os(iOS) config.platform_tag = GHOSTTY_PLATFORM_IOS config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s( uiview: Unmanaged.passUnretained(view).toOpaque() @@ -445,21 +469,108 @@ extension Ghostty { // probably set this to some default, then modify the scale factor through // libghostty APIs when a UIView is attached to a window/scene. TODO. config.scale_factor = UIScreen.main.scale - #else - #error("unsupported target") - #endif +#else +#error("unsupported target") +#endif - if let fontSize = fontSize { config.font_size = fontSize } - if let workingDirectory = workingDirectory { - config.working_directory = (workingDirectory as NSString).utf8String - } - if let command = command { - config.command = (command as NSString).utf8String - } + // Zero is our default value that means to inherit the font size. + config.font_size = fontSize ?? 0 - return config + // Use withCString to ensure strings remain valid for the duration of the closure + return try workingDirectory.withCString { cWorkingDir in + config.working_directory = cWorkingDir + + return try command.withCString { cCommand in + config.command = cCommand + + return try initialInput.withCString { cInput in + config.initial_input = cInput + + // Convert dictionary to arrays for easier processing + let keys = Array(environmentVariables.keys) + let values = Array(environmentVariables.values) + + // Create C strings for all keys and values + return try keys.withCStrings { keyCStrings in + return try values.withCStrings { valueCStrings in + // Create array of ghostty_env_var_s + var envVars = Array() + envVars.reserveCapacity(environmentVariables.count) + for i in 0.. = [] - private(set) var surface: ghostty_surface_t? private var markedText: NSMutableAttributedString private(set) var focused: Bool = true private var prevPressureStage: Int = 0 @@ -132,16 +148,16 @@ extension Ghostty { // by the user, this is set to the prior value (which may be empty, but non-nil). private var titleFromTerminal: String? + // The cached contents of the screen. + private(set) var cachedScreenContents: CachedValue + private(set) var cachedVisibleContents: CachedValue + /// Event monitor (see individual events for why) private var eventMonitor: Any? = nil // We need to support being a first responder so that we can get input events override var acceptsFirstResponder: Bool { return true } - // I don't think we need this but this lets us know we should redraw our layer - // so we'll use that to tell ghostty to refresh. - override var wantsUpdateLayer: Bool { return true } - init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { self.markedText = NSMutableAttributedString() self.uuid = uuid ?? .init() @@ -153,11 +169,59 @@ extension Ghostty { self.derivedConfig = DerivedConfig() } + // We need to initialize this so it does something but we want to set + // it back up later so we can reference `self`. This is a hack we should + // fix at some point. + self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" } + self.cachedVisibleContents = self.cachedScreenContents + // Initialize with some default frame size. The important thing is that this // is non-zero so that our layer bounds are non-zero so that our renderer // can do SOMETHING. super.init(frame: NSMakeRect(0, 0, 800, 600)) + // Our cache of screen data + cachedScreenContents = .init(duration: .milliseconds(500)) { [weak self] in + guard let self else { return "" } + guard let surface = self.surface else { return "" } + var text = ghostty_text_s() + let sel = ghostty_selection_s( + top_left: ghostty_point_s( + tag: GHOSTTY_POINT_SCREEN, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0), + bottom_right: ghostty_point_s( + tag: GHOSTTY_POINT_SCREEN, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0), + rectangle: false) + guard ghostty_surface_read_text(surface, sel, &text) else { return "" } + defer { ghostty_surface_free_text(surface, &text) } + return String(cString: text.text) + } + cachedVisibleContents = .init(duration: .milliseconds(500)) { [weak self] in + guard let self else { return "" } + guard let surface = self.surface else { return "" } + var text = ghostty_text_s() + let sel = ghostty_selection_s( + top_left: ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0), + bottom_right: ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0), + rectangle: false) + guard ghostty_surface_read_text(surface, sel, &text) else { return "" } + defer { ghostty_surface_free_text(surface, &text) } + return String(cString: text.text) + } + // Set a timer to show the ghost emoji after 500ms if no title is set titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in if let self = self, self.title.isEmpty { @@ -222,12 +286,14 @@ extension Ghostty { // Setup our surface. This will also initialize all the terminal IO. let surface_cfg = baseConfig ?? SurfaceConfiguration() - var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) - guard let surface = ghostty_surface_new(app, &surface_cfg_c) else { - self.error = AppError.surfaceCreateError + let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in + ghostty_surface_new(app, &surface_cfg_c) + } + guard let surface = surface else { + self.error = Ghostty.Error.apiFailed return } - self.surface = surface; + self.surfaceModel = Ghostty.Surface(cSurface: surface) // Setup our tracking area so we get mouse moved events updateTrackingAreas() @@ -279,22 +345,9 @@ extension Ghostty { // Remove ourselves from secure input if we have to SecureInput.shared.removeScoped(ObjectIdentifier(self)) - guard let surface = self.surface else { return } - ghostty_surface_free(surface) - } - - /// Close the surface early. This will free the associated Ghostty surface and the view will - /// no longer render. The view can never be used again. This is a way for us to free the - /// Ghostty resources while references may still be held to this view. I've found that SwiftUI - /// tends to hold this view longer than it should so we free the expensive stuff explicitly. - func close() { // Remove any notifications associated with this surface let identifiers = Array(self.notificationIdentifiers) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) - - guard let surface = self.surface else { return } - ghostty_surface_free(surface) - self.surface = nil } func focusDidChange(_ focused: Bool) { @@ -314,6 +367,14 @@ extension Ghostty { // We unset our bell state if we gained focus bell = false + + // Remove any notifications for this surface once we gain focus. + if !notificationIdentifiers.isEmpty { + UNUserNotificationCenter.current() + .removeDeliveredNotifications( + withIdentifiers: Array(notificationIdentifiers)) + self.notificationIdentifiers = [] + } } } @@ -667,11 +728,6 @@ extension Ghostty { setSurfaceSize(width: UInt32(fbFrame.size.width), height: UInt32(fbFrame.size.height)) } - override func updateLayer() { - guard let surface = self.surface else { return } - ghostty_surface_draw(surface); - } - override func mouseDown(with event: NSEvent) { guard let surface = self.surface else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) @@ -745,19 +801,23 @@ extension Ghostty { override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) - guard let surface = self.surface else { return } + 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 mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods) + let mouseEvent = Ghostty.Input.MousePosEvent( + x: pos.x, + y: frame.height - pos.y, + mods: .init(nsFlags: event.modifierFlags) + ) + surfaceModel.sendMousePos(mouseEvent) } override func mouseExited(with event: NSEvent) { - guard let surface = self.surface else { return } + guard let surfaceModel else { return } // If the mouse is being dragged then we don't have to emit // this because we get mouse drag events even if we've already @@ -767,17 +827,25 @@ extension Ghostty { } // Negative values indicate cursor has left the viewport - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_pos(surface, -1, -1, mods) + let mouseEvent = Ghostty.Input.MousePosEvent( + x: -1, + y: -1, + mods: .init(nsFlags: event.modifierFlags) + ) + surfaceModel.sendMousePos(mouseEvent) } override func mouseMoved(with event: NSEvent) { - guard let surface = self.surface else { return } + 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 mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods) + let mouseEvent = Ghostty.Input.MousePosEvent( + x: pos.x, + y: frame.height - pos.y, + mods: .init(nsFlags: event.modifierFlags) + ) + surfaceModel.sendMousePos(mouseEvent) // Handle focus-follows-mouse if let window, @@ -803,16 +871,13 @@ extension Ghostty { } override func scrollWheel(with event: NSEvent) { - guard let surface = self.surface else { return } - - // Builds up the "input.ScrollMods" bitmask - var mods: Int32 = 0 + guard let surfaceModel else { return } var x = event.scrollingDeltaX var y = event.scrollingDeltaY - if event.hasPreciseScrollingDeltas { - mods = 1 - + let precision = event.hasPreciseScrollingDeltas + + if precision { // We do a 2x speed multiplier. This is subjective, it "feels" better to me. x *= 2; y *= 2; @@ -820,29 +885,12 @@ extension Ghostty { // TODO(mitchellh): do we have to scale the x/y here by window scale factor? } - // Determine our momentum value - var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE - switch (event.momentumPhase) { - case .began: - momentum = GHOSTTY_MOUSE_MOMENTUM_BEGAN - case .stationary: - momentum = GHOSTTY_MOUSE_MOMENTUM_STATIONARY - case .changed: - momentum = GHOSTTY_MOUSE_MOMENTUM_CHANGED - case .ended: - momentum = GHOSTTY_MOUSE_MOMENTUM_ENDED - case .cancelled: - momentum = GHOSTTY_MOUSE_MOMENTUM_CANCELLED - case .mayBegin: - momentum = GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN - default: - break - } - - // Pack our momentum value into the mods bitmask - mods |= Int32(momentum.rawValue) << 1 - - ghostty_surface_mouse_scroll(surface, x, y, mods) + let scrollEvent = Ghostty.Input.MouseScrollEvent( + x: x, + y: y, + mods: .init(precision: precision, momentum: .init(event.momentumPhase)) + ) + surfaceModel.sendMouseScroll(scrollEvent) } override func pressureChange(with event: NSEvent) { @@ -1209,11 +1257,10 @@ extension Ghostty { guard let surface = self.surface else { return super.quickLook(with: event) } // Grab the text under the cursor - var info: ghostty_selection_s = ghostty_selection_s(); - let text = String(unsafeUninitializedCapacity: 1000000) { - Int(ghostty_surface_quicklook_word(surface, $0.baseAddress, UInt($0.count), &info)) - } - guard !text.isEmpty else { return super.quickLook(with: event) } + var text = ghostty_text_s() + guard ghostty_surface_quicklook_word(surface, &text) else { return super.quickLook(with: event) } + defer { ghostty_surface_free_text(surface, &text) } + guard text.text_len > 0 else { return super.quickLook(with: event) } // If we can get a font then we use the font. This should always work // since we always have a primary font. The only scenario this doesn't @@ -1230,8 +1277,8 @@ extension Ghostty { } // Ghostty coordinate system is top-left, convert to bottom-left for AppKit - let pt = NSMakePoint(info.tl_px_x, frame.size.height - info.tl_px_y) - let str = NSAttributedString.init(string: text, attributes: attributes) + let pt = NSMakePoint(text.tl_px_x, frame.size.height - text.tl_px_y) + let str = NSAttributedString.init(string: String(cString: text.text), attributes: attributes) self.showDefinition(for: str, at: pt); } @@ -1250,8 +1297,8 @@ extension Ghostty { // In this case, AppKit calls menu BEFORE calling any mouse events. // If mouse capturing is enabled then we never show the context menu // so that we can handle ctrl+left-click in the terminal app. - guard let surface = self.surface else { return nil } - if ghostty_surface_mouse_captured(surface) { + guard let surfaceModel else { return nil } + if surfaceModel.mouseCaptured { return nil } @@ -1261,13 +1308,10 @@ extension Ghostty { // // Note this never sounds a right mouse up event but that's the // same as normal right-click with capturing disabled from AppKit. - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_button( - surface, - GHOSTTY_MOUSE_PRESS, - GHOSTTY_MOUSE_RIGHT, - mods - ) + surfaceModel.sendMouseButton(.init( + action: .press, + button: .right, + mods: .init(nsFlags: event.modifierFlags))) default: return nil @@ -1275,6 +1319,10 @@ extension Ghostty { let menu = NSMenu() + // We just use a floating var so we can easily setup metadata on each item + // in a row without storing it all. + var item: NSMenuItem + // If we have a selection, add copy if self.selectedRange().length > 0 { menu.addItem(withTitle: "Copy", action: #selector(copy(_:)), keyEquivalent: "") @@ -1282,16 +1330,23 @@ extension Ghostty { menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "") menu.addItem(.separator()) - menu.addItem(withTitle: "Split Right", action: #selector(splitRight(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Split Left", action: #selector(splitLeft(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Split Down", action: #selector(splitDown(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Split Up", action: #selector(splitUp(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Split Right", action: #selector(splitRight(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled") + item = menu.addItem(withTitle: "Split Left", action: #selector(splitLeft(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled") + item = menu.addItem(withTitle: "Split Down", action: #selector(splitDown(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled") + item = menu.addItem(withTitle: "Split Up", action: #selector(splitUp(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled") menu.addItem(.separator()) - menu.addItem(withTitle: "Reset Terminal", action: #selector(resetTerminal(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Reset Terminal", action: #selector(resetTerminal(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise") + item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "scope") menu.addItem(.separator()) - menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "pencil.line") return menu } @@ -1396,13 +1451,29 @@ extension Ghostty { trigger: nil ) - UNUserNotificationCenter.current().add(request) { error in + // Note the callback may be executed on a background thread as documented + // so we need @MainActor since we're reading/writing view state. + UNUserNotificationCenter.current().add(request) { @MainActor error in if let error = error { AppDelegate.logger.error("Error scheduling user notification: \(error)") return } + // We need to keep track of this notification so we can remove it + // under certain circumstances self.notificationIdentifiers.insert(uuid) + + // If we're focused then we schedule to remove the notification + // after a few seconds. If we gain focus we automatically remove it + // in focusDidChange. + if (self.focused) { + Task { @MainActor [weak self] in + try await Task.sleep(for: .seconds(3)) + self?.notificationIdentifiers.remove(uuid) + UNUserNotificationCenter.current() + .removeDeliveredNotifications(withIdentifiers: [uuid]) + } + } } } @@ -1439,6 +1510,35 @@ extension Ghostty { self.windowAppearance = .init(ghosttyConfig: config) } } + + // MARK: - Codable + + enum CodingKeys: String, CodingKey { + case pwd + case uuid + } + + required convenience init(from decoder: Decoder) throws { + // Decoding uses the global Ghostty app + guard let del = NSApplication.shared.delegate, + let appDel = del as? AppDelegate, + let app = appDel.ghostty.app else { + throw TerminalRestoreError.delegateInvalid + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid)) + var config = Ghostty.SurfaceConfiguration() + config.workingDirectory = try container.decode(String?.self, forKey: .pwd) + + self.init(app, baseConfig: config, uuid: uuid) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(pwd, forKey: .pwd) + try container.encode(uuid.uuidString, forKey: .uuid) + } } } @@ -1460,9 +1560,10 @@ extension Ghostty.SurfaceView: NSTextInputClient { // Get our range from the Ghostty API. There is a race condition between getting the // range and actually using it since our selection may change but there isn't a good // way I can think of to solve this for AppKit. - var sel: ghostty_selection_s = ghostty_selection_s(); - guard ghostty_surface_selection_info(surface, &sel) else { return NSRange() } - return NSRange(location: Int(sel.offset_start), length: Int(sel.offset_len)) + var text = ghostty_text_s() + guard ghostty_surface_read_selection(surface, &text) else { return NSRange() } + defer { ghostty_surface_free_text(surface, &text) } + return NSRange(location: Int(text.offset_start), length: Int(text.offset_len)) } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { @@ -1500,7 +1601,6 @@ extension Ghostty.SurfaceView: NSTextInputClient { func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { // Ghostty.logger.warning("pressure substring range=\(range) selectedRange=\(self.selectedRange())") guard let surface = self.surface else { return nil } - guard ghostty_surface_has_selection(surface) else { return nil } // If the range is empty then we don't need to return anything guard range.length > 0 else { return nil } @@ -1510,11 +1610,10 @@ extension Ghostty.SurfaceView: NSTextInputClient { // bogus ranges I truly don't understand so we just always return the // attributed string containing our selection which is... weird but works? - // Get our selection. We cap it at 1MB for the purpose of this. This is - // arbitrary. If this is a good reason to increase it I'm happy to. - let v = String(unsafeUninitializedCapacity: 1000000) { - Int(ghostty_surface_selection(surface, $0.baseAddress, UInt($0.count))) - } + // Get our selection text + var text = ghostty_text_s() + guard ghostty_surface_read_selection(surface, &text) else { return nil } + defer { ghostty_surface_free_text(surface, &text) } // If we can get a font then we use the font. This should always work // since we always have a primary font. The only scenario this doesn't @@ -1530,7 +1629,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { font.release() } - return .init(string: v, attributes: attributes) + return .init(string: String(cString: text.text), attributes: attributes) } func characterIndex(for point: NSPoint) -> Int { @@ -1552,12 +1651,15 @@ extension Ghostty.SurfaceView: NSTextInputClient { // point right now. I'm sure I'm missing something fundamental... if range.length > 0 && range != self.selectedRange() { // QuickLook - var sel: ghostty_selection_s = ghostty_selection_s(); - if ghostty_surface_selection_info(surface, &sel) { + var text = ghostty_text_s() + if ghostty_surface_read_selection(surface, &text) { // The -2/+2 here is subjective. QuickLook seems to offset the rectangle // a bit and I think these small adjustments make it look more natural. - x = sel.tl_px_x - 2; - y = sel.tl_px_y + 2; + x = text.tl_px_x - 2; + y = text.tl_px_y + 2; + + // Free our text + ghostty_surface_free_text(surface, &text) } else { ghostty_surface_ime_point(surface, &x, &y) } @@ -1580,7 +1682,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { func insertText(_ string: Any, replacementRange: NSRange) { // We must have an associated event guard NSApp.currentEvent != nil else { return } - guard let surface = self.surface else { return } + guard let surfaceModel else { return } // We want the string view of the any value var chars = "" @@ -1604,13 +1706,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { return } - let len = chars.utf8CString.count - if (len == 0) { return } - - chars.withCString { ptr in - // len includes the null terminator so we do len - 1 - ghostty_surface_text(surface, ptr, UInt(len - 1)) - } + surfaceModel.sendText(chars) } /// This function needs to exist for two reasons: @@ -1683,14 +1779,13 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor { ) -> Bool { guard let surface = self.surface else { return false } - // We currently cap the maximum copy size to 1MB. iTerm2 I believe - // caps theirs at 0.1MB (configurable) so this is probably reasonable. - let v = String(unsafeUninitializedCapacity: 1000000) { - Int(ghostty_surface_selection(surface, $0.baseAddress, UInt($0.count))) - } + // Read the selection + var text = ghostty_text_s() + guard ghostty_surface_read_selection(surface, &text) else { return false } + defer { ghostty_surface_free_text(surface, &text) } pboard.declareTypes([.string], owner: nil) - pboard.setString(v, forType: .string) + pboard.setString(String(cString: text.text), forType: .string) return true } @@ -1782,3 +1877,148 @@ extension Ghostty.SurfaceView { return false } } + +// MARK: Accessibility + +extension Ghostty.SurfaceView { + /// Indicates that this view should be exposed to accessibility tools like VoiceOver. + /// By returning true, we make the terminal surface accessible to screen readers + /// and other assistive technologies. + override func isAccessibilityElement() -> Bool { + return true + } + + /// Defines the accessibility role for this view, which helps assistive technologies + /// understand what kind of content this view contains and how users can interact with it. + override func accessibilityRole() -> NSAccessibility.Role? { + /// We use .textArea because the terminal surface is essentially an editable text area + /// where users can input commands and view output. + return .textArea + } + + override func accessibilityHelp() -> String? { + return "Terminal content area" + } + + override func accessibilityValue() -> Any? { + return cachedScreenContents.get() + } + + /// Returns the range of text that is currently selected in the terminal. + /// This allows VoiceOver and other assistive technologies to understand + /// what text the user has selected. + override func accessibilitySelectedTextRange() -> NSRange { + return selectedRange() + } + + /// Returns the currently selected text as a string. + /// This allows assistive technologies to read the selected content. + override func accessibilitySelectedText() -> String? { + guard let surface = self.surface else { return nil } + + // Attempt to read the selection + var text = ghostty_text_s() + guard ghostty_surface_read_selection(surface, &text) else { return nil } + defer { ghostty_surface_free_text(surface, &text) } + + let str = String(cString: text.text) + return str.isEmpty ? nil : str + } + + /// Returns the number of characters in the terminal content. + /// This helps assistive technologies understand the size of the content. + override func accessibilityNumberOfCharacters() -> Int { + let content = cachedScreenContents.get() + return content.count + } + + /// Returns the visible character range for the terminal. + /// For terminals, we typically show all content as visible. + override func accessibilityVisibleCharacterRange() -> NSRange { + let content = cachedScreenContents.get() + return NSRange(location: 0, length: content.count) + } + + /// Returns the line number for a given character index. + /// This helps assistive technologies navigate by line. + override func accessibilityLine(for index: Int) -> Int { + let content = cachedScreenContents.get() + let substring = String(content.prefix(index)) + return substring.components(separatedBy: .newlines).count - 1 + } + + /// Returns a substring for the given range. + /// This allows assistive technologies to read specific portions of the content. + override func accessibilityString(for range: NSRange) -> String? { + let content = cachedScreenContents.get() + guard let swiftRange = Range(range, in: content) else { return nil } + return String(content[swiftRange]) + } + + /// Returns an attributed string for the given range. + /// + /// Note: right now this only applies font information. One day it'd be nice to extend + /// this to copy styling information as well but we need to augment Ghostty core to + /// expose that. + /// + /// This provides styling information to assistive technologies. + override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? { + guard let surface = self.surface else { return nil } + guard let plainString = accessibilityString(for: range) else { return nil } + + var attributes: [NSAttributedString.Key: Any] = [:] + + // Try to get the font from the surface + if let fontRaw = ghostty_surface_quicklook_font(surface) { + let font = Unmanaged.fromOpaque(fontRaw) + attributes[.font] = font.takeUnretainedValue() + font.release() + } + + return NSAttributedString(string: plainString, attributes: attributes) + } +} + +/// Caches a value for some period of time, evicting it automatically when that time expires. +/// We use this to cache our surface content. This probably should be extracted some day +/// to a more generic helper. +class CachedValue { + private var value: T? + private let fetch: () -> T + private let duration: Duration + private var expiryTask: Task? + + init(duration: Duration, fetch: @escaping () -> T) { + self.duration = duration + self.fetch = fetch + } + + deinit { + expiryTask?.cancel() + } + + func get() -> T { + if let value { + return value + } + + // We don't have a value (or it expired). Fetch and store. + let result = fetch() + let now = ContinuousClock.now + let expires = now + duration + self.value = result + + // Schedule a task to clear the value + expiryTask = Task { [weak self] in + do { + try await Task.sleep(until: expires) + self?.value = nil + self?.expiryTask = nil + } catch { + // Task was cancelled, do nothing + } + } + + return result + } +} diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 8d5b3038f..e88ec82e2 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -57,8 +57,10 @@ extension Ghostty { // Setup our surface. This will also initialize all the terminal IO. let surface_cfg = baseConfig ?? SurfaceConfiguration() - var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) - guard let surface = ghostty_surface_new(app, &surface_cfg_c) else { + let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in + ghostty_surface_new(app, &surface_cfg_c) + } + guard let surface = surface else { // TODO return } diff --git a/macos/Sources/Helpers/Xcode.swift b/macos/Sources/Helpers/AppInfo.swift similarity index 100% rename from macos/Sources/Helpers/Xcode.swift rename to macos/Sources/Helpers/AppInfo.swift diff --git a/macos/Sources/Helpers/ExpiringUndoManager.swift b/macos/Sources/Helpers/ExpiringUndoManager.swift new file mode 100644 index 000000000..5fde0e870 --- /dev/null +++ b/macos/Sources/Helpers/ExpiringUndoManager.swift @@ -0,0 +1,148 @@ +/// An UndoManager subclass that supports registering undo operations that automatically expire after a specified duration. +/// +/// This class extends the standard UndoManager to add time-based expiration for undo operations. +/// When an undo operation expires, it is automatically removed from the undo stack and cannot be invoked. +/// +/// Example usage: +/// ```swift +/// let undoManager = ExpiringUndoManager() +/// undoManager.registerUndo(withTarget: myObject, expiresAfter: .seconds(30)) { target in +/// // Undo operation that expires after 30 seconds +/// target.restorePreviousState() +/// } +/// ``` +class ExpiringUndoManager: UndoManager { + /// The set of expiring targets so we can properly clean them up when removeAllActions + /// is called with the real target. + private lazy var expiringTargets: Set = [] + + /// Registers an undo operation that automatically expires after the specified duration. + /// + /// - Parameters: + /// - target: The target object for the undo operation. The undo operation will be removed + /// if this object is deallocated before the operation is invoked. + /// - duration: The duration after which the undo operation should expire and be removed from the undo stack. + /// - handler: The closure to execute when the undo operation is invoked. The closure receives + /// the target object as its parameter. + func registerUndo( + withTarget target: TargetType, + expiresAfter duration: Duration, + handler: @escaping (TargetType) -> Void + ) { + // Ignore instantly expiring undos + guard duration.timeInterval > 0 else { return } + + // Ignore when undo registration is disabled. UndoManager still lets + // registration happen then cancels later but I was seeing some + // weird behavior with this so let's just guard on it. + guard self.isUndoRegistrationEnabled else { return } + + let expiringTarget = ExpiringTarget( + target, + expiresAfter: duration, + in: self) + expiringTargets.insert(expiringTarget) + + super.registerUndo(withTarget: expiringTarget) { [weak self] expiringTarget in + self?.expiringTargets.remove(expiringTarget) + guard let target = expiringTarget.target as? TargetType else { return } + handler(target) + } + } + + /// Removes all undo and redo operations from the undo manager. + /// + /// This override ensures that all expiring targets are also cleared when + /// the undo manager is reset. + override func removeAllActions() { + super.removeAllActions() + expiringTargets = [] + } + + /// Removes all undo and redo operations involving the specified target. + /// + /// This override ensures that when actions are removed for a target, any associated + /// expiring targets are also properly cleaned up. + /// + /// - Parameter target: The target object whose actions should be removed. + override func removeAllActions(withTarget target: Any) { + // Call super to handle standard removal + super.removeAllActions(withTarget: target) + + // If the target is an expiring target, remove it. + if let expiring = target as? ExpiringTarget { + expiringTargets.remove(expiring) + } else { + // Find and remove any ExpiringTarget instances that wrap this target. + expiringTargets + .filter { $0.target == nil || $0.target === (target as AnyObject) } + .forEach { + // Technically they'll always expire when they get deinitialized + // but we want to make sure it happens right now. + $0.expire() + expiringTargets.remove($0) + } + } + } +} + +/// A target object for ExpiringUndoManager that removes itself from the +/// undo manager after it expires. +/// +/// This class acts as a proxy for the real target object in undo operations. +/// It holds a weak reference to the actual target and automatically removes +/// all associated undo operations when either: +/// - The specified duration expires +/// - The ExpiringTarget instance is deallocated +/// - The expire() method is called manually +private class ExpiringTarget { + /// The actual target object for the undo operation, held weakly to avoid retain cycles. + private(set) weak var target: AnyObject? + + /// Timer that triggers expiration after the specified duration. + private var timer: Timer? + + /// The undo manager from which to remove actions when this target expires. + private weak var undoManager: UndoManager? + + /// Creates an expiring target that will automatically remove undo actions after the specified duration. + /// + /// - Parameters: + /// - target: The target object to hold weakly. + /// - duration: The time after which the target should expire. + /// - undoManager: The UndoManager from which to remove actions when expired. + init(_ target: AnyObject? = nil, expiresAfter duration: Duration, in undoManager: UndoManager) { + self.target = target + self.undoManager = undoManager + self.timer = Timer.scheduledTimer( + withTimeInterval: duration.timeInterval, + repeats: false) { [weak self] _ in + self?.expire() + } + } + + /// Manually expires the target, removing all associated undo actions and invalidating the timer. + /// + /// This method is called automatically when the timer fires, but can also be called manually + /// to expire the target before the timer duration has elapsed. + func expire() { + target = nil + undoManager?.removeAllActions(withTarget: self) + timer?.invalidate() + timer = nil + } + + deinit { + expire() + } +} + +extension ExpiringTarget: Hashable, Equatable { + static func == (lhs: ExpiringTarget, rhs: ExpiringTarget) -> Bool { + return lhs === rhs + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} diff --git a/macos/Sources/Helpers/Extensions/Array+Extension.swift b/macos/Sources/Helpers/Extensions/Array+Extension.swift new file mode 100644 index 000000000..4e8e39918 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Array+Extension.swift @@ -0,0 +1,48 @@ +extension Array { + subscript(safe index: Int) -> Element? { + return indices.contains(index) ? self[index] : nil + } + + /// Returns the index before i, with wraparound. Assumes i is a valid index. + func indexWrapping(before i: Int) -> Int { + if i == 0 { + return count - 1 + } + + return i - 1 + } + + /// Returns the index after i, with wraparound. Assumes i is a valid index. + func indexWrapping(after i: Int) -> Int { + if i == count - 1 { + return 0 + } + + return i + 1 + } +} + +extension Array where Element == String { + /// Executes a closure with an array of C string pointers. + func withCStrings(_ body: ([UnsafePointer?]) throws -> T) rethrows -> T { + // Handle empty array + if isEmpty { + return try body([]) + } + + // Recursive helper to process strings + func helper(index: Int, accumulated: [UnsafePointer?], body: ([UnsafePointer?]) throws -> T) rethrows -> T { + if index == count { + return try body(accumulated) + } + + return try self[index].withCString { cStr in + var newAccumulated = accumulated + newAccumulated.append(cStr) + return try helper(index: index + 1, accumulated: newAccumulated, body: body) + } + } + + return try helper(index: 0, accumulated: [], body: body) + } +} diff --git a/macos/Sources/Helpers/Extensions/Double+Extension.swift b/macos/Sources/Helpers/Extensions/Double+Extension.swift new file mode 100644 index 000000000..8d1151bac --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Double+Extension.swift @@ -0,0 +1,5 @@ +extension Double { + func clamped(to range: ClosedRange) -> Double { + return Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/macos/Sources/Helpers/Extensions/Duration+Extension.swift b/macos/Sources/Helpers/Extensions/Duration+Extension.swift new file mode 100644 index 000000000..43eca6b79 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Duration+Extension.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Duration { + var timeInterval: TimeInterval { + return TimeInterval(self.components.seconds) + + TimeInterval(self.components.attoseconds) / 1_000_000_000_000_000_000 + } +} diff --git a/macos/Sources/Helpers/EventModifiers+Extension.swift b/macos/Sources/Helpers/Extensions/EventModifiers+Extension.swift similarity index 100% rename from macos/Sources/Helpers/EventModifiers+Extension.swift rename to macos/Sources/Helpers/Extensions/EventModifiers+Extension.swift diff --git a/macos/Sources/Helpers/KeyboardShortcut+Extension.swift b/macos/Sources/Helpers/Extensions/KeyboardShortcut+Extension.swift similarity index 100% rename from macos/Sources/Helpers/KeyboardShortcut+Extension.swift rename to macos/Sources/Helpers/Extensions/KeyboardShortcut+Extension.swift diff --git a/macos/Sources/Helpers/NSAppearance+Extension.swift b/macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSAppearance+Extension.swift rename to macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift diff --git a/macos/Sources/Helpers/NSApplication+Extension.swift b/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift similarity index 99% rename from macos/Sources/Helpers/NSApplication+Extension.swift rename to macos/Sources/Helpers/Extensions/NSApplication+Extension.swift index d8e41523a..0bc79fb6a 100644 --- a/macos/Sources/Helpers/NSApplication+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift @@ -1,3 +1,4 @@ +import AppKit import Cocoa // MARK: Presentation Options diff --git a/macos/Sources/Helpers/NSImage+Extension.swift b/macos/Sources/Helpers/Extensions/NSImage+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSImage+Extension.swift rename to macos/Sources/Helpers/Extensions/NSImage+Extension.swift diff --git a/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift new file mode 100644 index 000000000..e512904ef --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift @@ -0,0 +1,11 @@ +import AppKit + +extension NSMenuItem { + /// Sets the image property from a symbol if we want images on our menu items. + func setImageIfDesired(systemSymbolName symbol: String) { + // We only set on macOS 26 when icons on menu items became the norm. + if #available(macOS 26, *) { + image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) + } + } +} diff --git a/macos/Sources/Helpers/NSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSPasteboard+Extension.swift rename to macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift diff --git a/macos/Sources/Helpers/NSScreen+Extension.swift b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSScreen+Extension.swift rename to macos/Sources/Helpers/Extensions/NSScreen+Extension.swift diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift new file mode 100644 index 000000000..fb209e4ac --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -0,0 +1,221 @@ +import AppKit +import SwiftUI + +extension NSView { + /// Returns true if this view is currently in the responder chain + var isInResponderChain: Bool { + var responder = window?.firstResponder + while let currentResponder = responder { + if currentResponder === self { + return true + } + responder = currentResponder.nextResponder + } + + return false + } +} + +// MARK: Screenshot + +extension NSView { + /// Take a screenshot of just this view. + func screenshot() -> NSImage? { + guard let bitmapRep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil } + cacheDisplay(in: bounds, to: bitmapRep) + let image = NSImage(size: bounds.size) + image.addRepresentation(bitmapRep) + return image + } + + func screenshot() -> Image? { + guard let nsImage: NSImage = self.screenshot() else { return nil } + return Image(nsImage: nsImage) + } +} + +// MARK: View Traversal and Search + +extension NSView { + /// Returns the absolute root view by walking up the superview chain. + var rootView: NSView { + var root: NSView = self + while let superview = root.superview { + root = superview + } + return root + } + + /// Checks if a view contains another view in its hierarchy. + func contains(_ view: NSView) -> Bool { + if self == view { + return true + } + + for subview in subviews { + if subview.contains(view) { + return true + } + } + + return false + } + + /// Checks if the view contains the given class in its hierarchy. + func contains(className name: String) -> Bool { + if String(describing: type(of: self)) == name { + return true + } + + for subview in subviews { + if subview.contains(className: name) { + return true + } + } + + return false + } + + /// Finds the superview with the given class name. + func firstSuperview(withClassName name: String) -> NSView? { + guard let superview else { return nil } + if String(describing: type(of: superview)) == name { + return superview + } + + return superview.firstSuperview(withClassName: name) + } + + /// Recursively finds and returns the first descendant view that has the given class name. + func firstDescendant(withClassName name: String) -> NSView? { + for subview in subviews { + if String(describing: type(of: subview)) == name { + return subview + } else if let found = subview.firstDescendant(withClassName: name) { + return found + } + } + + return nil + } + + /// Recursively finds and returns descendant views that have the given class name. + func descendants(withClassName name: String) -> [NSView] { + var result = [NSView]() + + for subview in subviews { + if String(describing: type(of: subview)) == name { + result.append(subview) + } + + result += subview.descendants(withClassName: name) + } + + return result + } + + /// Recursively finds and returns the first descendant view that has the given identifier. + func firstDescendant(withID id: String) -> NSView? { + for subview in subviews { + if subview.identifier == NSUserInterfaceItemIdentifier(id) { + return subview + } else if let found = subview.firstDescendant(withID: id) { + return found + } + } + + return nil + } + + /// Finds and returns the first view with the given class name starting from the absolute root of the view hierarchy. + /// This includes private views like title bar views. + func firstViewFromRoot(withClassName name: String) -> NSView? { + let root = rootView + + // Check if the root view itself matches + if String(describing: type(of: root)) == name { + return root + } + + // Otherwise search descendants + return root.firstDescendant(withClassName: name) + } +} + +// MARK: Debug + +extension NSView { + /// Prints the view hierarchy from the root in a tree-like ASCII format. + /// + /// I need this because the "Capture View Hierarchy" was broken under some scenarios in + /// Xcode 26 (FB17912569). But, I kept it around because it might be useful to print out + /// the view hierarchy without halting the program. + func printViewHierarchy() { + let root = rootView + print("View Hierarchy from Root:") + print(root.viewHierarchyDescription()) + } + + /// Returns a string representation of the view hierarchy in a tree-like format. + func viewHierarchyDescription(indent: String = "", isLast: Bool = true) -> String { + var result = "" + + // Add the tree branch characters + result += indent + if !indent.isEmpty { + result += isLast ? "└── " : "├── " + } + + // Add the class name and optional identifier + let className = String(describing: type(of: self)) + result += className + + // Add identifier if present + if let identifier = self.identifier { + result += " (id: \(identifier.rawValue))" + } + + // Add frame info + result += " [frame: \(frame)]" + + // Add visual properties + var properties: [String] = [] + + // Hidden status + if isHidden { + properties.append("hidden") + } + + // Opaque status + properties.append(isOpaque ? "opaque" : "transparent") + + // Layer backing + if wantsLayer { + properties.append("layer-backed") + if let bgColor = layer?.backgroundColor { + let color = NSColor(cgColor: bgColor) + if let rgb = color?.usingColorSpace(.deviceRGB) { + properties.append(String(format: "bg:rgba(%.0f,%.0f,%.0f,%.2f)", + rgb.redComponent * 255, + rgb.greenComponent * 255, + rgb.blueComponent * 255, + rgb.alphaComponent)) + } else { + properties.append("bg:\(bgColor)") + } + } + } + + result += " [\(properties.joined(separator: ", "))]" + result += "\n" + + // Process subviews + for (index, subview) in subviews.enumerated() { + let isLastSubview = index == subviews.count - 1 + let newIndent = indent + (isLast ? " " : "│ ") + result += subview.viewHierarchyDescription(indent: newIndent, isLast: isLastSubview) + } + + return result + } +} diff --git a/macos/Sources/Helpers/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift similarity index 67% rename from macos/Sources/Helpers/NSWindow+Extension.swift rename to macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index 06a9fa4e0..f9ed364aa 100644 --- a/macos/Sources/Helpers/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -9,4 +9,10 @@ extension NSWindow { guard windowNumber > 0 else { return nil } return CGWindowID(windowNumber) } + + /// True if this is the first window in the tab group. + var isFirstWindowInTabGroup: Bool { + guard let firstWindow = tabGroup?.windows.first else { return true } + return firstWindow === self + } } diff --git a/macos/Sources/Helpers/OSColor+Extension.swift b/macos/Sources/Helpers/Extensions/OSColor+Extension.swift similarity index 100% rename from macos/Sources/Helpers/OSColor+Extension.swift rename to macos/Sources/Helpers/Extensions/OSColor+Extension.swift diff --git a/macos/Sources/Helpers/Extensions/Optional+Extension.swift b/macos/Sources/Helpers/Extensions/Optional+Extension.swift new file mode 100644 index 000000000..a844c0fe9 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Optional+Extension.swift @@ -0,0 +1,10 @@ +extension Optional where Wrapped == String { + /// Executes a closure with a C string pointer, handling nil gracefully. + func withCString(_ body: (UnsafePointer?) throws -> T) rethrows -> T { + if let string = self { + return try string.withCString(body) + } else { + return try body(nil) + } + } +} diff --git a/macos/Sources/Helpers/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift similarity index 100% rename from macos/Sources/Helpers/String+Extension.swift rename to macos/Sources/Helpers/Extensions/String+Extension.swift diff --git a/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift b/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift new file mode 100644 index 000000000..6c7c1e9f1 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift @@ -0,0 +1,20 @@ +import Foundation + +extension UndoManager { + /// A Boolean value that indicates whether the undo manager is currently performing + /// either an undo or redo operation. + var isUndoingOrRedoing: Bool { + isUndoing || isRedoing + } + + /// Temporarily disables undo registration while executing the provided handler. + /// + /// This method provides a convenient way to perform operations without recording them + /// in the undo stack. It ensures that undo registration is properly re-enabled even + /// if the handler throws an error. + func disableUndoRegistration(handler: () -> Void) { + disableUndoRegistration() + handler() + enableUndoRegistration() + } +} diff --git a/macos/Sources/Helpers/View+Extension.swift b/macos/Sources/Helpers/Extensions/View+Extension.swift similarity index 100% rename from macos/Sources/Helpers/View+Extension.swift rename to macos/Sources/Helpers/Extensions/View+Extension.swift diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 6094bf844..f3940a9aa 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -45,10 +45,6 @@ protocol FullscreenDelegate: AnyObject { func fullscreenDidChange() } -extension FullscreenDelegate { - func fullscreenDidChange() {} -} - /// The base class for fullscreen implementations, cannot be used as a FullscreenStyle on its own. class FullscreenBase { let window: NSWindow @@ -78,10 +74,12 @@ class FullscreenBase { } @objc private func didEnterFullScreenNotification(_ notification: Notification) { + NotificationCenter.default.post(name: .fullscreenDidEnter, object: self) delegate?.fullscreenDidChange() } @objc private func didExitFullScreenNotification(_ notification: Notification) { + NotificationCenter.default.post(name: .fullscreenDidExit, object: self) delegate?.fullscreenDidChange() } } @@ -150,6 +148,26 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { private var savedState: SavedState? + required init?(_ window: NSWindow) { + super.init(window) + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowWillCloseNotification), + name: NSWindow.willCloseNotification, + object: window) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func windowWillCloseNotification(_ notification: Notification) { + // When the window closes we need to explicitly exit non-native fullscreen + // otherwise some state like the menu bar can remain hidden. + exit() + } + func enter() { // If we are in fullscreen we don't do it again. guard !isFullscreen else { return } @@ -218,6 +236,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.window.makeFirstResponder(firstResponder) } + NotificationCenter.default.post(name: .fullscreenDidEnter, object: self) self.delegate?.fullscreenDidChange() } } @@ -246,13 +265,24 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { window.styleMask = savedState.styleMask window.setFrame(window.frameRect(forContentRect: savedState.contentFrame), display: true) - // This is a hack that I want to remove from this but for now, we need to - // fix up the titlebar tabs here before we do everything below. - if let window = window as? TerminalWindow, - window.titlebarTabs { - window.titlebarTabs = true + // Removing the "titled" style also derefs all our accessory view controllers + // so we need to restore those. + for c in savedState.titlebarAccessoryViewControllers { + // Restoring the tab bar causes all sorts of problems. Its best to just ignore it, + // even though this is kind of a hack. + if let window = window as? TerminalWindow, window.isTabBar(c) { + continue + } + + if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil { + window.addTitlebarAccessoryViewController(c) + } } + // Removing "titled" also clears our toolbar + window.toolbar = savedState.toolbar + window.toolbarStyle = savedState.toolbarStyle + // If the window was previously in a tab group that isn't empty now, // we re-add it. We have to do this because our process of doing non-native // fullscreen removes the window from the tab group. @@ -283,6 +313,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { window.makeKeyAndOrderFront(nil) // Notify the delegate + NotificationCenter.default.post(name: .fullscreenDidExit, object: self) self.delegate?.fullscreenDidChange() } @@ -360,6 +391,9 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { let tabGroupIndex: Int? let contentFrame: NSRect let styleMask: NSWindow.StyleMask + let toolbar: NSToolbar? + let toolbarStyle: NSWindow.ToolbarStyle + let titlebarAccessoryViewControllers: [NSTitlebarAccessoryViewController] let dock: Bool let menu: Bool @@ -371,6 +405,9 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.tabGroupIndex = window.tabGroup?.windows.firstIndex(of: window) self.contentFrame = window.convertToScreen(contentView.frame) self.styleMask = window.styleMask + self.toolbar = window.toolbar + self.toolbarStyle = window.toolbarStyle + self.titlebarAccessoryViewControllers = window.titlebarAccessoryViewControllers self.dock = window.screen?.hasDock ?? false if let cgWindowId = window.cgWindowId { @@ -402,3 +439,8 @@ class NonNativeFullscreenVisibleMenu: NonNativeFullscreen { class NonNativeFullscreenPaddedNotch: NonNativeFullscreen { override var properties: Properties { Properties(paddedNotch: true) } } + +extension Notification.Name { + static let fullscreenDidEnter = Notification.Name("com.mitchellh.fullscreenDidEnter") + static let fullscreenDidExit = Notification.Name("com.mitchellh.fullscreenDidExit") +} diff --git a/macos/Sources/Helpers/NSView+Extension.swift b/macos/Sources/Helpers/NSView+Extension.swift deleted file mode 100644 index b9234a49a..000000000 --- a/macos/Sources/Helpers/NSView+Extension.swift +++ /dev/null @@ -1,44 +0,0 @@ -import AppKit - -extension NSView { - /// Recursively finds and returns the first descendant view that has the given class name. - func firstDescendant(withClassName name: String) -> NSView? { - for subview in subviews { - if String(describing: type(of: subview)) == name { - return subview - } else if let found = subview.firstDescendant(withClassName: name) { - return found - } - } - - return nil - } - - /// Recursively finds and returns descendant views that have the given class name. - func descendants(withClassName name: String) -> [NSView] { - var result = [NSView]() - - for subview in subviews { - if String(describing: type(of: subview)) == name { - result.append(subview) - } - - result += subview.descendants(withClassName: name) - } - - return result - } - - /// Recursively finds and returns the first descendant view that has the given identifier. - func firstDescendant(withID id: String) -> NSView? { - for subview in subviews { - if subview.identifier == NSUserInterfaceItemIdentifier(id) { - return subview - } else if let found = subview.firstDescendant(withID: id) { - return found - } - } - - return nil - } -} diff --git a/macos/Sources/Helpers/PermissionRequest.swift b/macos/Sources/Helpers/PermissionRequest.swift new file mode 100644 index 000000000..9c16c7163 --- /dev/null +++ b/macos/Sources/Helpers/PermissionRequest.swift @@ -0,0 +1,213 @@ +import AppKit +import Foundation + +/// Displays a permission request dialog with optional caching of user decisions +class PermissionRequest { + /// Specifies how long a permission decision should be cached + enum AllowDuration { + case once + case forever + case duration(Duration) + } + + /// Shows a permission request dialog with customizable caching behavior + /// - Parameters: + /// - key: Unique identifier for storing/retrieving cached decisions in UserDefaults + /// - message: The message to display in the alert dialog + /// - allowText: Custom text for the allow button (defaults to "Allow") + /// - allowDuration: If provided, automatically cache "Allow" responses for this duration + /// - rememberDuration: If provided, shows a checkbox to remember the decision for this duration + /// - window: If provided, shows the alert as a sheet attached to this window + /// - completion: Called with the user's decision (true for allow, false for deny) + /// + /// Caching behavior: + /// - If rememberDuration is provided and user checks "Remember my decision", both allow/deny are cached for that duration + /// - If allowDuration is provided and user selects allow (without checkbox), decision is cached for that duration + /// - Cached decisions are automatically returned without showing the dialog + @MainActor + static func show( + _ key: String, + message: String, + informative: String = "", + allowText: String = "Allow", + allowDuration: AllowDuration = .once, + rememberDuration: Duration? = .seconds(86400), + window: NSWindow? = nil, + completion: @escaping (Bool) -> Void + ) { + // Check if we have a stored decision that hasn't expired + if let storedResult = getStoredResult(for: key) { + completion(storedResult) + return + } + + let alert = NSAlert() + alert.messageText = message + alert.informativeText = informative + alert.alertStyle = .informational + + // Add buttons (they appear in reverse order) + alert.addButton(withTitle: allowText) + alert.addButton(withTitle: "Don't Allow") + + // Create checkbox for remembering if duration is provided + var checkbox: NSButton? + if let rememberDuration = rememberDuration { + let checkboxTitle = formatRememberText(for: rememberDuration) + checkbox = NSButton( + checkboxWithTitle: checkboxTitle, + target: nil, + action: nil) + checkbox!.state = .off + + // Set checkbox as accessory view + alert.accessoryView = checkbox + } + + // Show the alert + if let window = window { + alert.beginSheetModal(for: window) { response in + handleResponse(response, rememberDecision: checkbox?.state == .on, key: key, allowDuration: allowDuration, rememberDuration: rememberDuration, completion: completion) + } + } else { + let response = alert.runModal() + handleResponse(response, rememberDecision: checkbox?.state == .on, key: key, allowDuration: allowDuration, rememberDuration: rememberDuration, completion: completion) + } + } + + /// Handles the alert response and processes caching logic + /// - Parameters: + /// - response: The alert response from the user + /// - rememberDecision: Whether the remember checkbox was checked + /// - key: The UserDefaults key for caching + /// - allowDuration: Optional duration for auto-caching allow responses + /// - rememberDuration: Optional duration for the remember checkbox + /// - completion: Completion handler to call with the result + private static func handleResponse( + _ response: NSApplication.ModalResponse, + rememberDecision: Bool, + key: String, + allowDuration: AllowDuration, + rememberDuration: Duration?, + completion: @escaping (Bool) -> Void) { + + let result: Bool + switch response { + case .alertFirstButtonReturn: // Allow + result = true + case .alertSecondButtonReturn: // Don't Allow + result = false + default: + result = false + } + + // Store the result if checkbox is checked or if "Allow" was selected and allowDuration is set + if rememberDecision, let rememberDuration = rememberDuration { + storeResult(result, for: key, duration: rememberDuration) + } else if result { + switch allowDuration { + case .once: + // Don't store anything for once + break + case .forever: + // Store for a very long time (100 years). When the bug comes in that + // 100 years has passed and their forever permission expired I'll be + // dead so it won't be my problem. + storeResult(result, for: key, duration: .seconds(3153600000)) + case .duration(let duration): + storeResult(result, for: key, duration: duration) + } + } + + completion(result) + } + + /// Retrieves a cached permission decision if it hasn't expired + /// - 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 + guard let data = userDefaults.data(forKey: key), + let storedPermission = try? NSKeyedUnarchiver.unarchivedObject( + ofClass: StoredPermission.self, from: data) else { + return nil + } + + if Date() > storedPermission.expiry { + // Decision has expired, remove stored value + userDefaults.removeObject(forKey: key) + return nil + } + + return storedPermission.result + } + + /// Stores a permission decision in UserDefaults with an expiration date + /// - Parameters: + /// - result: The permission decision to store + /// - key: The UserDefaults key to store under + /// - duration: How long the decision should be cached + private static func storeResult(_ result: Bool, for key: String, duration: Duration) { + 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 + userDefaults.set(data, forKey: key) + } + } + + /// Formats the remember checkbox text based on the duration + /// - Parameter duration: The duration to format + /// - Returns: A human-readable string for the checkbox + private static func formatRememberText(for duration: Duration) -> String { + let seconds = duration.timeInterval + + // Warning: this probably isn't localization friendly at all so we're + // going to have to redo this for that. + switch seconds { + case 0..<60: + return "Remember my decision for \(Int(seconds)) seconds" + case 60..<3600: + let minutes = Int(seconds / 60) + return "Remember my decision for \(minutes) minute\(minutes == 1 ? "" : "s")" + case 3600..<86400: + let hours = Int(seconds / 3600) + return "Remember my decision for \(hours) hour\(hours == 1 ? "" : "s")" + case 86400: + return "Remember my decision for one day" + default: + let days = Int(seconds / 86400) + return "Remember my decision for \(days) day\(days == 1 ? "" : "s")" + } + } + + /// Internal class for storing permission decisions with expiration dates in UserDefaults + /// Conforms to NSSecureCoding for safe archiving/unarchiving + @objc(StoredPermission) + private class StoredPermission: NSObject, NSSecureCoding { + static var supportsSecureCoding: Bool = true + + let result: Bool + let expiry: Date + + init(result: Bool, expiry: Date) { + self.result = result + self.expiry = expiry + super.init() + } + + required init?(coder: NSCoder) { + self.result = coder.decodeBool(forKey: "result") + guard let expiry = coder.decodeObject(of: NSDate.self, forKey: "expiry") as? Date else { + return nil + } + self.expiry = expiry + super.init() + } + + func encode(with coder: NSCoder) { + coder.encode(result, forKey: "result") + coder.encode(expiry, forKey: "expiry") + } + } +} diff --git a/macos/Sources/Helpers/TabGroupCloseCoordinator.swift b/macos/Sources/Helpers/TabGroupCloseCoordinator.swift new file mode 100644 index 000000000..ca41bf89c --- /dev/null +++ b/macos/Sources/Helpers/TabGroupCloseCoordinator.swift @@ -0,0 +1,124 @@ +import AppKit + +/// Coordinates close operations for windows that are part of a tab group. +/// +/// This coordinator helps distinguish between closing a single tab versus closing +/// an entire window (with all its tabs). When macOS native tabs are used, close +/// operations can be ambiguous - this coordinator tracks close requests across +/// multiple windows in a tab group to determine the user's intent. +class TabGroupCloseCoordinator { + /// The scope of a close operation. + enum CloseScope { + case tab + case window + } + + /// Protocol that window controllers must implement to use the coordinator. + protocol Controller { + /// The tab group close coordinator instance for this controller. + var tabGroupCloseCoordinator: TabGroupCloseCoordinator { get } + } + + /// Callback type for close operations. + typealias Callback = (CloseScope) -> Void + + // We use weak vars and ObjectIdentifiers below because we don't want to + // create any strong reference cycles during coordination. + + /// The tab group being coordinated. Weak reference to avoid cycles. + private weak var tabGroup: NSWindowTabGroup? + + /// Map of window identifiers to their close callbacks. + private var closeRequests: [ObjectIdentifier: Callback] = [:] + + /// Timer used to debounce close requests and determine intent. + private var debounceTimer: Timer? + + deinit { + trigger(.tab) + } + + /// Call this from the windowShouldClose override in order to track whether + /// a window close event is from a tab or a window. If this window already + /// requested a close then only the latest will be called. + func windowShouldClose( + _ window: NSWindow, + callback: @escaping Callback + ) { + // If this window isn't part of a tab group we assume its a window + // close for the window and let our timer keep running for the rest. + guard let tabGroup = window.tabGroup else { + callback(.window) + return + } + + // Forward to the proper coordinator + if let firstController = tabGroup.windows.first?.windowController as? Controller, + firstController.tabGroupCloseCoordinator !== self { + let coordinator = firstController.tabGroupCloseCoordinator + coordinator.windowShouldClose(window, callback: callback) + return + } + + // If our tab group is nil then we either are seeing this for the first + // time or our weak ref expired and we should fire our callbacks. + if self.tabGroup == nil { + self.tabGroup = tabGroup + debounceTimer?.fire() + debounceTimer = nil + } + + // No matter what, we cancel our debounce and restart this. This opens + // us up to a DoS if close requests are looped but this would only + // happen in hostile scenarios that are self-inflicted. + debounceTimer?.invalidate() + debounceTimer = nil + + // If this tab group doesn't match then I don't really know what to + // do. This shouldn't happen. So we just assume it's a tab close + // and trigger the rest. No right answer here as far as I know. + if self.tabGroup != tabGroup { + callback(.tab) + trigger(.tab) + return + } + + // Add the request + closeRequests[ObjectIdentifier(window)] = callback + + // If close requests matches all our windows then we are done. + if closeRequests.count == tabGroup.windows.count { + let allWindows = Set(tabGroup.windows.map { ObjectIdentifier($0) }) + if Set(closeRequests.keys) == allWindows { + trigger(.window) + return + } + } + + // Setup our new timer + debounceTimer = Timer.scheduledTimer( + withTimeInterval: Duration.milliseconds(100).timeInterval, + repeats: false + ) { [weak self] _ in + self?.trigger(.tab) + } + } + + /// Triggers all pending close callbacks with the given scope. + /// + /// This method is called when the coordinator has determined the user's intent + /// (either closing a tab or the entire window). It executes all pending callbacks + /// and resets the coordinator's state. + /// + /// - Parameter scope: The determined scope of the close operation. + private func trigger(_ scope: CloseScope) { + // Reset our state + tabGroup = nil + debounceTimer?.invalidate() + debounceTimer = nil + + // Trigger all of our callbacks + closeRequests.forEach { $0.value(scope) } + closeRequests = [:] + } +} diff --git a/nix/devShell.nix b/nix/devShell.nix index b87c23dd1..f4ea62235 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -16,7 +16,7 @@ python3, qemu, scdoc, - snapcraft, + # snapcraft, valgrind, #, vulkan-loader # unused vttest, @@ -134,7 +134,7 @@ in appstream flatpak-builder gdb - snapcraft + # snapcraft valgrind wraptest diff --git a/pkg/README.md b/pkg/README.md index 1d6f9f6eb..fddc4b3db 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -12,7 +12,7 @@ paste them into your project. the Ghostty project. This license does not apply to the rest of the Ghostty project.** -Copyright © 2024 Mitchell Hashimoto +Copyright © 2024 Mitchell Hashimoto, Ghostty contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index 1be733dd6..18a6c0968 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -7,12 +7,17 @@ pub fn build(b: *std.Build) !void { _ = optimize; } -/// Add the SDK framework, include, and library paths to the given module. -/// The module target is used to determine the SDK to use so it must have -/// a resolved target. -pub fn addPaths(b: *std.Build, m: *std.Build.Module) !void { +/// Setup the step to point to the proper Apple SDK for libc and +/// frameworks. This expects and relies on the native SDK being +/// installed on the system. Ghostty doesn't support cross-compilation +/// for Apple platforms. +pub fn addPaths( + b: *std.Build, + step: *std.Build.Step.Compile, +) !void { // The cache. This always uses b.allocator and never frees memory - // (which is idiomatic for a Zig build exe). + // (which is idiomatic for a Zig build exe). We cache the libc txt + // file we create because it is expensive to generate (subprocesses). const Cache = struct { const Key = struct { arch: std.Target.Cpu.Arch, @@ -20,27 +25,72 @@ pub fn addPaths(b: *std.Build, m: *std.Build.Module) !void { abi: std.Target.Abi, }; - var map: std.AutoHashMapUnmanaged(Key, ?[]const u8) = .{}; + var map: std.AutoHashMapUnmanaged(Key, ?struct { + libc: std.Build.LazyPath, + framework: []const u8, + system_include: []const u8, + library: []const u8, + }) = .{}; }; - const target = m.resolved_target.?.result; + const target = step.rootModuleTarget(); const gop = try Cache.map.getOrPut(b.allocator, .{ .arch = target.cpu.arch, .os = target.os.tag, .abi = target.abi, }); - // This executes `xcrun` to get the SDK path. We don't want to execute - // this multiple times so we cache the value. if (!gop.found_existing) { - gop.value_ptr.* = std.zig.system.darwin.getSdk( - b.allocator, - m.resolved_target.?.result, - ); + // Detect our SDK using the "findNative" Zig stdlib function. + // This is really important because it forces using `xcrun` to + // find the SDK path. + const libc = try std.zig.LibCInstallation.findNative(.{ + .allocator = b.allocator, + .target = step.rootModuleTarget(), + .verbose = false, + }); + + // Render the file compatible with the `--libc` Zig flag. + var list: std.ArrayList(u8) = .init(b.allocator); + defer list.deinit(); + try libc.render(list.writer()); + + // Create a temporary file to store the libc path because + // `--libc` expects a file path. + const wf = b.addWriteFiles(); + const path = wf.add("libc.txt", list.items); + + // Determine our framework path. Zig has a bug where it doesn't + // parse this from the libc txt file for `-framework` flags: + // https://github.com/ziglang/zig/issues/24024 + const framework_path = framework: { + const down1 = std.fs.path.dirname(libc.sys_include_dir.?).?; + const down2 = std.fs.path.dirname(down1).?; + break :framework try std.fs.path.join(b.allocator, &.{ + down2, + "System", + "Library", + "Frameworks", + }); + }; + + const library_path = library: { + const down1 = std.fs.path.dirname(libc.sys_include_dir.?).?; + break :library try std.fs.path.join(b.allocator, &.{ + down1, + "lib", + }); + }; + + gop.value_ptr.* = .{ + .libc = path, + .framework = framework_path, + .system_include = libc.sys_include_dir.?, + .library = library_path, + }; } - // The active SDK we want to use - const path = gop.value_ptr.* orelse return switch (target.os.tag) { + const value = gop.value_ptr.* orelse return switch (target.os.tag) { // Return a more descriptive error. Before we just returned the // generic error but this was confusing a lot of community members. // It costs us nothing in the build script to return something better. @@ -50,7 +100,12 @@ pub fn addPaths(b: *std.Build, m: *std.Build.Module) !void { .watchos => error.XcodeWatchOSSDKNotFound, else => error.XcodeAppleSDKNotFound, }; - m.addSystemFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/System/Library/Frameworks" }) }); - m.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/include" }) }); - m.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/lib" }) }); + + step.setLibCFile(value.libc); + + // This is only necessary until this bug is fixed: + // https://github.com/ziglang/zig/issues/24024 + step.root_module.addSystemFrameworkPath(.{ .cwd_relative = value.framework }); + step.root_module.addSystemIncludePath(.{ .cwd_relative = value.system_include }); + step.root_module.addLibraryPath(.{ .cwd_relative = value.library }); } diff --git a/pkg/breakpad/build.zig b/pkg/breakpad/build.zig index e2fdec7ad..42247b12c 100644 --- a/pkg/breakpad/build.zig +++ b/pkg/breakpad/build.zig @@ -13,7 +13,7 @@ pub fn build(b: *std.Build) !void { lib.addIncludePath(b.path("vendor")); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/cimgui/build.zig b/pkg/cimgui/build.zig index c76b53966..3ca735383 100644 --- a/pkg/cimgui/build.zig +++ b/pkg/cimgui/build.zig @@ -84,8 +84,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { if (!target.query.isNative()) { - try @import("apple_sdk").addPaths(b, lib.root_module); - try @import("apple_sdk").addPaths(b, module); + try @import("apple_sdk").addPaths(b, lib); } lib.addCSourceFile(.{ .file = imgui.path("backends/imgui_impl_metal.mm"), diff --git a/pkg/fontconfig/build.zig b/pkg/fontconfig/build.zig index 77e8df549..9e4173da8 100644 --- a/pkg/fontconfig/build.zig +++ b/pkg/fontconfig/build.zig @@ -164,11 +164,23 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu "-DHAVE_SYS_STATVFS_H", "-DFC_CACHEDIR=\"/var/cache/fontconfig\"", - "-DFC_TEMPLATEDIR=\"/usr/share/fontconfig/conf.avail\"", - "-DFONTCONFIG_PATH=\"/etc/fonts\"", - "-DCONFIGDIR=\"/usr/local/fontconfig/conf.d\"", "-DFC_DEFAULT_FONTS=\"/usr/share/fonts/usr/local/share/fonts\"", }); + + if (target.result.os.tag == .freebsd) { + try flags.appendSlice(&.{ + "-DFC_TEMPLATEDIR=\"/usr/local/etc/fonts/conf.avail\"", + "-DFONTCONFIG_PATH=\"/usr/local/etc/fonts\"", + "-DCONFIGDIR=\"/usr/local/etc/fonts/conf.d\"", + }); + } else { + try flags.appendSlice(&.{ + "-DFC_TEMPLATEDIR=\"/usr/share/fontconfig/conf.avail\"", + "-DFONTCONFIG_PATH=\"/etc/fonts\"", + "-DCONFIGDIR=\"/usr/local/fontconfig/conf.d\"", + }); + } + if (target.result.os.tag == .linux) { try flags.appendSlice(&.{ "-DHAVE_SYS_STATFS_H", diff --git a/pkg/fontconfig/pattern.zig b/pkg/fontconfig/pattern.zig index e0ec27a69..3a623e223 100644 --- a/pkg/fontconfig/pattern.zig +++ b/pkg/fontconfig/pattern.zig @@ -44,7 +44,7 @@ pub const Pattern = opaque { &val, ))).toError(); - return Value.init(&val); + return .init(&val); } pub fn delete(self: *Pattern, prop: Property) bool { @@ -138,7 +138,7 @@ pub const Pattern = opaque { return Entry{ .result = @enumFromInt(result), .binding = @enumFromInt(binding), - .value = Value.init(&value), + .value = .init(&value), }; } }; diff --git a/pkg/freetype/build.zig b/pkg/freetype/build.zig index bfe27e5aa..e9f72210a 100644 --- a/pkg/freetype/build.zig +++ b/pkg/freetype/build.zig @@ -69,7 +69,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu lib.linkLibC(); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/glfw/LICENSE b/pkg/glfw/LICENSE index eeeb852fe..8c422bd23 100644 --- a/pkg/glfw/LICENSE +++ b/pkg/glfw/LICENSE @@ -1,5 +1,5 @@ Copyright (c) 2021 Hexops Contributors (given via the Git commit history). -Copyright (c) 2025 Mitchell Hashimoto +Copyright (c) 2025 Mitchell Hashimoto, Ghostty contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/pkg/glfw/Monitor.zig b/pkg/glfw/Monitor.zig index 4accb23cd..3b194965a 100644 --- a/pkg/glfw/Monitor.zig +++ b/pkg/glfw/Monitor.zig @@ -281,7 +281,7 @@ pub inline fn setGamma(self: Monitor, gamma: f32) void { /// see also: monitor_gamma pub inline fn getGammaRamp(self: Monitor) ?GammaRamp { internal_debug.assertInitialized(); - if (c.glfwGetGammaRamp(self.handle)) |ramp| return GammaRamp.fromC(ramp.*); + if (c.glfwGetGammaRamp(self.handle)) |ramp| return .fromC(ramp.*); return null; } diff --git a/pkg/glfw/build.zig b/pkg/glfw/build.zig index cc61f18b2..142a558da 100644 --- a/pkg/glfw/build.zig +++ b/pkg/glfw/build.zig @@ -24,7 +24,7 @@ pub fn build(b: *std.Build) !void { .optimize = optimize, }); if (target.result.os.tag.isDarwin()) { - try apple_sdk.addPaths(b, exe.root_module); + try apple_sdk.addPaths(b, exe); } const tests_run = b.addRunArtifact(exe); @@ -122,8 +122,7 @@ fn buildLib( }, .macos => { - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); // Transitive dependencies, explicit linkage of these works around // ziglang/zig#17130 diff --git a/pkg/glfw/opengl.zig b/pkg/glfw/opengl.zig index 04bc3a65c..8fe2efbed 100644 --- a/pkg/glfw/opengl.zig +++ b/pkg/glfw/opengl.zig @@ -47,7 +47,7 @@ pub inline fn makeContextCurrent(window: ?Window) void { /// see also: context_current, glfwMakeContextCurrent pub inline fn getCurrentContext() ?Window { internal_debug.assertInitialized(); - if (c.glfwGetCurrentContext()) |handle| return Window.from(handle); + if (c.glfwGetCurrentContext()) |handle| return .from(handle); return null; } diff --git a/pkg/glslang/build.zig b/pkg/glslang/build.zig index 629490aa4..747216a39 100644 --- a/pkg/glslang/build.zig +++ b/pkg/glslang/build.zig @@ -16,10 +16,6 @@ pub fn build(b: *std.Build) !void { module.addIncludePath(upstream.path("")); module.addIncludePath(b.path("override")); - if (target.result.os.tag.isDarwin()) { - const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, module); - } if (target.query.isNative()) { const test_exe = b.addTest(.{ @@ -55,7 +51,7 @@ fn buildGlslang( lib.addIncludePath(b.path("override")); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/gtk4-layer-shell/src/main.zig b/pkg/gtk4-layer-shell/src/main.zig index 88d99772b..f7848ea94 100644 --- a/pkg/gtk4-layer-shell/src/main.zig +++ b/pkg/gtk4-layer-shell/src/main.zig @@ -1,3 +1,5 @@ +const std = @import("std"); + const c = @cImport({ @cInclude("gtk4-layer-shell.h"); }); @@ -27,6 +29,18 @@ pub fn isSupported() bool { return c.gtk_layer_is_supported() != 0; } +pub fn getProtocolVersion() c_uint { + return c.gtk_layer_get_protocol_version(); +} + +pub fn getLibraryVersion() std.SemanticVersion { + return .{ + .major = c.gtk_layer_get_major_version(), + .minor = c.gtk_layer_get_minor_version(), + .patch = c.gtk_layer_get_micro_version(), + }; +} + pub fn initForWindow(window: *gtk.Window) void { c.gtk_layer_init_for_window(@ptrCast(window)); } @@ -46,3 +60,7 @@ pub fn setMargin(window: *gtk.Window, edge: ShellEdge, margin_size: c_int) void pub fn setKeyboardMode(window: *gtk.Window, mode: KeyboardMode) void { c.gtk_layer_set_keyboard_mode(@ptrCast(window), @intFromEnum(mode)); } + +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 d0dd6d01c..3bdc30a32 100644 --- a/pkg/harfbuzz/build.zig +++ b/pkg/harfbuzz/build.zig @@ -93,8 +93,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu lib.linkLibCpp(); if (target.result.os.tag.isDarwin()) { - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); } const dynamic_link_opts = options.dynamic_link_opts; diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index c72ca355f..5036316da 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -23,8 +23,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/libintl/build.zig b/pkg/libintl/build.zig index 53eb67f16..1baed195a 100644 --- a/pkg/libintl/build.zig +++ b/pkg/libintl/build.zig @@ -40,7 +40,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } if (b.lazyDependency("gettext", .{})) |upstream| { diff --git a/pkg/libpng/build.zig b/pkg/libpng/build.zig index d012f2712..8729398f8 100644 --- a/pkg/libpng/build.zig +++ b/pkg/libpng/build.zig @@ -15,7 +15,7 @@ pub fn build(b: *std.Build) !void { } if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } // For dynamic linking, we prefer dynamic linking and to search by diff --git a/pkg/macos/animation.zig b/pkg/macos/animation.zig index 5c3c8fd30..247f97605 100644 --- a/pkg/macos/animation.zig +++ b/pkg/macos/animation.zig @@ -2,6 +2,8 @@ pub const c = @import("animation/c.zig").c; /// https://developer.apple.com/documentation/quartzcore/calayer/contents_gravity_values?language=objc pub extern "c" const kCAGravityTopLeft: *anyopaque; +pub extern "c" const kCAGravityBottomLeft: *anyopaque; +pub extern "c" const kCAGravityCenter: *anyopaque; test { @import("std").testing.refAllDecls(@This()); diff --git a/pkg/macos/build.zig b/pkg/macos/build.zig index 911664a2f..3e0a97d1a 100644 --- a/pkg/macos/build.zig +++ b/pkg/macos/build.zig @@ -33,6 +33,7 @@ pub fn build(b: *std.Build) !void { lib.linkFramework("CoreText"); lib.linkFramework("CoreVideo"); lib.linkFramework("QuartzCore"); + lib.linkFramework("IOSurface"); if (target.result.os.tag == .macos) { lib.linkFramework("Carbon"); module.linkFramework("Carbon", .{}); @@ -44,9 +45,9 @@ pub fn build(b: *std.Build) !void { module.linkFramework("CoreText", .{}); module.linkFramework("CoreVideo", .{}); module.linkFramework("QuartzCore", .{}); + module.linkFramework("IOSurface", .{}); - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); } b.installArtifact(lib); @@ -58,7 +59,7 @@ pub fn build(b: *std.Build) !void { .optimize = optimize, }); if (target.result.os.tag.isDarwin()) { - try apple_sdk.addPaths(b, test_exe.root_module); + try apple_sdk.addPaths(b, test_exe); } test_exe.linkLibrary(lib); diff --git a/pkg/macos/dispatch.zig b/pkg/macos/dispatch.zig index 2bc7e8396..3add9c0e9 100644 --- a/pkg/macos/dispatch.zig +++ b/pkg/macos/dispatch.zig @@ -3,6 +3,16 @@ pub const data = @import("dispatch/data.zig"); pub const queue = @import("dispatch/queue.zig"); pub const Data = data.Data; +pub extern "c" fn dispatch_sync( + queue: *anyopaque, + block: *anyopaque, +) void; + +pub extern "c" fn dispatch_async( + queue: *anyopaque, + block: *anyopaque, +) void; + test { @import("std").testing.refAllDecls(@This()); } diff --git a/pkg/macos/foundation.zig b/pkg/macos/foundation.zig index 85562faf0..d4f634091 100644 --- a/pkg/macos/foundation.zig +++ b/pkg/macos/foundation.zig @@ -30,6 +30,7 @@ pub const stringGetSurrogatePairForLongCharacter = string.stringGetSurrogatePair pub const URL = url.URL; pub const URLPathStyle = url.URLPathStyle; pub const CFRelease = typepkg.CFRelease; +pub const CFRetain = typepkg.CFRetain; test { @import("std").testing.refAllDecls(@This()); diff --git a/pkg/macos/foundation/type.zig b/pkg/macos/foundation/type.zig index e3ee150f2..45bd09054 100644 --- a/pkg/macos/foundation/type.zig +++ b/pkg/macos/foundation/type.zig @@ -1 +1,2 @@ pub extern "c" fn CFRelease(*anyopaque) void; +pub extern "c" fn CFRetain(*anyopaque) void; diff --git a/pkg/macos/iosurface.zig b/pkg/macos/iosurface.zig new file mode 100644 index 000000000..9d2e750cf --- /dev/null +++ b/pkg/macos/iosurface.zig @@ -0,0 +1,8 @@ +const iosurface = @import("iosurface/iosurface.zig"); + +pub const c = @import("iosurface/c.zig").c; +pub const IOSurface = iosurface.IOSurface; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/pkg/macos/iosurface/c.zig b/pkg/macos/iosurface/c.zig new file mode 100644 index 000000000..1a7d1627e --- /dev/null +++ b/pkg/macos/iosurface/c.zig @@ -0,0 +1 @@ +pub const c = @import("../main.zig").c; diff --git a/pkg/macos/iosurface/iosurface.zig b/pkg/macos/iosurface/iosurface.zig new file mode 100644 index 000000000..37f8712ba --- /dev/null +++ b/pkg/macos/iosurface/iosurface.zig @@ -0,0 +1,136 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const c = @import("c.zig").c; +const foundation = @import("../foundation.zig"); +const graphics = @import("../graphics.zig"); +const video = @import("../video.zig"); + +pub const IOSurface = opaque { + pub const Error = error{ + InvalidOperation, + }; + + pub const Properties = struct { + width: c_int, + height: c_int, + pixel_format: video.PixelFormat, + bytes_per_element: c_int, + colorspace: ?*graphics.ColorSpace, + }; + + pub fn init(properties: Properties) Allocator.Error!*IOSurface { + var w = try foundation.Number.create(.int, &properties.width); + defer w.release(); + var h = try foundation.Number.create(.int, &properties.height); + defer h.release(); + var pf = try foundation.Number.create(.int, &@as(c_int, @intFromEnum(properties.pixel_format))); + defer pf.release(); + var bpe = try foundation.Number.create(.int, &properties.bytes_per_element); + defer bpe.release(); + + var properties_dict = try foundation.Dictionary.create( + &[_]?*const anyopaque{ + c.kIOSurfaceWidth, + c.kIOSurfaceHeight, + c.kIOSurfacePixelFormat, + c.kIOSurfaceBytesPerElement, + }, + &[_]?*const anyopaque{ w, h, pf, bpe }, + ); + defer properties_dict.release(); + + var surface = @as(?*IOSurface, @ptrFromInt(@intFromPtr( + c.IOSurfaceCreate(@ptrCast(properties_dict)), + ))) orelse return error.OutOfMemory; + + if (properties.colorspace) |space| { + surface.setColorSpace(space); + } + + return surface; + } + + pub fn deinit(self: *IOSurface) void { + // We mark it purgeable so that it is immediately unloaded, so that we + // don't have to wait for CoreFoundation garbage collection to trigger. + _ = c.IOSurfaceSetPurgeable( + @ptrCast(self), + c.kIOSurfacePurgeableEmpty, + null, + ); + foundation.CFRelease(self); + } + + pub fn retain(self: *IOSurface) void { + foundation.CFRetain(self); + } + + pub fn release(self: *IOSurface) void { + foundation.CFRelease(self); + } + + pub fn setColorSpace(self: *IOSurface, colorspace: *graphics.ColorSpace) void { + const serialized_colorspace = graphics.c.CGColorSpaceCopyPropertyList( + @ptrCast(colorspace), + ).?; + defer foundation.CFRelease(@constCast(serialized_colorspace)); + + c.IOSurfaceSetValue( + @ptrCast(self), + c.kIOSurfaceColorSpace, + @ptrCast(serialized_colorspace), + ); + } + + pub inline fn lock(self: *IOSurface) void { + c.IOSurfaceLock( + @ptrCast(self), + 0, + null, + ); + } + pub inline fn unlock(self: *IOSurface) void { + c.IOSurfaceUnlock( + @ptrCast(self), + 0, + null, + ); + } + + pub inline fn getAllocSize(self: *IOSurface) usize { + return c.IOSurfaceGetAllocSize(@ptrCast(self)); + } + + pub inline fn getWidth(self: *IOSurface) usize { + return c.IOSurfaceGetWidth(@ptrCast(self)); + } + + pub inline fn getHeight(self: *IOSurface) usize { + return c.IOSurfaceGetHeight(@ptrCast(self)); + } + + pub inline fn getBytesPerElement(self: *IOSurface) usize { + return c.IOSurfaceGetBytesPerElement(@ptrCast(self)); + } + + pub inline fn getBytesPerRow(self: *IOSurface) usize { + return c.IOSurfaceGetBytesPerRow(@ptrCast(self)); + } + + pub inline fn getBaseAddress(self: *IOSurface) ?[*]u8 { + return @ptrCast(c.IOSurfaceGetBaseAddress(@ptrCast(self))); + } + + pub inline fn getElementWidth(self: *IOSurface) usize { + return c.IOSurfaceGetElementWidth(@ptrCast(self)); + } + + pub inline fn getElementHeight(self: *IOSurface) usize { + return c.IOSurfaceGetElementHeight(@ptrCast(self)); + } + + pub inline fn getPixelFormat(self: *IOSurface) video.PixelFormat { + return @enumFromInt(c.IOSurfaceGetPixelFormat(@ptrCast(self))); + } +}; diff --git a/pkg/macos/main.zig b/pkg/macos/main.zig index d094b987e..42253ba48 100644 --- a/pkg/macos/main.zig +++ b/pkg/macos/main.zig @@ -8,6 +8,7 @@ pub const graphics = @import("graphics.zig"); pub const os = @import("os.zig"); pub const text = @import("text.zig"); pub const video = @import("video.zig"); +pub const iosurface = @import("iosurface.zig"); // All of our C imports consolidated into one place. We used to // import them one by one in each package but Zig 0.14 has some @@ -17,7 +18,9 @@ pub const c = @cImport({ @cInclude("CoreGraphics/CoreGraphics.h"); @cInclude("CoreText/CoreText.h"); @cInclude("CoreVideo/CoreVideo.h"); + @cInclude("CoreVideo/CVPixelBuffer.h"); @cInclude("QuartzCore/CALayer.h"); + @cInclude("IOSurface/IOSurfaceRef.h"); @cInclude("dispatch/dispatch.h"); @cInclude("os/log.h"); diff --git a/pkg/macos/video.zig b/pkg/macos/video.zig index 0f5cbc4d6..d0b1125ab 100644 --- a/pkg/macos/video.zig +++ b/pkg/macos/video.zig @@ -1,7 +1,9 @@ const display_link = @import("video/display_link.zig"); +const pixel_format = @import("video/pixel_format.zig"); pub const c = @import("video/c.zig").c; pub const DisplayLink = display_link.DisplayLink; +pub const PixelFormat = pixel_format.PixelFormat; test { @import("std").testing.refAllDecls(@This()); diff --git a/pkg/macos/video/pixel_format.zig b/pkg/macos/video/pixel_format.zig new file mode 100644 index 000000000..78091daa3 --- /dev/null +++ b/pkg/macos/video/pixel_format.zig @@ -0,0 +1,171 @@ +const c = @import("c.zig").c; + +pub const PixelFormat = enum(c_int) { + /// 1 bit indexed + @"1Monochrome" = c.kCVPixelFormatType_1Monochrome, + /// 2 bit indexed + @"2Indexed" = c.kCVPixelFormatType_2Indexed, + /// 4 bit indexed + @"4Indexed" = c.kCVPixelFormatType_4Indexed, + /// 8 bit indexed + @"8Indexed" = c.kCVPixelFormatType_8Indexed, + /// 1 bit indexed gray, white is zero + @"1IndexedGray_WhiteIsZero" = c.kCVPixelFormatType_1IndexedGray_WhiteIsZero, + /// 2 bit indexed gray, white is zero + @"2IndexedGray_WhiteIsZero" = c.kCVPixelFormatType_2IndexedGray_WhiteIsZero, + /// 4 bit indexed gray, white is zero + @"4IndexedGray_WhiteIsZero" = c.kCVPixelFormatType_4IndexedGray_WhiteIsZero, + /// 8 bit indexed gray, white is zero + @"8IndexedGray_WhiteIsZero" = c.kCVPixelFormatType_8IndexedGray_WhiteIsZero, + /// 16 bit BE RGB 555 + @"16BE555" = c.kCVPixelFormatType_16BE555, + /// 16 bit LE RGB 555 + @"16LE555" = c.kCVPixelFormatType_16LE555, + /// 16 bit LE RGB 5551 + @"16LE5551" = c.kCVPixelFormatType_16LE5551, + /// 16 bit BE RGB 565 + @"16BE565" = c.kCVPixelFormatType_16BE565, + /// 16 bit LE RGB 565 + @"16LE565" = c.kCVPixelFormatType_16LE565, + /// 24 bit RGB + @"24RGB" = c.kCVPixelFormatType_24RGB, + /// 24 bit BGR + @"24BGR" = c.kCVPixelFormatType_24BGR, + /// 32 bit ARGB + @"32ARGB" = c.kCVPixelFormatType_32ARGB, + /// 32 bit BGRA + @"32BGRA" = c.kCVPixelFormatType_32BGRA, + /// 32 bit ABGR + @"32ABGR" = c.kCVPixelFormatType_32ABGR, + /// 32 bit RGBA + @"32RGBA" = c.kCVPixelFormatType_32RGBA, + /// 64 bit ARGB, 16-bit big-endian samples + @"64ARGB" = c.kCVPixelFormatType_64ARGB, + /// 64 bit RGBA, 16-bit little-endian full-range (0-65535) samples + @"64RGBALE" = c.kCVPixelFormatType_64RGBALE, + /// 48 bit RGB, 16-bit big-endian samples + @"48RGB" = c.kCVPixelFormatType_48RGB, + /// 32 bit AlphaGray, 16-bit big-endian samples, black is zero + @"32AlphaGray" = c.kCVPixelFormatType_32AlphaGray, + /// 16 bit Grayscale, 16-bit big-endian samples, black is zero + @"16Gray" = c.kCVPixelFormatType_16Gray, + /// 30 bit RGB, 10-bit big-endian samples, 2 unused padding bits (at least significant end). + @"30RGB" = c.kCVPixelFormatType_30RGB, + /// 30 bit RGB, 10-bit big-endian samples, 2 unused padding bits (at most significant end), video-range (64-940). + @"30RGB_r210" = c.kCVPixelFormatType_30RGB_r210, + /// Component Y'CbCr 8-bit 4:2:2, ordered Cb Y'0 Cr Y'1 + @"422YpCbCr8" = c.kCVPixelFormatType_422YpCbCr8, + /// Component Y'CbCrA 8-bit 4:4:4:4, ordered Cb Y' Cr A + @"4444YpCbCrA8" = c.kCVPixelFormatType_4444YpCbCrA8, + /// Component Y'CbCrA 8-bit 4:4:4:4, rendering format. full range alpha, zero biased YUV, ordered A Y' Cb Cr + @"4444YpCbCrA8R" = c.kCVPixelFormatType_4444YpCbCrA8R, + /// Component Y'CbCrA 8-bit 4:4:4:4, ordered A Y' Cb Cr, full range alpha, video range Y'CbCr. + @"4444AYpCbCr8" = c.kCVPixelFormatType_4444AYpCbCr8, + /// Component Y'CbCrA 16-bit 4:4:4:4, ordered A Y' Cb Cr, full range alpha, video range Y'CbCr, 16-bit little-endian samples. + @"4444AYpCbCr16" = c.kCVPixelFormatType_4444AYpCbCr16, + /// Component AY'CbCr single precision floating-point 4:4:4:4 + @"4444AYpCbCrFloat" = c.kCVPixelFormatType_4444AYpCbCrFloat, + /// Component Y'CbCr 8-bit 4:4:4, ordered Cr Y' Cb, video range Y'CbCr + @"444YpCbCr8" = c.kCVPixelFormatType_444YpCbCr8, + /// Component Y'CbCr 10,12,14,16-bit 4:2:2 + @"422YpCbCr16" = c.kCVPixelFormatType_422YpCbCr16, + /// Component Y'CbCr 10-bit 4:2:2 + @"422YpCbCr10" = c.kCVPixelFormatType_422YpCbCr10, + /// Component Y'CbCr 10-bit 4:4:4 + @"444YpCbCr10" = c.kCVPixelFormatType_444YpCbCr10, + /// Planar Component Y'CbCr 8-bit 4:2:0. baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrPlanar struct + @"420YpCbCr8Planar" = c.kCVPixelFormatType_420YpCbCr8Planar, + /// Planar Component Y'CbCr 8-bit 4:2:0, full range. baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrPlanar struct + @"420YpCbCr8PlanarFullRange" = c.kCVPixelFormatType_420YpCbCr8PlanarFullRange, + /// First plane: Video-range Component Y'CbCr 8-bit 4:2:2, ordered Cb Y'0 Cr Y'1; second plane: alpha 8-bit 0-255 + @"422YpCbCr_4A_8BiPlanar" = c.kCVPixelFormatType_422YpCbCr_4A_8BiPlanar, + /// Bi-Planar Component Y'CbCr 8-bit 4:2:0, video-range (luma=[16,235] chroma=[16,240]). baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrBiPlanar struct + @"420YpCbCr8BiPlanarVideoRange" = c.kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + /// Bi-Planar Component Y'CbCr 8-bit 4:2:0, full-range (luma=[0,255] chroma=[1,255]). baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrBiPlanar struct + @"420YpCbCr8BiPlanarFullRange" = c.kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, + /// Bi-Planar Component Y'CbCr 8-bit 4:2:2, video-range (luma=[16,235] chroma=[16,240]). baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrBiPlanar struct + @"422YpCbCr8BiPlanarVideoRange" = c.kCVPixelFormatType_422YpCbCr8BiPlanarVideoRange, + /// Bi-Planar Component Y'CbCr 8-bit 4:2:2, full-range (luma=[0,255] chroma=[1,255]). baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrBiPlanar struct + @"422YpCbCr8BiPlanarFullRange" = c.kCVPixelFormatType_422YpCbCr8BiPlanarFullRange, + /// Bi-Planar Component Y'CbCr 8-bit 4:4:4, video-range (luma=[16,235] chroma=[16,240]). baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrBiPlanar struct + @"444YpCbCr8BiPlanarVideoRange" = c.kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange, + /// Bi-Planar Component Y'CbCr 8-bit 4:4:4, full-range (luma=[0,255] chroma=[1,255]). baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrBiPlanar struct + @"444YpCbCr8BiPlanarFullRange" = c.kCVPixelFormatType_444YpCbCr8BiPlanarFullRange, + /// Component Y'CbCr 8-bit 4:2:2, ordered Y'0 Cb Y'1 Cr + @"422YpCbCr8_yuvs" = c.kCVPixelFormatType_422YpCbCr8_yuvs, + /// Component Y'CbCr 8-bit 4:2:2, full range, ordered Y'0 Cb Y'1 Cr + @"422YpCbCr8FullRange" = c.kCVPixelFormatType_422YpCbCr8FullRange, + /// 8 bit one component, black is zero + OneComponent8 = c.kCVPixelFormatType_OneComponent8, + /// 8 bit two component, black is zero + TwoComponent8 = c.kCVPixelFormatType_TwoComponent8, + /// little-endian RGB101010, 2 MSB are ignored, wide-gamut (384-895) + @"30RGBLEPackedWideGamut" = c.kCVPixelFormatType_30RGBLEPackedWideGamut, + /// little-endian ARGB2101010 full-range ARGB + ARGB2101010LEPacked = c.kCVPixelFormatType_ARGB2101010LEPacked, + /// little-endian ARGB10101010, each 10 bits in the MSBs of 16bits, wide-gamut (384-895, including alpha) + @"40ARGBLEWideGamut" = c.kCVPixelFormatType_40ARGBLEWideGamut, + /// little-endian ARGB10101010, each 10 bits in the MSBs of 16bits, wide-gamut (384-895, including alpha). Alpha premultiplied + @"40ARGBLEWideGamutPremultiplied" = c.kCVPixelFormatType_40ARGBLEWideGamutPremultiplied, + /// 10 bit little-endian one component, stored as 10 MSBs of 16 bits, black is zero + OneComponent10 = c.kCVPixelFormatType_OneComponent10, + /// 12 bit little-endian one component, stored as 12 MSBs of 16 bits, black is zero + OneComponent12 = c.kCVPixelFormatType_OneComponent12, + /// 16 bit little-endian one component, black is zero + OneComponent16 = c.kCVPixelFormatType_OneComponent16, + /// 16 bit little-endian two component, black is zero + TwoComponent16 = c.kCVPixelFormatType_TwoComponent16, + /// 16 bit one component IEEE half-precision float, 16-bit little-endian samples + OneComponent16Half = c.kCVPixelFormatType_OneComponent16Half, + /// 32 bit one component IEEE float, 32-bit little-endian samples + OneComponent32Float = c.kCVPixelFormatType_OneComponent32Float, + /// 16 bit two component IEEE half-precision float, 16-bit little-endian samples + TwoComponent16Half = c.kCVPixelFormatType_TwoComponent16Half, + /// 32 bit two component IEEE float, 32-bit little-endian samples + TwoComponent32Float = c.kCVPixelFormatType_TwoComponent32Float, + /// 64 bit RGBA IEEE half-precision float, 16-bit little-endian samples + @"64RGBAHalf" = c.kCVPixelFormatType_64RGBAHalf, + /// 128 bit RGBA IEEE float, 32-bit little-endian samples + @"128RGBAFloat" = c.kCVPixelFormatType_128RGBAFloat, + /// Bayer 14-bit Little-Endian, packed in 16-bits, ordered G R G R... alternating with B G B G... + @"14Bayer_GRBG" = c.kCVPixelFormatType_14Bayer_GRBG, + /// Bayer 14-bit Little-Endian, packed in 16-bits, ordered R G R G... alternating with G B G B... + @"14Bayer_RGGB" = c.kCVPixelFormatType_14Bayer_RGGB, + /// Bayer 14-bit Little-Endian, packed in 16-bits, ordered B G B G... alternating with G R G R... + @"14Bayer_BGGR" = c.kCVPixelFormatType_14Bayer_BGGR, + /// Bayer 14-bit Little-Endian, packed in 16-bits, ordered G B G B... alternating with R G R G... + @"14Bayer_GBRG" = c.kCVPixelFormatType_14Bayer_GBRG, + /// IEEE754-2008 binary16 (half float), describing the normalized shift when comparing two images. Units are 1/meters: ( pixelShift / (pixelFocalLength * baselineInMeters) ) + DisparityFloat16 = c.kCVPixelFormatType_DisparityFloat16, + /// IEEE754-2008 binary32 float, describing the normalized shift when comparing two images. Units are 1/meters: ( pixelShift / (pixelFocalLength * baselineInMeters) ) + DisparityFloat32 = c.kCVPixelFormatType_DisparityFloat32, + /// IEEE754-2008 binary16 (half float), describing the depth (distance to an object) in meters + DepthFloat16 = c.kCVPixelFormatType_DepthFloat16, + /// IEEE754-2008 binary32 float, describing the depth (distance to an object) in meters + DepthFloat32 = c.kCVPixelFormatType_DepthFloat32, + /// 2 plane YCbCr10 4:2:0, each 10 bits in the MSBs of 16bits, video-range (luma=[64,940] chroma=[64,960]) + @"420YpCbCr10BiPlanarVideoRange" = c.kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, + /// 2 plane YCbCr10 4:2:2, each 10 bits in the MSBs of 16bits, video-range (luma=[64,940] chroma=[64,960]) + @"422YpCbCr10BiPlanarVideoRange" = c.kCVPixelFormatType_422YpCbCr10BiPlanarVideoRange, + /// 2 plane YCbCr10 4:4:4, each 10 bits in the MSBs of 16bits, video-range (luma=[64,940] chroma=[64,960]) + @"444YpCbCr10BiPlanarVideoRange" = c.kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange, + /// 2 plane YCbCr10 4:2:0, each 10 bits in the MSBs of 16bits, full-range (Y range 0-1023) + @"420YpCbCr10BiPlanarFullRange" = c.kCVPixelFormatType_420YpCbCr10BiPlanarFullRange, + /// 2 plane YCbCr10 4:2:2, each 10 bits in the MSBs of 16bits, full-range (Y range 0-1023) + @"422YpCbCr10BiPlanarFullRange" = c.kCVPixelFormatType_422YpCbCr10BiPlanarFullRange, + /// 2 plane YCbCr10 4:4:4, each 10 bits in the MSBs of 16bits, full-range (Y range 0-1023) + @"444YpCbCr10BiPlanarFullRange" = c.kCVPixelFormatType_444YpCbCr10BiPlanarFullRange, + /// first and second planes as per 420YpCbCr8BiPlanarVideoRange (420v), alpha 8 bits in third plane full-range. No CVPlanarPixelBufferInfo struct. + @"420YpCbCr8VideoRange_8A_TriPlanar" = c.kCVPixelFormatType_420YpCbCr8VideoRange_8A_TriPlanar, + /// Single plane Bayer 16-bit little-endian sensor element ("sensel".*) samples from full-size decoding of ProRes RAW images; Bayer pattern (sensel ordering) and other raw conversion information is described via buffer attachments + @"16VersatileBayer" = c.kCVPixelFormatType_16VersatileBayer, + /// Single plane 64-bit RGBA (16-bit little-endian samples) from downscaled decoding of ProRes RAW images; components--which may not be co-sited with one another--are sensel values and require raw conversion, information for which is described via buffer attachments + @"64RGBA_DownscaledProResRAW" = c.kCVPixelFormatType_64RGBA_DownscaledProResRAW, + /// 2 plane YCbCr16 4:2:2, video-range (luma=[4096,60160] chroma=[4096,61440]) + @"422YpCbCr16BiPlanarVideoRange" = c.kCVPixelFormatType_422YpCbCr16BiPlanarVideoRange, + /// 2 plane YCbCr16 4:4:4, video-range (luma=[4096,60160] chroma=[4096,61440]) + @"444YpCbCr16BiPlanarVideoRange" = c.kCVPixelFormatType_444YpCbCr16BiPlanarVideoRange, + /// 3 plane video-range YCbCr16 4:4:4 with 16-bit full-range alpha (luma=[4096,60160] chroma=[4096,61440] alpha=[0,65535]). No CVPlanarPixelBufferInfo struct. + @"444YpCbCr16VideoRange_16A_TriPlanar" = c.kCVPixelFormatType_444YpCbCr16VideoRange_16A_TriPlanar, + _, +}; diff --git a/pkg/oniguruma/build.zig b/pkg/oniguruma/build.zig index 1c93bbf9a..c23d744df 100644 --- a/pkg/oniguruma/build.zig +++ b/pkg/oniguruma/build.zig @@ -67,7 +67,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } if (b.lazyDependency("oniguruma", .{})) |upstream| { diff --git a/pkg/opengl/Buffer.zig b/pkg/opengl/Buffer.zig index 3e55410b7..609342958 100644 --- a/pkg/opengl/Buffer.zig +++ b/pkg/opengl/Buffer.zig @@ -51,7 +51,7 @@ pub const Binding = struct { data: anytype, usage: Usage, ) !void { - const info = dataInfo(&data); + const info = dataInfo(data); glad.context.BufferData.?( @intFromEnum(b.target), info.size, @@ -136,10 +136,6 @@ pub const Binding = struct { }; } - pub fn enableAttribArray(_: Binding, idx: c.GLuint) !void { - glad.context.EnableVertexAttribArray.?(idx); - } - /// Shorthand for vertexAttribPointer that is specialized towards the /// common use case of specifying an array of homogeneous types that /// don't need normalization. This also enables the attribute at idx. @@ -230,6 +226,7 @@ pub const Target = enum(c_uint) { array = c.GL_ARRAY_BUFFER, element_array = c.GL_ELEMENT_ARRAY_BUFFER, uniform = c.GL_UNIFORM_BUFFER, + storage = c.GL_SHADER_STORAGE_BUFFER, _, }; diff --git a/pkg/opengl/Framebuffer.zig b/pkg/opengl/Framebuffer.zig index c5d659f98..ea1f0d2ba 100644 --- a/pkg/opengl/Framebuffer.zig +++ b/pkg/opengl/Framebuffer.zig @@ -5,6 +5,7 @@ const c = @import("c.zig").c; const errors = @import("errors.zig"); const glad = @import("glad.zig"); const Texture = @import("Texture.zig"); +const Renderbuffer = @import("Renderbuffer.zig"); id: c.GLuint, @@ -86,6 +87,29 @@ pub const Binding = struct { try errors.getError(); } + pub fn renderbuffer( + self: Binding, + attachment: Attachment, + buffer: Renderbuffer, + ) !void { + glad.context.FramebufferRenderbuffer.?( + @intFromEnum(self.target), + @intFromEnum(attachment), + c.GL_RENDERBUFFER, + buffer.id, + ); + try errors.getError(); + } + + pub fn drawBuffers( + self: Binding, + bufs: []Attachment, + ) !void { + _ = self; + glad.context.DrawBuffers.?(@intCast(bufs.len), bufs.ptr); + try errors.getError(); + } + pub fn checkStatus(self: Binding) Status { return @enumFromInt(glad.context.CheckFramebufferStatus.?(@intFromEnum(self.target))); } diff --git a/pkg/opengl/Renderbuffer.zig b/pkg/opengl/Renderbuffer.zig new file mode 100644 index 000000000..ef21287f7 --- /dev/null +++ b/pkg/opengl/Renderbuffer.zig @@ -0,0 +1,56 @@ +const Renderbuffer = @This(); + +const std = @import("std"); +const c = @import("c.zig").c; +const errors = @import("errors.zig"); +const glad = @import("glad.zig"); + +const Texture = @import("Texture.zig"); + +id: c.GLuint, + +/// Create a single buffer. +pub fn create() !Renderbuffer { + var rbo: c.GLuint = undefined; + glad.context.GenRenderbuffers.?(1, &rbo); + return .{ .id = rbo }; +} + +pub fn destroy(v: Renderbuffer) void { + glad.context.DeleteRenderbuffers.?(1, &v.id); +} + +pub fn bind(v: Renderbuffer) !Binding { + // Keep track of the previous binding so we can restore it in unbind. + var current: c.GLint = undefined; + glad.context.GetIntegerv.?(c.GL_RENDERBUFFER_BINDING, ¤t); + glad.context.BindRenderbuffer.?(c.GL_RENDERBUFFER, v.id); + return .{ .previous = @intCast(current) }; +} + +pub const Binding = struct { + previous: c.GLuint, + + pub fn unbind(self: Binding) void { + glad.context.BindRenderbuffer.?( + c.GL_RENDERBUFFER, + self.previous, + ); + } + + pub fn storage( + self: Binding, + format: Texture.InternalFormat, + width: c.GLsizei, + height: c.GLsizei, + ) !void { + _ = self; + glad.context.RenderbufferStorage.?( + c.GL_RENDERBUFFER, + @intCast(@intFromEnum(format)), + width, + height, + ); + try errors.getError(); + } +}; diff --git a/pkg/opengl/Texture.zig b/pkg/opengl/Texture.zig index fa5cf770b..2c8e05eff 100644 --- a/pkg/opengl/Texture.zig +++ b/pkg/opengl/Texture.zig @@ -7,15 +7,16 @@ const glad = @import("glad.zig"); id: c.GLuint, -pub fn active(target: c.GLenum) !void { - glad.context.ActiveTexture.?(target); +pub fn active(index: c_uint) errors.Error!void { + glad.context.ActiveTexture.?(index + c.GL_TEXTURE0); try errors.getError(); } /// Create a single texture. -pub fn create() !Texture { +pub fn create() errors.Error!Texture { var id: c.GLuint = undefined; glad.context.GenTextures.?(1, &id); + try errors.getError(); return .{ .id = id }; } @@ -30,7 +31,7 @@ pub fn destroy(v: Texture) void { glad.context.DeleteTextures.?(1, &v.id); } -/// Enun for possible texture binding targets. +/// Enum for possible texture binding targets. pub const Target = enum(c_uint) { @"1D" = c.GL_TEXTURE_1D, @"2D" = c.GL_TEXTURE_2D, @@ -67,11 +68,14 @@ pub const Parameter = enum(c_uint) { /// Internal format enum for texture images. pub const InternalFormat = enum(c_int) { red = c.GL_RED, - rgb = c.GL_RGB, - rgba = c.GL_RGBA, + rgb = c.GL_RGB8, + rgba = c.GL_RGBA8, - srgb = c.GL_SRGB, - srgba = c.GL_SRGB_ALPHA, + srgb = c.GL_SRGB8, + srgba = c.GL_SRGB8_ALPHA8, + + rgba_compressed = c.GL_COMPRESSED_RGBA_BPTC_UNORM, + srgba_compressed = c.GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM, // There are so many more that I haven't filled in. _, @@ -107,7 +111,7 @@ pub const Binding = struct { glad.context.GenerateMipmap.?(@intFromEnum(b.target)); } - pub fn parameter(b: Binding, name: Parameter, value: anytype) !void { + pub fn parameter(b: Binding, name: Parameter, value: anytype) errors.Error!void { switch (@TypeOf(value)) { c.GLint => glad.context.TexParameteri.?( @intFromEnum(b.target), @@ -116,6 +120,7 @@ pub const Binding = struct { ), else => unreachable, } + try errors.getError(); } pub fn image2D( @@ -124,22 +129,22 @@ pub const Binding = struct { internal_format: InternalFormat, width: c.GLsizei, height: c.GLsizei, - border: c.GLint, format: Format, typ: DataType, data: ?*const anyopaque, - ) !void { + ) errors.Error!void { glad.context.TexImage2D.?( @intFromEnum(b.target), level, @intFromEnum(internal_format), width, height, - border, + 0, @intFromEnum(format), @intFromEnum(typ), data, ); + try errors.getError(); } pub fn subImage2D( @@ -152,7 +157,7 @@ pub const Binding = struct { format: Format, typ: DataType, data: ?*const anyopaque, - ) !void { + ) errors.Error!void { glad.context.TexSubImage2D.?( @intFromEnum(b.target), level, @@ -164,6 +169,7 @@ pub const Binding = struct { @intFromEnum(typ), data, ); + try errors.getError(); } pub fn copySubImage2D( @@ -175,7 +181,17 @@ pub const Binding = struct { y: c.GLint, width: c.GLsizei, height: c.GLsizei, - ) !void { - glad.context.CopyTexSubImage2D.?(@intFromEnum(b.target), level, xoffset, yoffset, x, y, width, height); + ) errors.Error!void { + glad.context.CopyTexSubImage2D.?( + @intFromEnum(b.target), + level, + xoffset, + yoffset, + x, + y, + width, + height, + ); + try errors.getError(); } }; diff --git a/pkg/opengl/VertexArray.zig b/pkg/opengl/VertexArray.zig index 4a6a37576..44bf31621 100644 --- a/pkg/opengl/VertexArray.zig +++ b/pkg/opengl/VertexArray.zig @@ -29,4 +29,88 @@ pub const Binding = struct { _ = self; glad.context.BindVertexArray.?(0); } + + pub fn enableAttribArray(_: Binding, idx: c.GLuint) !void { + glad.context.EnableVertexAttribArray.?(idx); + try errors.getError(); + } + + pub fn bindingDivisor(_: Binding, idx: c.GLuint, divisor: c.GLuint) !void { + glad.context.VertexBindingDivisor.?(idx, divisor); + try errors.getError(); + } + + pub fn attributeBinding( + _: Binding, + attrib_idx: c.GLuint, + binding_idx: c.GLuint, + ) !void { + glad.context.VertexAttribBinding.?(attrib_idx, binding_idx); + try errors.getError(); + } + + pub fn attributeFormat( + _: Binding, + idx: c.GLuint, + size: c.GLint, + typ: c.GLenum, + normalized: bool, + offset: c.GLuint, + ) !void { + glad.context.VertexAttribFormat.?( + idx, + size, + typ, + @intCast(@intFromBool(normalized)), + offset, + ); + try errors.getError(); + } + + pub fn attributeIFormat( + _: Binding, + idx: c.GLuint, + size: c.GLint, + typ: c.GLenum, + offset: c.GLuint, + ) !void { + glad.context.VertexAttribIFormat.?( + idx, + size, + typ, + offset, + ); + try errors.getError(); + } + + pub fn attributeLFormat( + _: Binding, + idx: c.GLuint, + size: c.GLint, + offset: c.GLuint, + ) !void { + glad.context.VertexAttribLFormat.?( + idx, + size, + c.GL_DOUBLE, + offset, + ); + try errors.getError(); + } + + pub fn bindVertexBuffer( + _: Binding, + idx: c.GLuint, + buffer: c.GLuint, + offset: c.GLintptr, + stride: c.GLsizei, + ) !void { + glad.context.BindVertexBuffer.?( + idx, + buffer, + offset, + stride, + ); + try errors.getError(); + } }; diff --git a/pkg/opengl/draw.zig b/pkg/opengl/draw.zig index 866511c32..50110f605 100644 --- a/pkg/opengl/draw.zig +++ b/pkg/opengl/draw.zig @@ -1,6 +1,7 @@ const c = @import("c.zig").c; const errors = @import("errors.zig"); const glad = @import("glad.zig"); +const Primitive = @import("primitives.zig").Primitive; pub fn clearColor(r: f32, g: f32, b: f32, a: f32) void { glad.context.ClearColor.?(r, g, b, a); @@ -15,6 +16,21 @@ pub fn drawArrays(mode: c.GLenum, first: c.GLint, count: c.GLsizei) !void { try errors.getError(); } +pub fn drawArraysInstanced( + mode: Primitive, + first: c.GLint, + count: c.GLsizei, + primcount: c.GLsizei, +) !void { + glad.context.DrawArraysInstanced.?( + @intCast(@intFromEnum(mode)), + first, + count, + primcount, + ); + try errors.getError(); +} + pub fn drawElements(mode: c.GLenum, count: c.GLsizei, typ: c.GLenum, offset: usize) !void { const offsetPtr = if (offset == 0) null else @as(*const anyopaque, @ptrFromInt(offset)); glad.context.DrawElements.?(mode, count, typ, offsetPtr); @@ -25,9 +41,15 @@ pub fn drawElementsInstanced( mode: c.GLenum, count: c.GLsizei, typ: c.GLenum, - primcount: usize, + primcount: c.GLsizei, ) !void { - glad.context.DrawElementsInstanced.?(mode, count, typ, null, @intCast(primcount)); + glad.context.DrawElementsInstanced.?( + mode, + count, + typ, + null, + primcount, + ); try errors.getError(); } @@ -36,6 +58,11 @@ pub fn enable(cap: c.GLenum) !void { try errors.getError(); } +pub fn disable(cap: c.GLenum) !void { + glad.context.Disable.?(cap); + try errors.getError(); +} + pub fn frontFace(mode: c.GLenum) !void { glad.context.FrontFace.?(mode); try errors.getError(); @@ -57,3 +84,11 @@ pub fn pixelStore(mode: c.GLenum, value: anytype) !void { } try errors.getError(); } + +pub fn finish() void { + glad.context.Finish.?(); +} + +pub fn flush() void { + glad.context.Flush.?(); +} diff --git a/pkg/opengl/main.zig b/pkg/opengl/main.zig index 19cd750d0..7165ad3ab 100644 --- a/pkg/opengl/main.zig +++ b/pkg/opengl/main.zig @@ -16,20 +16,29 @@ pub const glad = @import("glad.zig"); pub const ext = @import("extensions.zig"); pub const Buffer = @import("Buffer.zig"); pub const Framebuffer = @import("Framebuffer.zig"); +pub const Renderbuffer = @import("Renderbuffer.zig"); pub const Program = @import("Program.zig"); pub const Shader = @import("Shader.zig"); pub const Texture = @import("Texture.zig"); pub const VertexArray = @import("VertexArray.zig"); +pub const errors = @import("errors.zig"); + +pub const Primitive = @import("primitives.zig").Primitive; + const draw = @import("draw.zig"); pub const blendFunc = draw.blendFunc; pub const clear = draw.clear; pub const clearColor = draw.clearColor; pub const drawArrays = draw.drawArrays; +pub const drawArraysInstanced = draw.drawArraysInstanced; pub const drawElements = draw.drawElements; pub const drawElementsInstanced = draw.drawElementsInstanced; pub const enable = draw.enable; +pub const disable = draw.disable; pub const frontFace = draw.frontFace; pub const pixelStore = draw.pixelStore; pub const viewport = draw.viewport; +pub const flush = draw.flush; +pub const finish = draw.finish; diff --git a/pkg/opengl/primitives.zig b/pkg/opengl/primitives.zig new file mode 100644 index 000000000..e12f51a66 --- /dev/null +++ b/pkg/opengl/primitives.zig @@ -0,0 +1,18 @@ +pub const c = @import("c.zig").c; + +pub const Primitive = enum(c_int) { + point = c.GL_POINTS, + line = c.GL_LINES, + line_strip = c.GL_LINE_STRIP, + triangle = c.GL_TRIANGLES, + triangle_strip = c.GL_TRIANGLE_STRIP, + + // Commented out primitive types are excluded for parity with Metal. + // + // line_loop = c.GL_LINE_LOOP, + // line_adjacency = c.GL_LINES_ADJACENCY, + // line_strip_adjacency = c.GL_LINE_STRIP_ADJACENCY, + // triangle_fan = c.GL_TRIANGLE_FAN, + // triangle_adjacency = c.GL_TRIANGLES_ADJACENCY, + // triangle_strip_adjacency = c.GL_TRIANGLE_STRIP_ADJACENCY, +}; diff --git a/pkg/sentry/build.zig b/pkg/sentry/build.zig index 3c0019710..0e6993ad4 100644 --- a/pkg/sentry/build.zig +++ b/pkg/sentry/build.zig @@ -20,8 +20,7 @@ pub fn build(b: *std.Build) !void { lib.linkLibC(); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/simdutf/build.zig b/pkg/simdutf/build.zig index 859653443..30de40fea 100644 --- a/pkg/simdutf/build.zig +++ b/pkg/simdutf/build.zig @@ -14,7 +14,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/spirv-cross/build.zig b/pkg/spirv-cross/build.zig index c7d0d2039..ff67e3e72 100644 --- a/pkg/spirv-cross/build.zig +++ b/pkg/spirv-cross/build.zig @@ -44,7 +44,7 @@ fn buildSpirvCross( lib.linkLibCpp(); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/utfcpp/build.zig b/pkg/utfcpp/build.zig index 6b80fec7b..8e1a3cb20 100644 --- a/pkg/utfcpp/build.zig +++ b/pkg/utfcpp/build.zig @@ -13,7 +13,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/wuffs/build.zig b/pkg/wuffs/build.zig index d47771c22..4d144e76a 100644 --- a/pkg/wuffs/build.zig +++ b/pkg/wuffs/build.zig @@ -11,11 +11,6 @@ pub fn build(b: *std.Build) !void { .link_libc = true, }); - if (target.result.os.tag.isDarwin()) { - const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, module); - } - const unit_tests = b.addTest(.{ .root_source_file = b.path("src/main.zig"), .target = target, diff --git a/pkg/wuffs/src/main.zig b/pkg/wuffs/src/main.zig index f282261c2..89f3c008c 100644 --- a/pkg/wuffs/src/main.zig +++ b/pkg/wuffs/src/main.zig @@ -3,11 +3,12 @@ const std = @import("std"); pub const png = @import("png.zig"); pub const jpeg = @import("jpeg.zig"); pub const swizzle = @import("swizzle.zig"); +pub const Error = @import("error.zig").Error; pub const ImageData = struct { width: u32, height: u32, - data: []const u8, + data: []u8, }; test { diff --git a/pkg/wuffs/src/swizzle.zig b/pkg/wuffs/src/swizzle.zig index d57da98a9..352cf2b50 100644 --- a/pkg/wuffs/src/swizzle.zig +++ b/pkg/wuffs/src/swizzle.zig @@ -33,6 +33,24 @@ pub fn rgbToRgba(alloc: Allocator, src: []const u8) Error![]u8 { ); } +pub fn bgrToRgba(alloc: Allocator, src: []const u8) Error![]u8 { + return swizzle( + alloc, + src, + c.WUFFS_BASE__PIXEL_FORMAT__BGR, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + ); +} + +pub fn bgraToRgba(alloc: Allocator, src: []const u8) Error![]u8 { + return swizzle( + alloc, + src, + c.WUFFS_BASE__PIXEL_FORMAT__BGRA_PREMUL, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + ); +} + fn swizzle( alloc: Allocator, src: []const u8, diff --git a/pkg/zlib/build.zig b/pkg/zlib/build.zig index 28ae62424..28344c989 100644 --- a/pkg/zlib/build.zig +++ b/pkg/zlib/build.zig @@ -12,7 +12,7 @@ pub fn build(b: *std.Build) !void { lib.linkLibC(); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } if (b.lazyDependency("zlib", .{})) |upstream| { diff --git a/po/ca_ES.UTF-8.po b/po/ca_ES.UTF-8.po index 712f0d5af..653439fa2 100644 --- a/po/ca_ES.UTF-8.po +++ b/po/ca_ES.UTF-8.po @@ -1,5 +1,5 @@ # Catalan translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Francesc Arpi , 2025. # diff --git a/po/com.mitchellh.ghostty.pot b/po/com.mitchellh.ghostty.pot index d6a99d01d..7691f91b5 100644 --- a/po/com.mitchellh.ghostty.pot +++ b/po/com.mitchellh.ghostty.pot @@ -1,5 +1,5 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR Mitchell Hashimoto +# Copyright (C) YEAR "Mitchell Hashimoto, Ghostty contributors" # This file is distributed under the same license as the com.mitchellh.ghostty package. # FIRST AUTHOR , YEAR. # @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2025-04-23 16:58+0800\n" +"POT-Creation-Date: 2025-06-28 17:01+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -26,7 +26,8 @@ msgid "Leave blank to restore the default title." msgstr "" #: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/CloseDialog.zig:44 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/ui/1.2/ccw-paste.blp:10 +#: src/apprt/gtk/CloseDialog.zig:44 msgid "Cancel" msgstr "" @@ -35,22 +36,26 @@ msgid "OK" msgstr "" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:5 msgid "Configuration Errors" msgstr "" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." msgstr "" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:9 msgid "Ignore" msgstr "" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 msgid "Reload Configuration" msgstr "" @@ -89,7 +94,7 @@ msgstr "" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:11 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:11 -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 src/apprt/gtk/ui/1.2/ccw-paste.blp:11 msgid "Paste" msgstr "" @@ -119,7 +124,7 @@ msgstr "" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 -#: src/apprt/gtk/Window.zig:255 +#: src/apprt/gtk/Window.zig:263 msgid "New Tab" msgstr "" @@ -160,7 +165,7 @@ msgid "Terminal Inspector" msgstr "" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 -#: src/apprt/gtk/Window.zig:1024 +#: src/apprt/gtk/Window.zig:1036 msgid "About Ghostty" msgstr "" @@ -170,10 +175,13 @@ msgstr "" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:6 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:6 msgid "Authorize Clipboard Access" msgstr "" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:7 msgid "" "An application is attempting to read from the clipboard. The current " "clipboard contents are shown below." @@ -181,52 +189,67 @@ msgstr "" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:10 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:10 msgid "Deny" msgstr "" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:11 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:11 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:11 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:11 msgid "Allow" msgstr "" +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:81 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:77 +msgid "Remember choice for this split" +msgstr "" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 +msgid "Reload configuration to show this prompt again" +msgstr "" + #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 msgid "" "An application is attempting to write to the clipboard. The current " "clipboard contents are shown below." msgstr "" -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 src/apprt/gtk/ui/1.2/ccw-paste.blp:6 msgid "Warning: Potentially Unsafe Paste" msgstr "" -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 src/apprt/gtk/ui/1.2/ccw-paste.blp:7 msgid "" "Pasting this text into the terminal may be dangerous as it looks like some " "commands may be executed." msgstr "" -#: src/apprt/gtk/Window.zig:208 +#: src/apprt/gtk/Window.zig:216 msgid "Main Menu" msgstr "" -#: src/apprt/gtk/Window.zig:229 +#: src/apprt/gtk/Window.zig:238 msgid "View Open Tabs" msgstr "" -#: src/apprt/gtk/Window.zig:256 +#: src/apprt/gtk/Window.zig:264 msgid "New Split" msgstr "" -#: src/apprt/gtk/Window.zig:319 +#: src/apprt/gtk/Window.zig:327 msgid "" "⚠️ You're running a debug build of Ghostty! Performance will be degraded." msgstr "" -#: src/apprt/gtk/Window.zig:765 +#: src/apprt/gtk/Window.zig:773 msgid "Reloaded the configuration" msgstr "" -#: src/apprt/gtk/Window.zig:1005 +#: src/apprt/gtk/Window.zig:1017 msgid "Ghostty Developers" msgstr "" @@ -270,6 +293,6 @@ msgstr "" msgid "The currently running process in this split will be terminated." msgstr "" -#: src/apprt/gtk/Surface.zig:1243 +#: src/apprt/gtk/Surface.zig:1257 msgid "Copied to clipboard" msgstr "" diff --git a/po/de_DE.UTF-8.po b/po/de_DE.UTF-8.po index 44f3bae39..2d3b96d81 100644 --- a/po/de_DE.UTF-8.po +++ b/po/de_DE.UTF-8.po @@ -1,6 +1,6 @@ # German translations for com.mitchellh.ghostty package # German translation for com.mitchellh.ghostty. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Robin Pfäffle , 2025. # diff --git a/po/es_BO.UTF-8.po b/po/es_BO.UTF-8.po index f3a62748a..077b7dfa1 100644 --- a/po/es_BO.UTF-8.po +++ b/po/es_BO.UTF-8.po @@ -1,5 +1,5 @@ # Spanish translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Miguel Peredo , 2025. # diff --git a/po/fr_FR.UTF-8.po b/po/fr_FR.UTF-8.po index 4db72a23e..aef0d96ac 100644 --- a/po/fr_FR.UTF-8.po +++ b/po/fr_FR.UTF-8.po @@ -1,5 +1,5 @@ # French translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Kirwiisp , 2025. # diff --git a/po/id_ID.UTF-8.po b/po/id_ID.UTF-8.po index d5204d420..f82ec6197 100644 --- a/po/id_ID.UTF-8.po +++ b/po/id_ID.UTF-8.po @@ -1,5 +1,5 @@ # Indonesian translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Satrio Bayu Aji , 2025. # diff --git a/po/ja_JP.UTF-8.po b/po/ja_JP.UTF-8.po index e6e015f8a..73ddd9f5a 100644 --- a/po/ja_JP.UTF-8.po +++ b/po/ja_JP.UTF-8.po @@ -1,6 +1,6 @@ # Japanese translations for com.mitchellh.ghostty package # com.mitchellh.ghostty パッケージに対する和訳. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Lon Sagisawa , 2025. # diff --git a/po/mk_MK.UTF-8.po b/po/mk_MK.UTF-8.po index 39bb72b91..20a43572e 100644 --- a/po/mk_MK.UTF-8.po +++ b/po/mk_MK.UTF-8.po @@ -1,5 +1,5 @@ # Macedonian translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Andrej Daskalov , 2025. # diff --git a/po/nb_NO.UTF-8.po b/po/nb_NO.UTF-8.po index ad76eea3d..045d47a80 100644 --- a/po/nb_NO.UTF-8.po +++ b/po/nb_NO.UTF-8.po @@ -1,5 +1,5 @@ # Norwegian Bokmal translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Hanna Rose , 2025. # Uzair Aftab , 2025. @@ -63,25 +63,25 @@ msgstr "Last konfigurasjon på nytt" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:38 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:50 msgid "Split Up" -msgstr "Splitt opp" +msgstr "Del oppover" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:11 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:43 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:55 msgid "Split Down" -msgstr "Splitt ned" +msgstr "Del nedover" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:16 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:48 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:60 msgid "Split Left" -msgstr "Splitt venstre" +msgstr "Del til venstre" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:21 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:53 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:65 msgid "Split Right" -msgstr "Splitt høyre" +msgstr "Del til høyre" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 @@ -107,7 +107,7 @@ msgstr "Nullstill" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:30 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:42 msgid "Split" -msgstr "Splitt" +msgstr "Del vindu" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:33 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:45 @@ -218,7 +218,7 @@ msgstr "Se åpne faner" #: src/apprt/gtk/Window.zig:249 msgid "New Split" -msgstr "" +msgstr "Del opp vindu" #: src/apprt/gtk/Window.zig:312 msgid "" @@ -251,7 +251,7 @@ msgstr "Lukk fane?" #: src/apprt/gtk/CloseDialog.zig:90 msgid "Close Split?" -msgstr "Lukk splitt?" +msgstr "Lukk delt vindu?" #: src/apprt/gtk/CloseDialog.zig:96 msgid "All terminal sessions will be terminated." diff --git a/po/nl_NL.UTF-8.po b/po/nl_NL.UTF-8.po index 466116352..355bc4a57 100644 --- a/po/nl_NL.UTF-8.po +++ b/po/nl_NL.UTF-8.po @@ -1,5 +1,5 @@ # Dutch translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Nico Geesink , 2025. # diff --git a/po/pl_PL.UTF-8.po b/po/pl_PL.UTF-8.po index 22d2cd975..a68d56818 100644 --- a/po/pl_PL.UTF-8.po +++ b/po/pl_PL.UTF-8.po @@ -1,6 +1,6 @@ # Polish translations for com.mitchellh.ghostty package # Polskie tłumaczenia dla pakietu com.mitchellh.ghostty. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Bartosz Sokorski , 2025. # diff --git a/po/pt_BR.UTF-8.po b/po/pt_BR.UTF-8.po index f6d2f26a2..ba13f4460 100644 --- a/po/pt_BR.UTF-8.po +++ b/po/pt_BR.UTF-8.po @@ -1,6 +1,6 @@ # Portuguese translations for com.mitchellh.ghostty package # Traduções em português brasileiro para o pacote com.mitchellh.ghostty. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Gustavo Peres , 2025. # @@ -9,8 +9,8 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2025-04-22 08:57-0700\n" -"PO-Revision-Date: 2025-03-28 11:04-0300\n" -"Last-Translator: Gustavo Peres \n" +"PO-Revision-Date: 2025-06-20 10:19-0300\n" +"Last-Translator: Mário Victor Ribeiro Silva \n" "Language-Team: Brazilian Portuguese \n" "Language: pt_BR\n" @@ -217,7 +217,7 @@ msgstr "Visualizar abas abertas" #: src/apprt/gtk/Window.zig:249 msgid "New Split" -msgstr "" +msgstr "Nova divisão" #: src/apprt/gtk/Window.zig:312 msgid "" diff --git a/po/ru_RU.UTF-8.po b/po/ru_RU.UTF-8.po index 9e9cf8077..0cb533de7 100644 --- a/po/ru_RU.UTF-8.po +++ b/po/ru_RU.UTF-8.po @@ -1,6 +1,6 @@ # Russian translations for com.mitchellh.ghostty package # Русские переводы для пакета com.mitchellh.ghostty. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # blackzeshi , 2025. # diff --git a/po/tr_TR.UTF-8.po b/po/tr_TR.UTF-8.po index 3de70d61c..5d761f6a4 100644 --- a/po/tr_TR.UTF-8.po +++ b/po/tr_TR.UTF-8.po @@ -1,5 +1,5 @@ # Turkish translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Emir SARI , 2025. # diff --git a/po/uk_UA.UTF-8.po b/po/uk_UA.UTF-8.po index 5a264b537..bde975fc4 100644 --- a/po/uk_UA.UTF-8.po +++ b/po/uk_UA.UTF-8.po @@ -1,5 +1,5 @@ # Ukrainian translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Danylo Zalizchuk , 2025. # diff --git a/po/zh_CN.UTF-8.po b/po/zh_CN.UTF-8.po index ee2c51362..17a6dc921 100644 --- a/po/zh_CN.UTF-8.po +++ b/po/zh_CN.UTF-8.po @@ -1,6 +1,6 @@ # Chinese translations for com.mitchellh.ghostty package # com.mitchellh.ghostty 软件包的简体中文翻译. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Leah , 2025. # @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2025-04-23 16:58+0800\n" +"POT-Creation-Date: 2025-06-28 17:01+0200\n" "PO-Revision-Date: 2025-02-27 09:16+0100\n" "Last-Translator: Leah \n" "Language-Team: Chinese (simplified) \n" @@ -26,7 +26,8 @@ msgid "Leave blank to restore the default title." msgstr "留空以重置至默认标题。" #: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/CloseDialog.zig:44 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/ui/1.2/ccw-paste.blp:10 +#: src/apprt/gtk/CloseDialog.zig:44 msgid "Cancel" msgstr "取消" @@ -35,10 +36,12 @@ msgid "OK" msgstr "确认" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:5 msgid "Configuration Errors" msgstr "配置错误" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -46,12 +49,14 @@ msgstr "" "加载配置时发现了以下错误。请仔细阅读错误信息,并选择忽略或重新加载配置文件。" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:9 msgid "Ignore" msgstr "忽略" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 msgid "Reload Configuration" msgstr "重新加载配置" @@ -90,7 +95,7 @@ msgstr "复制" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:11 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:11 -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 src/apprt/gtk/ui/1.2/ccw-paste.blp:11 msgid "Paste" msgstr "粘贴" @@ -120,7 +125,7 @@ msgstr "标签页" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 -#: src/apprt/gtk/Window.zig:255 +#: src/apprt/gtk/Window.zig:263 msgid "New Tab" msgstr "新建标签页" @@ -161,7 +166,7 @@ msgid "Terminal Inspector" msgstr "终端调试器" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 -#: src/apprt/gtk/Window.zig:1024 +#: src/apprt/gtk/Window.zig:1036 msgid "About Ghostty" msgstr "关于 Ghostty" @@ -171,10 +176,13 @@ msgstr "退出" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:6 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:6 msgid "Authorize Clipboard Access" msgstr "剪贴板访问授权" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:7 msgid "" "An application is attempting to read from the clipboard. The current " "clipboard contents are shown below." @@ -182,52 +190,67 @@ msgstr "一个应用正在试图从剪贴板读取内容。剪贴板目前的内 #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:10 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:10 msgid "Deny" msgstr "拒绝" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:11 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:11 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:11 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:11 msgid "Allow" msgstr "允许" +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:81 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:77 +msgid "Remember choice for this split" +msgstr "为本分屏记住当前选择" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 +msgid "Reload configuration to show this prompt again" +msgstr "本提示将在重载配置后再次出现" + #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 msgid "" "An application is attempting to write to the clipboard. The current " "clipboard contents are shown below." msgstr "一个应用正在试图向剪贴板写入内容。剪贴板目前的内容如下:" -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 src/apprt/gtk/ui/1.2/ccw-paste.blp:6 msgid "Warning: Potentially Unsafe Paste" msgstr "警告:粘贴内容可能不安全" -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 src/apprt/gtk/ui/1.2/ccw-paste.blp:7 msgid "" "Pasting this text into the terminal may be dangerous as it looks like some " "commands may be executed." msgstr "将以下内容粘贴至终端内将可能执行有害命令。" -#: src/apprt/gtk/Window.zig:208 +#: src/apprt/gtk/Window.zig:216 msgid "Main Menu" msgstr "主菜单" -#: src/apprt/gtk/Window.zig:229 +#: src/apprt/gtk/Window.zig:238 msgid "View Open Tabs" msgstr "浏览标签页" -#: src/apprt/gtk/Window.zig:256 +#: src/apprt/gtk/Window.zig:264 msgid "New Split" msgstr "新建分屏" -#: src/apprt/gtk/Window.zig:319 +#: src/apprt/gtk/Window.zig:327 msgid "" "⚠️ You're running a debug build of Ghostty! Performance will be degraded." msgstr "⚠️ Ghostty 正在以调试模式运行!性能将大打折扣。" -#: src/apprt/gtk/Window.zig:765 +#: src/apprt/gtk/Window.zig:773 msgid "Reloaded the configuration" msgstr "已重新加载配置" -#: src/apprt/gtk/Window.zig:1005 +#: src/apprt/gtk/Window.zig:1017 msgid "Ghostty Developers" msgstr "Ghostty 开发团队" @@ -271,6 +294,6 @@ msgstr "标签页内所有运行中的进程将被终止。" msgid "The currently running process in this split will be terminated." msgstr "分屏内正在运行中的进程将被终止。" -#: src/apprt/gtk/Surface.zig:1243 +#: src/apprt/gtk/Surface.zig:1257 msgid "Copied to clipboard" msgstr "已复制至剪贴板" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index b57411a6c..df8d6ae53 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -72,17 +72,16 @@ parts: build-packages: - libgtk-4-dev - libadwaita-1-dev - # TODO: Add when the Snap is updated to Ubuntu 24.10+ - # - gtk4-layer-shell - libxml2-utils - git - patchelf - gettext + # TODO: Remove -fno-sys=gtk4-layer-shell when we upgrade to a version that packages it Ubuntu 24.10+ override-build: | craftctl set version=$(cat VERSION) - $CRAFT_PART_SRC/../../zig/src/zig build -Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR -Doptimize=ReleaseFast -Dcpu=baseline + $CRAFT_PART_SRC/../../zig/src/zig build -Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR -Doptimize=ReleaseFast -Dcpu=baseline -fno-sys=gtk4-layer-shell cp -rp zig-out/* $CRAFT_PART_INSTALL/ - sed -i 's|Icon=com.mitchellh.ghostty|Icon=/snap/ghostty/current/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png|g' $CRAFT_PART_INSTALL/share/applications/com.mitchellh.ghostty.desktop + sed -i 's|Icon=com.mitchellh.ghostty|Icon=${SNAP}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png|g' $CRAFT_PART_INSTALL/share/applications/com.mitchellh.ghostty.desktop libs: plugin: nil diff --git a/src/App.zig b/src/App.zig index 005b745a6..02089ae5b 100644 --- a/src/App.zig +++ b/src/App.zig @@ -76,34 +76,38 @@ first: bool = true, pub const CreateError = Allocator.Error || font.SharedGridSet.InitError; +/// Create a new app instance. This returns a stable pointer to the app +/// instance which is required for callbacks. +pub fn create(alloc: Allocator) CreateError!*App { + var app = try alloc.create(App); + errdefer alloc.destroy(app); + try app.init(alloc); + return app; +} + /// Initialize the main app instance. This creates the main window, sets /// up the renderer state, compiles the shaders, etc. This is the primary /// "startup" logic. /// /// After calling this function, well behaved apprts should then call /// `focusEvent` to set the initial focus state of the app. -pub fn create( +pub fn init( + self: *App, alloc: Allocator, -) CreateError!*App { - var app = try alloc.create(App); - errdefer alloc.destroy(app); - +) CreateError!void { var font_grid_set = try font.SharedGridSet.init(alloc); errdefer font_grid_set.deinit(); - app.* = .{ + self.* = .{ .alloc = alloc, .surfaces = .{}, .mailbox = .{}, .font_grid_set = font_grid_set, .config_conditional_state = .{}, }; - errdefer app.surfaces.deinit(alloc); - - return app; } -pub fn destroy(self: *App) void { +pub fn deinit(self: *App) void { // Clean up all our surfaces for (self.surfaces.items) |surface| surface.deinit(); self.surfaces.deinit(self.alloc); @@ -114,7 +118,13 @@ pub fn destroy(self: *App) void { // should gracefully close all surfaces. assert(self.font_grid_set.count() == 0); self.font_grid_set.deinit(); +} +pub fn destroy(self: *App) void { + // Deinitialize the app + self.deinit(); + + // Free the app memory self.alloc.destroy(self); } @@ -445,6 +455,10 @@ pub fn performAction( .toggle_quick_terminal => _ = try rt_app.performAction(.app, .toggle_quick_terminal, {}), .toggle_visibility => _ = try rt_app.performAction(.app, .toggle_visibility, {}), .check_for_updates => _ = try rt_app.performAction(.app, .check_for_updates, {}), + .show_gtk_inspector => _ = try rt_app.performAction(.app, .show_gtk_inspector, {}), + .undo => _ = try rt_app.performAction(.app, .undo, {}), + + .redo => _ = try rt_app.performAction(.app, .redo, {}), } } diff --git a/src/Command.zig b/src/Command.zig index e17c1b370..7ed026efe 100644 --- a/src/Command.zig +++ b/src/Command.zig @@ -323,7 +323,7 @@ fn setupFd(src: File.Handle, target: i32) !void { } } }, - .ios, .macos => { + .freebsd, .ios, .macos => { // Mac doesn't support dup3 so we use dup2. We purposely clear // CLO_ON_EXEC for this fd. const flags = try posix.fcntl(src, posix.F.GETFD, 0); @@ -370,7 +370,7 @@ pub fn wait(self: Command, block: bool) !Exit { } }; - return Exit.init(res.status); + return .init(res.status); } /// Sets command->data to data. diff --git a/src/Surface.zig b/src/Surface.zig index f9e232340..5acec8c00 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -160,7 +160,7 @@ pub const InputEffect = enum { /// Mouse state for the surface. const Mouse = struct { /// The last tracked mouse button state by button. - click_state: [input.MouseButton.max]input.MouseButtonState = .{.release} ** input.MouseButton.max, + click_state: [input.MouseButton.max]input.MouseButtonState = @splat(.release), /// The last mods state when the last mouse button (whatever it was) was /// pressed or release. @@ -237,6 +237,7 @@ const DerivedConfig = struct { /// For docs for these, see the associated config they are derived from. original_font_size: f32, keybind: configpkg.Keybinds, + abnormal_command_exit_runtime_ms: u32, clipboard_read: configpkg.ClipboardAccess, clipboard_write: configpkg.ClipboardAccess, clipboard_trim_trailing_spaces: bool, @@ -255,6 +256,7 @@ const DerivedConfig = struct { macos_option_as_alt: ?configpkg.OptionAsAlt, selection_clear_on_typing: bool, vt_kam_allowed: bool, + wait_after_command: bool, window_padding_top: u32, window_padding_bottom: u32, window_padding_left: u32, @@ -301,6 +303,7 @@ const DerivedConfig = struct { return .{ .original_font_size = config.@"font-size", .keybind = try config.keybind.clone(alloc), + .abnormal_command_exit_runtime_ms = config.@"abnormal-command-exit-runtime", .clipboard_read = config.@"clipboard-read", .clipboard_write = config.@"clipboard-write", .clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces", @@ -319,6 +322,7 @@ const DerivedConfig = struct { .macos_option_as_alt = config.@"macos-option-as-alt", .selection_clear_on_typing = config.@"selection-clear-on-typing", .vt_kam_allowed = config.@"vt-kam-allowed", + .wait_after_command = config.@"wait-after-command", .window_padding_top = config.@"window-padding-y".top_left, .window_padding_bottom = config.@"window-padding-y".bottom_right, .window_padding_left = config.@"window-padding-x".top_left, @@ -463,11 +467,12 @@ pub fn init( // Create our terminal grid with the initial size const app_mailbox: App.Mailbox = .{ .rt_app = rt_app, .mailbox = &app.mailbox }; var renderer_impl = try Renderer.init(alloc, .{ - .config = try Renderer.DerivedConfig.init(alloc, config), + .config = try .init(alloc, config), .font_grid = font_grid, .size = size, .surface_mailbox = .{ .surface = self, .app = app_mailbox }, .rt_surface = rt_surface, + .thread = &self.renderer_thread, }); errdefer renderer_impl.deinit(); @@ -545,7 +550,7 @@ pub fn init( .shell_integration = config.@"shell-integration", .shell_integration_features = config.@"shell-integration-features", .working_directory = config.@"working-directory", - .resources_dir = global_state.resources_dir, + .resources_dir = global_state.resources_dir.host(), .term = config.term, // Get the cgroup if we're on linux and have the decl. I'd love @@ -726,7 +731,9 @@ pub fn close(self: *Surface) void { /// is in the middle of animation (such as a resize, etc.) or when /// the render timer is managed manually by the apprt. pub fn draw(self: *Surface) !void { - try self.renderer_thread.draw_now.notify(); + // Renderers are required to support `drawFrame` being called from + // the main thread, so that they can update contents during resize. + try self.renderer.drawFrame(true); } /// Activate the inspector. This will begin collecting inspection data. @@ -908,11 +915,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .close => self.close(), - // Close without confirmation. - .child_exited => { - self.child_exited = true; - self.close(); - }, + .child_exited => |v| self.childExited(v), .desktop_notification => |notification| { if (!self.config.desktop_notifications) { @@ -945,6 +948,136 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { } } +fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { + // Mark our flag that we exited immediately + self.child_exited = true; + + // If our runtime was below some threshold then we assume that this + // was an abnormal exit and we show an error message. + if (info.runtime_ms <= self.config.abnormal_command_exit_runtime_ms) runtime: { + // On macOS, our exit code detection doesn't work, possibly + // because of our `login` wrapper. More investigation required. + if (comptime builtin.target.os.tag.isDarwin()) break :runtime; + + // If the exit code is 0 then we it was a good exit. + if (info.exit_code == 0) break :runtime; + log.warn("abnormal process exit detected, showing error message", .{}); + + // Update our terminal to note the abnormal exit. In the future we + // may want the apprt to handle this to show some native GUI element. + self.childExitedAbnormally(info) catch |err| { + log.err("error handling abnormal child exit err={}", .{err}); + return; + }; + + return; + } + + // We output a message so that the user knows whats going on and + // doesn't think their terminal just froze. We show this unconditionally + // on close even if `wait_after_command` is false and the surface closes + // immediately because if a user does an `undo` to restore a closed + // surface then they will see this message and know the process has + // completed. + terminal: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + t.carriageReturn(); + t.linefeed() catch break :terminal; + t.printString("Process exited. Press any key to close the terminal.") catch + break :terminal; + t.modes.set(.cursor_visible, false); + } + + // Waiting after command we stop here. The terminal is updated, our + // state is updated, and now its up to the user to decide what to do. + if (self.config.wait_after_command) return; + + // If we aren't waiting after the command, then we exit immediately + // with no confirmation. + self.close(); +} + +/// Called when the child process exited abnormally. +fn childExitedAbnormally( + self: *Surface, + info: apprt.surface.Message.ChildExited, +) !void { + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Build up our command for the error message + const command = try std.mem.join(alloc, " ", switch (self.io.backend) { + .exec => |*exec| exec.subprocess.args, + }); + const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{info.runtime_ms}); + + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + + // No matter what move the cursor back to the column 0. + t.carriageReturn(); + + // Reset styles + try t.setAttribute(.{ .unset = {} }); + + // If there is data in the viewport, we want to scroll down + // a little bit and write a horizontal rule before writing + // our message. This lets the use see the error message the + // command may have output. + const viewport_str = try t.plainString(alloc); + if (viewport_str.len > 0) { + try t.linefeed(); + for (0..t.cols) |_| try t.print(0x2501); + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + } + + // Output our error message + try t.setAttribute(.{ .@"8_fg" = .bright_red }); + try t.setAttribute(.{ .bold = {} }); + try t.printString("Ghostty failed to launch the requested command:"); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString(command); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Runtime: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(runtime_str); + try t.setAttribute(.{ .unset = {} }); + + // We don't print this on macOS because the exit code is always 0 + // due to the way we launch the process. + if (comptime !builtin.target.os.tag.isDarwin()) { + const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{info.exit_code}); + t.carriageReturn(); + try t.linefeed(); + try t.printString("Exit Code: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(exit_code_str); + try t.setAttribute(.{ .unset = {} }); + } + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Press any key to close the window."); + + // Hide the cursor + t.modes.set(.cursor_visible, false); +} + /// Called when the terminal detects there is a password input prompt. fn passwordInput(self: *Surface, v: bool) !void { { @@ -1292,6 +1425,133 @@ fn recomputeInitialSize( ) catch return error.AppActionFailed; } +/// Represents text read from the terminal and some metadata about it +/// that is often useful to apprts. +pub const Text = struct { + /// The text that was read from the terminal. + text: [:0]const u8, + + /// The viewport information about this text, if it is visible in + /// the viewport. + /// + /// NOTE(mitchellh): This will only be non-null currently if the entirety + /// of the selection is contained within the viewport. We don't have a + /// use case currently for partial bounds but we should support this + /// eventually. + viewport: ?Viewport = null, + + pub const Viewport = struct { + /// The top-left corner of the selection in pixels within the viewport. + tl_px_x: f64, + tl_px_y: f64, + + /// The linear offset of the start of the selection and the length. + /// This is "linear" in the sense that it is the offset in the + /// flattened viewport as a single array of text. + offset_start: u32, + offset_len: u32, + }; + + pub fn deinit(self: *Text, alloc: Allocator) void { + alloc.free(self.text); + } +}; + +/// Grab the value of text at the given selection point. Note that the +/// selection structure is used as a way to determine the area of the +/// screen to read from, it doesn't have to match the user's current +/// selection state. +/// +/// The returned value contains allocated data and must be deinitialized. +pub fn dumpText( + self: *Surface, + alloc: Allocator, + sel: terminal.Selection, +) !Text { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + return try self.dumpTextLocked(alloc, sel); +} + +/// Same as `dumpText` but assumes the renderer state mutex is already +/// held. +pub fn dumpTextLocked( + self: *Surface, + alloc: Allocator, + sel: terminal.Selection, +) !Text { + // Read out the text + const text = try self.io.terminal.screen.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + errdefer alloc.free(text); + + // Calculate our viewport info if we can. + const vp: ?Text.Viewport = viewport: { + // If our tl or br is not in the viewport then we don't + // have a viewport. One day we should extend this to support + // partial selections that are in the viewport. + const tl_pt = self.io.terminal.screen.pages.pointFromPin( + .viewport, + sel.topLeft(&self.io.terminal.screen), + ) orelse break :viewport null; + const br_pt = self.io.terminal.screen.pages.pointFromPin( + .viewport, + sel.bottomRight(&self.io.terminal.screen), + ) orelse break :viewport null; + const tl_coord = tl_pt.coord(); + const br_coord = br_pt.coord(); + + // Our sizes are all scaled so we need to send the unscaled values back. + const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 }; + const x: f64 = x: { + // Simple x * cell width gives the left + var x: f64 = @floatFromInt(tl_coord.x * self.size.cell.width); + + // Add padding + x += @floatFromInt(self.size.padding.left); + + // Scale + x /= content_scale.x; + + break :x x; + }; + const y: f64 = y: { + // Simple y * cell height gives the top + var y: f64 = @floatFromInt(tl_coord.y * self.size.cell.height); + + // We want the text baseline + y += @floatFromInt(self.size.cell.height); + y -= @floatFromInt(self.font_metrics.cell_baseline); + + // Add padding + y += @floatFromInt(self.size.padding.top); + + // Scale + y /= content_scale.y; + + break :y y; + }; + + // Utilize viewport sizing to convert to offsets + const start = tl_coord.y * self.io.terminal.screen.pages.cols + tl_coord.x; + const end = br_coord.y * self.io.terminal.screen.pages.cols + br_coord.x; + + break :viewport .{ + .tl_px_x = x, + .tl_px_y = y, + .offset_start = start, + .offset_len = end - start, + }; + }; + + return .{ + .text = text, + .viewport = vp, + }; +} + /// Returns true if the terminal has a selection. pub fn hasSelection(self: *const Surface) bool { self.renderer_state.mutex.lock(); @@ -1823,6 +2083,14 @@ pub fn keyCallback( if (self.io.terminal.modes.get(.disable_keyboard)) return .consumed; } + // If our process is exited and we press a key then we close the + // surface. We may want to eventually move this to the apprt rather + // than in core. + if (self.child_exited and event.action == .press) { + self.close(); + return .closed; + } + // If this input event has text, then we hide the mouse if configured. // We only do this on pressed events to avoid hiding the mouse when we // change focus due to a keybinding (i.e. switching tabs). @@ -2069,12 +2337,18 @@ fn maybeHandleBinding( break :performed try self.performBindingAction(action); }; - // If we performed an action and it was a closing action, - // our "self" pointer is not safe to use anymore so we need to - // just exit immediately. - if (performed and closingAction(action)) { - log.debug("key binding is a closing binding, halting key event processing", .{}); - return .closed; + if (performed) { + // If we performed an action and it was a closing action, + // our "self" pointer is not safe to use anymore so we need to + // just exit immediately. + if (closingAction(action)) { + log.debug("key binding is a closing binding, halting key event processing", .{}); + return .closed; + } + + // If our action was "ignore" then we return the special input + // effect of "ignored". + if (action == .ignore) return .ignored; } // If we have the performable flag and the action was not performed, @@ -2958,15 +3232,33 @@ pub fn mouseButtonCallback( } } - // Handle link clicking. We want to do this before we do mouse - // reporting or any other mouse handling because a successfully - // clicked link will swallow the event. - if (button == .left and action == .release and self.mouse.over_link) { - const pos = try self.rt_surface.getCursorPos(); - if (self.processLinks(pos)) |processed| { - if (processed) return true; - } else |err| { - log.warn("error processing links err={}", .{err}); + if (button == .left and action == .release) { + // The selection clipboard is only updated for left-click drag when + // the left button is released. This is to avoid the clipboard + // being updated on every mouse move which would be noisy. + if (self.config.copy_on_select != .false) { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const prev_ = self.io.terminal.screen.selection; + if (prev_) |prev| { + try self.setSelection(terminal.Selection.init( + prev.start(), + prev.end(), + false, + )); + } + } + + // Handle link clicking. We want to do this before we do mouse + // reporting or any other mouse handling because a successfully + // clicked link will swallow the event. + if (self.mouse.over_link) { + const pos = try self.rt_surface.getCursorPos(); + if (self.processLinks(pos)) |processed| { + if (processed) return true; + } else |err| { + log.warn("error processing links err={}", .{err}); + } } } @@ -3102,12 +3394,16 @@ pub fn mouseButtonCallback( log.err("error reading time, mouse multi-click won't work err={}", .{err}); } + // In all cases below, we set the selection directly rather than use + // `setSelection` because we want to avoid copying the selection + // to the selection clipboard. For left mouse clicks we only set + // the clipboard on release. switch (self.mouse.left_click_count) { // Single click 1 => { // If we have a selection, clear it. This always happens. if (self.io.terminal.screen.selection != null) { - try self.setSelection(null); + try self.io.terminal.screen.select(null); try self.queueRender(); } }, @@ -3116,7 +3412,7 @@ pub fn mouseButtonCallback( 2 => { const sel_ = self.io.terminal.screen.selectWord(pin.*); if (sel_) |sel| { - try self.setSelection(sel); + try self.io.terminal.screen.select(sel); try self.queueRender(); } }, @@ -3128,7 +3424,7 @@ pub fn mouseButtonCallback( else self.io.terminal.screen.selectLine(.{ .pin = pin.* }); if (sel_) |sel| { - try self.setSelection(sel); + try self.io.terminal.screen.select(sel); try self.queueRender(); } }, @@ -3413,7 +3709,7 @@ pub fn mousePressureCallback( // to handle state inconsistency here. const pin = self.mouse.left_click_pin orelse break :select; const sel = self.io.terminal.screen.selectWord(pin.*) orelse break :select; - try self.setSelection(sel); + try self.io.terminal.screen.select(sel); try self.queueRender(); } } @@ -3632,13 +3928,13 @@ fn dragLeftClickDouble( // If our current mouse position is before the starting position, // then the selection start is the word nearest our current position. if (drag_pin.before(click_pin)) { - try self.setSelection(terminal.Selection.init( + try self.io.terminal.screen.select(.init( word_current.start(), word_start.end(), false, )); } else { - try self.setSelection(terminal.Selection.init( + try self.io.terminal.screen.select(.init( word_start.start(), word_current.end(), false, @@ -3670,171 +3966,168 @@ fn dragLeftClickTriple( } else { sel.endPtr().* = line.end(); } - try self.setSelection(sel); + try self.io.terminal.screen.select(sel); } fn dragLeftClickSingle( self: *Surface, drag_pin: terminal.Pin, - xpos: f64, + drag_x: f64, ) !void { - // NOTE(mitchellh): This logic super sucks. There has to be an easier way - // to calculate this, but this is good for a v1. Selection isn't THAT - // common so its not like this performance heavy code is running that - // often. - // TODO: unit test this, this logic sucks - - // If we were selecting, and we switched directions, then we restart - // calculations because it forces us to reconsider if the first cell is - // selected. - self.checkResetSelSwitch(drag_pin); - - // Our logic for determining if the starting cell is selected: - // - // - The "xboundary" is 60% the width of a cell from the left. We choose - // 60% somewhat arbitrarily based on feeling. - // - If we started our click left of xboundary, backwards selections - // can NEVER select the current char. - // - If we started our click right of xboundary, backwards selections - // ALWAYS selected the current char, but we must move the cursor - // left of the xboundary. - // - Inverted logic for forwards selections. - // - - // Our clicking point - const click_pin = self.mouse.left_click_pin.?.*; - - // the boundary point at which we consider selection or non-selection - const cell_width_f64: f64 = @floatFromInt(self.size.cell.width); - const cell_xboundary = cell_width_f64 * 0.6; - - // first xpos of the clicked cell adjusted for padding - const left_padding_f64: f64 = @as(f64, @floatFromInt(self.size.padding.left)); - const cell_xstart = @as(f64, @floatFromInt(click_pin.x)) * cell_width_f64; - const cell_start_xpos = self.mouse.left_click_xpos - cell_xstart - left_padding_f64; - - // If this is the same cell, then we only start the selection if weve - // moved past the boundary point the opposite direction from where we - // started. - if (click_pin.eql(drag_pin)) { - // Ensuring to adjusting the cursor position for padding - const cell_xpos = xpos - cell_xstart - left_padding_f64; - const selected: bool = if (cell_start_xpos < cell_xboundary) - cell_xpos >= cell_xboundary - else - cell_xpos < cell_xboundary; - - try self.setSelection(if (selected) terminal.Selection.init( - drag_pin, - drag_pin, - SurfaceMouse.isRectangleSelectState(self.mouse.mods), - ) else null); - - return; - } - - // If this is a different cell and we haven't started selection, - // we determine the starting cell first. - if (self.io.terminal.screen.selection == null) { - // - If we're moving to a point before the start, then we select - // the starting cell if we started after the boundary, else - // we start selection of the prior cell. - // - Inverse logic for a point after the start. - const start: terminal.Pin = if (dragLeftClickBefore( - drag_pin, - click_pin, - self.mouse.mods, - )) start: { - if (cell_start_xpos >= cell_xboundary) break :start click_pin; - if (click_pin.x > 0) break :start click_pin.left(1); - var start = click_pin.up(1) orelse click_pin; - start.x = self.io.terminal.screen.pages.cols - 1; - break :start start; - } else start: { - if (cell_start_xpos < cell_xboundary) break :start click_pin; - if (click_pin.x < self.io.terminal.screen.pages.cols - 1) - break :start click_pin.right(1); - var start = click_pin.down(1) orelse click_pin; - start.x = 0; - break :start start; - }; - - try self.setSelection(terminal.Selection.init( - start, - drag_pin, - SurfaceMouse.isRectangleSelectState(self.mouse.mods), - )); - return; - } - - // TODO: detect if selection point is passed the point where we've - // actually written data before and disallow it. - - // We moved! Set the selection end point. The start point should be - // set earlier. - assert(self.io.terminal.screen.selection != null); - const sel = self.io.terminal.screen.selection.?; - try self.setSelection(terminal.Selection.init( - sel.start(), + // This logic is in a separate function so that it can be unit tested. + try self.io.terminal.screen.select(mouseSelection( + self.mouse.left_click_pin.?.*, drag_pin, - sel.rectangle, + @intFromFloat(@max(0.0, self.mouse.left_click_xpos)), + @intFromFloat(@max(0.0, drag_x)), + self.mouse.mods, + self.size, )); } -// Resets the selection if we switched directions, depending on the select -// mode. See dragLeftClickSingle for more details. -fn checkResetSelSwitch( - self: *Surface, +/// Calculates the appropriate selection given pins and pixel x positions for +/// the click point and the drag point, as well as mouse mods and screen size. +fn mouseSelection( + click_pin: terminal.Pin, drag_pin: terminal.Pin, -) void { - const screen = &self.io.terminal.screen; - const sel = screen.selection orelse return; - const sel_start = sel.start(); - const sel_end = sel.end(); + click_x: u32, + drag_x: u32, + mods: input.Mods, + size: rendererpkg.Size, +) ?terminal.Selection { + // Explanation: + // + // # Normal selections + // + // ## Left-to-right selections + // - The clicked cell is included if it was clicked to the left of its + // threshold point and the drag location is right of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is right of its threshold point. + // + // ## Right-to-left selections + // - The clicked cell is included if it was clicked to the right of its + // threshold point and the drag location is left of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is left of its threshold point. + // + // # Rectangular selections + // + // Rectangular selections are handled similarly, except that + // entire columns are considered rather than individual cells. - var reset: bool = false; - if (sel.rectangle) { - // When we're in rectangle mode, we reset the selection relative to - // the click point depending on the selection mode we're in, with - // the exception of single-column selections, which we always reset - // on if we drift. - if (sel_start.x == sel_end.x) { - reset = drag_pin.x != sel_start.x; - } else { - reset = switch (sel.order(screen)) { - .forward => drag_pin.x < sel_start.x or drag_pin.before(sel_start), - .reverse => drag_pin.x > sel_start.x or sel_start.before(drag_pin), - .mirrored_forward => drag_pin.x > sel_start.x or drag_pin.before(sel_start), - .mirrored_reverse => drag_pin.x < sel_start.x or sel_start.before(drag_pin), + // We only include cells in the selection if the threshold point lies + // between the start and end points of the selection. A threshold of + // 60% of the cell width was chosen empirically because it felt good. + const threshold_point: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(size.cell.width)) * 0.6, + )); + + // We use this to clamp the pixel positions below. + const max_x = size.grid().columns * size.cell.width - 1; + + // We need to know how far across in the cell the drag pos is, so + // we subtract the padding and then take it modulo the cell width. + const drag_x_frac = @min(max_x, drag_x -| size.padding.left) % size.cell.width; + + // We figure out the fractional part of the click x position similarly. + const click_x_frac = @min(max_x, click_x -| size.padding.left) % size.cell.width; + + // Whether or not this is a rectangular selection. + const rectangle_selection = SurfaceMouse.isRectangleSelectState(mods); + + // Whether the click pin and drag pin are equal. + const same_pin = drag_pin.eql(click_pin); + + // Whether or not the end point of our selection is before the start point. + const end_before_start = ebs: { + if (same_pin) { + break :ebs drag_x_frac < click_x_frac; + } + + // Special handling for rectangular selections, we only use x position. + if (rectangle_selection) { + break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) { + .eq => drag_x_frac < click_x_frac, + .lt => true, + .gt => false, }; } - } else { - // Normal select uses simpler logic that is just based on the - // selection start/end. - reset = if (sel_end.before(sel_start)) - sel_start.before(drag_pin) + + break :ebs drag_pin.before(click_pin); + }; + + // Whether or not the 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 + // should be included in the selection. + const include_drag_cell = if (end_before_start) + drag_x_frac < threshold_point + else + drag_x_frac >= threshold_point; + + // If the click cell should be included in the selection then it's the + // start, otherwise we get the previous or next cell to it depending on + // the type and direction of the selection. + const start_pin = + if (include_click_cell) + click_pin + else if (end_before_start) + if (rectangle_selection) + click_pin.leftClamp(1) + else + click_pin.leftWrap(1) orelse click_pin + else if (rectangle_selection) + click_pin.rightClamp(1) else - drag_pin.before(sel_start); + click_pin.rightWrap(1) orelse click_pin; + + // Likewise for the end pin with the drag cell. + const end_pin = + if (include_drag_cell) + drag_pin + else if (end_before_start) + if (rectangle_selection) + drag_pin.rightClamp(1) + else + drag_pin.rightWrap(1) orelse drag_pin + else if (rectangle_selection) + drag_pin.leftClamp(1) + else + drag_pin.leftWrap(1) orelse drag_pin; + + // If the click cell is the same as the drag cell and the click cell + // shouldn't be included, or if the cells are adjacent such that the + // start or end pin becomes the other cell, and that cell should not + // be included, then we have no selection, so we set it to null. + // + // If in rectangular selection mode, we compare columns as well. + // + // TODO(qwerasd): this can/should probably be refactored, it's a bit + // repetitive and does excess work in rectangle mode. + if ((!include_click_cell and same_pin) or + (!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or + (!include_click_cell and end_pin.eql(click_pin)) or + (!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or + (!include_drag_cell and start_pin.eql(drag_pin)) or + (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x)) + { + return null; } - // Nullifying a selection can't fail. - if (reset) self.setSelection(null) catch unreachable; -} + // TODO: Clamp selection to the screen area, don't + // let it extend past the last written row. -// Handles how whether or not the drag screen point is before the click point. -// When we are in rectangle select, we only interpret the x axis to determine -// where to start the selection (before or after the click point). See -// dragLeftClickSingle for more details. -fn dragLeftClickBefore( - drag_pin: terminal.Pin, - click_pin: terminal.Pin, - mods: input.Mods, -) bool { - if (mods.ctrlOrSuper() and mods.alt) { - return drag_pin.x < click_pin.x; - } - - return drag_pin.before(click_pin); + return .init( + start_pin, + end_pin, + rectangle_selection, + ); } /// Call to notify Ghostty that the color scheme for the terminal has @@ -3920,6 +4213,21 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .parent = self }, ), + // Undo and redo both support both surface and app targeting. + // If we are triggering on a surface then we perform the + // action with the surface target. + .undo => return try self.rt_app.performAction( + .{ .surface = self }, + .undo, + {}, + ), + + .redo => return try self.rt_app.performAction( + .{ .surface = self }, + .redo, + {}, + ), + else => try self.app.performAction( self.rt_app, action.scoped(.app).?, @@ -4537,6 +4845,11 @@ fn writeScreenFile( const path = try tmp_dir.dir.realpath(filename, &path_buf); switch (write_action) { + .copy => { + const pathZ = try self.alloc.dupeZ(u8, path); + defer self.alloc.free(pathZ); + try self.rt_surface.setClipboardString(pathZ, .standard, false); + }, .open => try internal_os.open(self.alloc, .text, path), .paste => self.io.queueMessage(try termio.Message.writeReq( self.alloc, @@ -4819,3 +5132,430 @@ fn presentSurface(self: *Surface) !void { {}, ); } + +/// Utility function for the unit tests for mouse selection logic. +/// +/// Tests a click and drag on a 10x5 cell grid, x positions are given in +/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3. +/// +/// NOTE: The size tested with has 10px wide cells, meaning only one digit +/// after the decimal place has any meaning, e.g. 3.14 is equal to 3.1. +/// +/// The provided start_x/y and end_x/y are the expected start and end points +/// of the resulting selection. +fn testMouseSelection( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + start_x: terminal.size.CellCountInt, + start_y: u32, + end_x: terminal.size.CellCountInt, + end_y: u32, + rect: bool, +) !void { + assert(builtin.is_test); + + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const size: rendererpkg.Size = .{ + .cell = .{ .width = 10, .height = 20 }, + .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, + .screen = .{ .width = 110, .height = 110 }, + }; + var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0); + defer screen.deinit(); + + // We hold both ctrl and alt for rectangular + // select so that this test is platform agnostic. + const mods: input.Mods = .{ + .ctrl = rect, + .alt = rect, + }; + + try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); + + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; + + const cell_width_f64: f64 = @floatFromInt(size.cell.width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + size.padding.left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + size.padding.left; + + const start_pin = screen.pages.pin(.{ + .viewport = .{ .x = start_x, .y = start_y }, + }) orelse unreachable; + const end_pin = screen.pages.pin(.{ + .viewport = .{ .x = end_x, .y = end_y }, + }) orelse unreachable; + + try std.testing.expectEqualDeep(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = rect, + }, mouseSelection( + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + mods, + size, + )); +} + +/// Like `testMouseSelection` but checks that the resulting selection is null. +/// +/// See `testMouseSelection` for more details. +fn testMouseSelectionIsNull( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + rect: bool, +) !void { + assert(builtin.is_test); + + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const size: rendererpkg.Size = .{ + .cell = .{ .width = 10, .height = 20 }, + .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, + .screen = .{ .width = 110, .height = 110 }, + }; + var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0); + defer screen.deinit(); + + // We hold both ctrl and alt for rectangular + // select so that this test is platform agnostic. + const mods: input.Mods = .{ + .ctrl = rect, + .alt = rect, + }; + + try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); + + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; + + const cell_width_f64: f64 = @floatFromInt(size.cell.width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + size.padding.left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + size.padding.left; + + try std.testing.expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + mods, + size, + ), + ); +} + +test "Surface: selection logic" { + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off + + // -- LTR + // single cell selection + try testMouseSelection( + 3.0, 3, // click + 3.9, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testMouseSelection( + 3.0, 3, // click + 5.9, 3, // drag + 3, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testMouseSelection( + 3.0, 3, // click + 5.0, 3, // drag + 3, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testMouseSelection( + 3.9, 3, // click + 5.9, 3, // drag + 4, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testMouseSelection( + 3.9, 3, // click + 5.0, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testMouseSelectionIsNull( + 3.0, 3, // click + 3.1, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testMouseSelectionIsNull( + 3.8, 3, // click + 3.9, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testMouseSelectionIsNull( + 3.9, 3, // click + 4.0, 3, // drag + false, // regular selection + ); + + // -- RTL + // single cell selection + try testMouseSelection( + 3.9, 3, // click + 3.0, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testMouseSelection( + 5.9, 3, // click + 3.0, 3, // drag + 5, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testMouseSelection( + 5.9, 3, // click + 3.9, 3, // drag + 5, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testMouseSelection( + 5.0, 3, // click + 3.0, 3, // drag + 4, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testMouseSelection( + 5.0, 3, // click + 3.9, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testMouseSelectionIsNull( + 3.1, 3, // click + 3.0, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testMouseSelectionIsNull( + 3.9, 3, // click + 3.8, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testMouseSelectionIsNull( + 4.0, 3, // click + 3.9, 3, // drag + false, // regular selection + ); + + // -- Wrapping + // LTR, wrap excluded cells + try testMouseSelection( + 9.9, 2, // click + 0.0, 4, // drag + 0, 3, // expected start + 9, 3, // expected end + false, // regular selection + ); + // RTL, wrap excluded cells + try testMouseSelection( + 0.0, 4, // click + 9.9, 2, // drag + 9, 3, // expected start + 0, 3, // expected end + false, // regular selection + ); +} + +test "Surface: rectangle selection logic" { + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off + + // -- LTR + // single column selection + try testMouseSelection( + 3.0, 2, // click + 3.9, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testMouseSelection( + 3.0, 2, // click + 5.9, 4, // drag + 3, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testMouseSelection( + 3.0, 2, // click + 5.0, 4, // drag + 3, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testMouseSelection( + 3.9, 2, // click + 5.9, 4, // drag + 4, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testMouseSelection( + 3.9, 2, // click + 5.0, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testMouseSelectionIsNull( + 3.0, 2, // click + 3.1, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testMouseSelectionIsNull( + 3.8, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testMouseSelectionIsNull( + 3.9, 2, // click + 4.0, 4, // drag + true, //rectangle selection + ); + + // -- RTL + // single column selection + try testMouseSelection( + 3.9, 2, // click + 3.0, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testMouseSelection( + 5.9, 2, // click + 3.0, 4, // drag + 5, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testMouseSelection( + 5.9, 2, // click + 3.9, 4, // drag + 5, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testMouseSelection( + 5.0, 2, // click + 3.0, 4, // drag + 4, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testMouseSelection( + 5.0, 2, // click + 3.9, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testMouseSelectionIsNull( + 3.1, 2, // click + 3.0, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testMouseSelectionIsNull( + 3.9, 2, // click + 3.8, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testMouseSelectionIsNull( + 4.0, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); + + // -- Wrapping + // LTR, do not wrap + try testMouseSelection( + 9.9, 2, // click + 0.0, 4, // drag + 9, 2, // expected start + 0, 4, // expected end + true, //rectangle selection + ); + // RTL, do not wrap + try testMouseSelection( + 0.0, 4, // click + 9.9, 2, // drag + 0, 4, // expected start + 9, 2, // expected end + true, //rectangle selection + ); +} diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 8a23bc1a4..b4c5164c2 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -165,6 +165,9 @@ pub const Action = union(Key) { /// Control whether the inspector is shown or hidden. inspector: Inspector, + /// Show the GTK inspector. + show_gtk_inspector, + /// The inspector for the given target has changes and should be /// rendered at the next opportunity. render_inspector, @@ -255,6 +258,13 @@ pub const Action = union(Key) { /// it needs to ring the bell. This is usually a sound or visual effect. ring_bell, + /// Undo the last action. See the "undo" keybinding for more + /// details on what can and cannot be undone. + undo, + + /// Redo the last undone action. + redo, + check_for_updates, /// Sync with: ghostty_action_tag_e @@ -284,6 +294,7 @@ pub const Action = union(Key) { initial_size, cell_size, inspector, + show_gtk_inspector, render_inspector, desktop_notification, set_title, @@ -303,6 +314,8 @@ pub const Action = union(Key) { config_change, close_window, ring_bell, + undo, + redo, check_for_updates, }; diff --git a/src/apprt/browser.zig b/src/apprt/browser.zig index d60776a6a..3b1aa468f 100644 --- a/src/apprt/browser.zig +++ b/src/apprt/browser.zig @@ -1,2 +1,4 @@ +const internal_os = @import("../os/main.zig"); +pub const resourcesDir = internal_os.resourcesDir; pub const App = struct {}; pub const Window = struct {}; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 7bc84bcad..dec1e4135 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -23,6 +23,8 @@ const Config = configpkg.Config; const log = std.log.scoped(.embedded_window); +pub const resourcesDir = internal_os.resourcesDir; + pub const App = struct { /// Because we only expect the embedding API to be used in embedded /// environments, the options are extern so that we can expose it @@ -115,10 +117,11 @@ pub const App = struct { config: Config, pub fn init( + self: *App, core_app: *CoreApp, config: *const Config, opts: Options, - ) !App { + ) !void { // We have to clone the config. const alloc = core_app.alloc; var config_clone = try config.clone(alloc); @@ -127,7 +130,7 @@ pub const App = struct { var keymap = try input.Keymap.init(); errdefer keymap.deinit(); - return .{ + self.* = .{ .core_app = core_app, .config = config_clone, .opts = opts, @@ -376,6 +379,14 @@ pub const PlatformTag = enum(c_int) { ios = 2, }; +pub const EnvVar = extern struct { + /// The name of the environment variable. + key: [*:0]const u8, + + /// The value of the environment variable. + value: [*:0]const u8, +}; + pub const Surface = struct { app: *App, platform: Platform, @@ -407,7 +418,7 @@ pub const Surface = struct { font_size: f32 = 0, /// The working directory to load into. - working_directory: [*:0]const u8 = "", + working_directory: ?[*:0]const u8 = null, /// The command to run in the new surface. If this is set then /// the "wait-after-command" option is also automatically set to true, @@ -417,13 +428,20 @@ pub const Surface = struct { /// despite Ghostty allowing directly executed commands via config. /// This is a legacy thing and we should probably change it in the /// future once we have a concrete use case. - command: [*:0]const u8 = "", + command: ?[*:0]const u8 = null, + + /// Extra environment variables to set for the surface. + env_vars: ?[*]EnvVar = null, + env_var_count: usize = 0, + + /// Input to send to the command after it is started. + initial_input: ?[*:0]const u8 = null, }; pub fn init(self: *Surface, app: *App, opts: Options) !void { self.* = .{ .app = app, - .platform = try Platform.init(opts.platform_tag, opts.platform), + .platform = try .init(opts.platform_tag, opts.platform), .userdata = opts.userdata, .core_surface = undefined, .content_scale = .{ @@ -443,41 +461,72 @@ pub const Surface = struct { defer config.deinit(); // If we have a working directory from the options then we set it. - const wd = std.mem.sliceTo(opts.working_directory, 0); - if (wd.len > 0) wd: { - var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| { - log.warn( - "error opening requested working directory dir={s} err={}", - .{ wd, err }, - ); - break :wd; - }; - defer dir.close(); + if (opts.working_directory) |c_wd| { + const wd = std.mem.sliceTo(c_wd, 0); + if (wd.len > 0) wd: { + var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| { + log.warn( + "error opening requested working directory dir={s} err={}", + .{ wd, err }, + ); + break :wd; + }; + defer dir.close(); - const stat = dir.stat() catch |err| { - log.warn( - "failed to stat requested working directory dir={s} err={}", - .{ wd, err }, - ); - break :wd; - }; + const stat = dir.stat() catch |err| { + log.warn( + "failed to stat requested working directory dir={s} err={}", + .{ wd, err }, + ); + break :wd; + }; - if (stat.kind != .directory) { - log.warn( - "requested working directory is not a directory dir={s}", - .{wd}, - ); - break :wd; + if (stat.kind != .directory) { + log.warn( + "requested working directory is not a directory dir={s}", + .{wd}, + ); + break :wd; + } + + config.@"working-directory" = wd; } - - config.@"working-directory" = wd; } // If we have a command from the options then we set it. - const cmd = std.mem.sliceTo(opts.command, 0); - if (cmd.len > 0) { - config.command = .{ .shell = cmd }; - config.@"wait-after-command" = true; + if (opts.command) |c_command| { + const cmd = std.mem.sliceTo(c_command, 0); + if (cmd.len > 0) { + config.command = .{ .shell = cmd }; + config.@"wait-after-command" = true; + } + } + + // Apply any environment variables that were requested. + if (opts.env_var_count > 0) { + const alloc = config.arenaAlloc(); + for (opts.env_vars.?[0..opts.env_var_count]) |env_var| { + const key = std.mem.sliceTo(env_var.key, 0); + const value = std.mem.sliceTo(env_var.value, 0); + try config.env.map.put( + alloc, + try alloc.dupeZ(u8, key), + try alloc.dupeZ(u8, value), + ); + } + } + + // If we have an initial input then we set it. + if (opts.initial_input) |c_input| { + const alloc = config.arenaAlloc(); + config.input.list.clearRetainingCapacity(); + try config.input.list.append( + alloc, + .{ .raw = try alloc.dupeZ(u8, std.mem.sliceTo( + c_input, + 0, + )) }, + ); } // Initialize our surface right away. We're given a view that is @@ -522,7 +571,7 @@ pub const Surface = struct { const alloc = self.app.core_app.alloc; const inspector = try alloc.create(Inspector); errdefer alloc.destroy(inspector); - inspector.* = try Inspector.init(self); + inspector.* = try .init(self); self.inspector = inspector; return inspector; } @@ -842,7 +891,10 @@ pub const Surface = struct { // our translation settings for Ghostty. If we aren't from // the desktop then we didn't set our LANGUAGE var so we // don't need to remove it. - if (internal_os.launchedFromDesktop()) env.remove("LANGUAGE"); + switch (self.app.config.@"launched-from".?) { + .desktop => env.remove("LANGUAGE"), + .dbus, .systemd, .cli => {}, + } } return env; @@ -1135,13 +1187,6 @@ pub const CAPI = struct { } }; - const Selection = extern struct { - tl_x_px: f64, - tl_y_px: f64, - offset_start: u32, - offset_len: u32, - }; - const SurfaceSize = extern struct { columns: u16, rows: u16, @@ -1151,6 +1196,104 @@ pub const CAPI = struct { cell_height_px: u32, }; + // ghostty_text_s + const Text = extern struct { + tl_px_x: f64, + tl_px_y: f64, + offset_start: u32, + offset_len: u32, + text: ?[*:0]const u8, + text_len: usize, + + pub fn deinit(self: *Text) void { + if (self.text) |ptr| { + global.alloc.free(ptr[0..self.text_len :0]); + } + } + }; + + // ghostty_point_s + const Point = extern struct { + tag: Tag, + coord_tag: CoordTag, + x: u32, + y: u32, + + const Tag = enum(c_int) { + active = 0, + viewport = 1, + screen = 2, + history = 3, + }; + + const CoordTag = enum(c_int) { + exact = 0, + top_left = 1, + bottom_right = 2, + }; + + fn pin( + self: Point, + screen: *const terminal.Screen, + ) ?terminal.Pin { + // The core point tag. + const tag: terminal.point.Tag = switch (self.tag) { + inline else => |tag| @field( + terminal.point.Tag, + @tagName(tag), + ), + }; + + // Clamp our point to the screen bounds. + const clamped_x = @min(self.x, screen.pages.cols -| 1); + const clamped_y = @min(self.y, screen.pages.rows -| 1); + + return switch (self.coord_tag) { + // Exact coordinates require a specific pin. + .exact => exact: { + const pt_x = std.math.cast( + terminal.size.CellCountInt, + clamped_x, + ) orelse std.math.maxInt(terminal.size.CellCountInt); + + const pt: terminal.Point = switch (tag) { + inline else => |v| @unionInit( + terminal.Point, + @tagName(v), + .{ .x = pt_x, .y = clamped_y }, + ), + }; + + break :exact screen.pages.pin(pt) orelse null; + }, + + .top_left => screen.pages.getTopLeft(tag), + + .bottom_right => screen.pages.getBottomRight(tag), + }; + } + }; + + // ghostty_selection_s + const Selection = extern struct { + tl: Point, + br: Point, + rectangle: bool, + + fn core( + self: Selection, + screen: *const terminal.Screen, + ) ?terminal.Selection { + return .{ + .bounds = .{ .untracked = .{ + .start = self.tl.pin(screen) orelse return null, + .end = self.br.pin(screen) orelse return null, + } }, + .rectangle = self.rectangle, + }; + } + }; + // Reference the conditional exports based on target platform // so they're included in the C API. comptime { @@ -1174,13 +1317,13 @@ pub const CAPI = struct { opts: *const apprt.runtime.App.Options, config: *const Config, ) !*App { - var core_app = try CoreApp.create(global.alloc); + const core_app = try CoreApp.create(global.alloc); errdefer core_app.destroy(); // Create our runtime app var app = try global.alloc.create(App); errdefer global.alloc.destroy(app); - app.* = try App.init(core_app, config, opts.*); + try app.init(core_app, config, opts.*); errdefer app.terminate(); return app; @@ -1356,28 +1499,90 @@ pub const CAPI = struct { return surface.core_surface.needsConfirmQuit(); } + /// Returns true if the surface process has exited. + export fn ghostty_surface_process_exited(surface: *Surface) bool { + return surface.core_surface.child_exited; + } + /// Returns true if the surface has a selection. export fn ghostty_surface_has_selection(surface: *Surface) bool { return surface.core_surface.hasSelection(); } - /// Copies the surface selection text into the provided buffer and - /// returns the copied size. If the buffer is too small, there is no - /// selection, or there is an error, then 0 is returned. - export fn ghostty_surface_selection(surface: *Surface, buf: [*]u8, cap: usize) usize { - const selection_ = surface.core_surface.selectionString(global.alloc) catch |err| { - log.warn("error getting selection err={}", .{err}); - return 0; + /// Same as ghostty_surface_read_text but reads from the user selection, + /// if any. + export fn ghostty_surface_read_selection( + surface: *Surface, + result: *Text, + ) bool { + const core_surface = &surface.core_surface; + core_surface.renderer_state.mutex.lock(); + defer core_surface.renderer_state.mutex.unlock(); + + // If we don't have a selection, do nothing. + const core_sel = core_surface.io.terminal.screen.selection orelse return false; + + // Read the text from the selection. + return readTextLocked(surface, core_sel, result); + } + + /// Read some arbitrary text from the surface. + /// + /// This is an expensive operation so it shouldn't be called too + /// often. We recommend that callers cache the result and throttle + /// calls to this function. + export fn ghostty_surface_read_text( + surface: *Surface, + sel: Selection, + result: *Text, + ) bool { + surface.core_surface.renderer_state.mutex.lock(); + defer surface.core_surface.renderer_state.mutex.unlock(); + + const core_sel = sel.core( + &surface.core_surface.renderer_state.terminal.screen, + ) orelse return false; + + return readTextLocked(surface, core_sel, result); + } + + fn readTextLocked( + surface: *Surface, + core_sel: terminal.Selection, + result: *Text, + ) bool { + const core_surface = &surface.core_surface; + + // Get our text directly from the core surface. + const text = core_surface.dumpTextLocked( + global.alloc, + core_sel, + ) catch |err| { + log.warn("error reading text err={}", .{err}); + return false; }; - const selection = selection_ orelse return 0; - defer global.alloc.free(selection); - // If the buffer is too small, return no selection. - if (selection.len > cap) return 0; + const vp: CoreSurface.Text.Viewport = text.viewport orelse .{ + .tl_px_x = -1, + .tl_px_y = -1, + .offset_start = 0, + .offset_len = 0, + }; - // Copy into the buffer and return the length - @memcpy(buf[0..selection.len], selection); - return selection.len; + result.* = .{ + .tl_px_x = vp.tl_px_x, + .tl_px_y = vp.tl_px_y, + .offset_start = vp.offset_start, + .offset_len = vp.offset_len, + .text = text.text.ptr, + .text_len = text.text.len, + }; + + return true; + } + + export fn ghostty_surface_free_text(ptr: *Text) void { + ptr.deinit(); } /// Tell the surface that it needs to schedule a render @@ -1681,12 +1886,10 @@ pub const CAPI = struct { return false; }; - _ = ptr.core_surface.performBindingAction(action) catch |err| { + return ptr.core_surface.performBindingAction(action) catch |err| { log.err("error performing binding action action={} err={}", .{ action, err }); return false; }; - - return true; } /// Complete a clipboard read request started via the read callback. @@ -1880,21 +2083,12 @@ pub const CAPI = struct { /// This does not modify the selection active on the surface (if any). export fn ghostty_surface_quicklook_word( ptr: *Surface, - buf: [*]u8, - cap: usize, - info: *Selection, - ) usize { + result: *Text, + ) bool { const surface = &ptr.core_surface; surface.renderer_state.mutex.lock(); defer surface.renderer_state.mutex.unlock(); - // To make everything in this function easier, we modify the - // selection to be the word under the cursor and call normal APIs. - // We restore the old selection so it isn't ever changed. Since we hold - // the renderer mutex it'll never show up in a frame. - const prev = surface.io.terminal.screen.selection; - defer surface.io.terminal.screen.selection = prev; - // Get our word selection const sel = sel: { const screen = &surface.renderer_state.terminal.screen; @@ -1907,49 +2101,17 @@ pub const CAPI = struct { }, }) orelse { if (comptime std.debug.runtime_safety) unreachable; - return 0; + return false; }; - break :sel surface.io.terminal.screen.selectWord(pin) orelse return 0; + break :sel surface.io.terminal.screen.selectWord(pin) orelse return false; }; - // Set the selection - surface.io.terminal.screen.selection = sel; - - // No we call normal functions. These require that the lock - // is unlocked. This may cause a frame flicker with the fake - // selection but I think the lack of new complexity is worth it - // for now. - { - surface.renderer_state.mutex.unlock(); - defer surface.renderer_state.mutex.lock(); - const len = ghostty_surface_selection(ptr, buf, cap); - if (!ghostty_surface_selection_info(ptr, info)) return 0; - return len; - } - } - - /// This returns the selection metadata for the current selection. - /// This will return false if there is no selection or the - /// selection is not fully contained in the viewport (since the - /// metadata is all about that). - export fn ghostty_surface_selection_info( - ptr: *Surface, - info: *Selection, - ) bool { - const sel = ptr.core_surface.selectionInfo() orelse - return false; - - info.* = .{ - .tl_x_px = sel.tl_x_px, - .tl_y_px = sel.tl_y_px, - .offset_start = sel.offset_start, - .offset_len = sel.offset_len, - }; - return true; + // Read the selection + return readTextLocked(ptr, sel, result); } export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool { - return ptr.initMetal(objc.Object.fromId(device)); + return ptr.initMetal(.fromId(device)); } export fn ghostty_inspector_metal_render( @@ -1958,8 +2120,8 @@ pub const CAPI = struct { descriptor: objc.c.id, ) void { return ptr.renderMetal( - objc.Object.fromId(command_buffer), - objc.Object.fromId(descriptor), + .fromId(command_buffer), + .fromId(descriptor), ) catch |err| { log.err("error rendering inspector err={}", .{err}); return; diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 221d5344a..b82771d75 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -35,6 +35,8 @@ const darwin_enabled = builtin.target.os.tag.isDarwin() and const log = std.log.scoped(.glfw); +pub const resourcesDir = internal_os.resourcesDir; + pub const App = struct { app: *CoreApp, config: Config, @@ -48,7 +50,7 @@ pub const App = struct { pub const Options = struct {}; - pub fn init(core_app: *CoreApp, _: Options) !App { + pub fn init(self: *App, core_app: *CoreApp, _: Options) !void { if (comptime builtin.target.os.tag.isDarwin()) { log.warn("WARNING WARNING WARNING: GLFW ON MAC HAS BUGS.", .{}); log.warn("You should use the AppKit-based app instead. The official download", .{}); @@ -105,7 +107,7 @@ pub const App = struct { // We want the event loop to wake up instantly so we can process our tick. glfw.postEmptyEvent(); - return .{ + self.* = .{ .app = core_app, .config = config, .darwin = darwin, @@ -250,6 +252,9 @@ pub const App = struct { .reset_window_size, .ring_bell, .check_for_updates, + .undo, + .redo, + .show_gtk_inspector, => { log.info("unimplemented action={}", .{action}); return false; diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 882448ed7..3193065c4 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -2,6 +2,7 @@ pub const App = @import("gtk/App.zig"); pub const Surface = @import("gtk/Surface.zig"); +pub const resourcesDir = @import("gtk/flatpak.zig").resourcesDir; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index da828b973..7786f976a 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -55,6 +55,11 @@ pub const c = @cImport({ const log = std.log.scoped(.gtk); +/// This is detected by the Renderer, in which case it sends a `redraw_surface` +/// message so that we can call `drawFrame` ourselves from the app thread, +/// because GTK's `GLArea` does not support drawing from a different thread. +pub const must_draw_from_app_thread = true; + pub const Options = struct {}; core_app: *CoreApp, @@ -105,7 +110,7 @@ quit_timer: union(enum) { expired: void, } = .{ .off = {} }, -pub fn init(core_app: *CoreApp, opts: Options) !App { +pub fn init(self: *App, core_app: *CoreApp, opts: Options) !void { _ = opts; // Log our GTK version @@ -143,8 +148,8 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { if (config.@"async-backend" != .auto) { const result: bool = switch (config.@"async-backend") { .auto => unreachable, - .epoll => xev.prefer(.epoll), - .io_uring => xev.prefer(.io_uring), + .epoll => if (comptime xev.dynamic) xev.prefer(.epoll) else false, + .io_uring => if (comptime xev.dynamic) xev.prefer(.io_uring) else false, }; if (result) { @@ -273,7 +278,10 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { const single_instance = switch (config.@"gtk-single-instance") { .true => true, .false => false, - .desktop => internal_os.launchedFromDesktop(), + .desktop => switch (config.@"launched-from".?) { + .desktop, .systemd, .dbus => true, + .cli => false, + }, }; // Setup the flags for our application. @@ -288,7 +296,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // can develop Ghostty in Ghostty. const app_id: [:0]const u8 = app_id: { if (config.class) |class| { - if (isValidAppId(class)) { + if (gio.Application.idIsValid(class) != 0) { break :app_id class; } else { log.warn("invalid 'class' in config, ignoring", .{}); @@ -397,11 +405,15 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // This just calls the `activate` signal but its part of the normal startup // routine so we just call it, but only if the config allows it (this allows // for launching Ghostty in the "background" without immediately opening - // a window) + // a window). An initial window will not be immediately created if we were + // launched by D-Bus activation or systemd. D-Bus activation will send it's + // own `activate` or `new-window` signal later. // // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 - if (config.@"initial-window") - gio_app.activate(); + if (config.@"initial-window") switch (config.@"launched-from".?) { + .desktop, .cli => gio_app.activate(), + .dbus, .systemd => {}, + }; // Internally, GTK ensures that only one instance of this provider exists in the provider list // for the display. @@ -412,7 +424,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 3, ); - return .{ + self.* = .{ .core_app = core_app, .app = adw_app, .config = config, @@ -481,6 +493,7 @@ pub fn performAction( .config_change => self.configChange(target, value.config), .reload_config => try self.reloadConfig(target, value), .inspector => self.controlInspector(target, value), + .show_gtk_inspector => self.showGTKInspector(), .desktop_notification => self.showDesktopNotification(target, value), .set_title => try self.setTitle(target, value), .pwd => try self.setPwd(target, value), @@ -511,6 +524,8 @@ pub fn performAction( .color_change, .reset_window_size, .check_for_updates, + .undo, + .redo, => { log.warn("unimplemented action={}", .{action}); return false; @@ -687,6 +702,12 @@ fn controlInspector( surface.controlInspector(mode); } +fn showGTKInspector( + _: *const App, +) void { + gtk.Window.setInteractiveDebugging(@intFromBool(true)); +} + fn toggleMaximize(_: *App, target: apprt.Target) void { switch (target) { .app => {}, @@ -1060,6 +1081,7 @@ fn syncActionAccelerators(self: *App) !void { try self.syncActionAccelerator("app.open-config", .{ .open_config = {} }); try self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} }); try self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle }); + try self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector); try self.syncActionAccelerator("win.toggle-command-palette", .toggle_command_palette); try self.syncActionAccelerator("win.close", .{ .close_window = {} }); try self.syncActionAccelerator("win.new-window", .{ .new_window = {} }); @@ -1655,6 +1677,27 @@ fn gtkActionPresentSurface( ); } +fn gtkActionShowGTKInspector( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *App, +) callconv(.c) void { + self.core_app.performAction(self, .show_gtk_inspector) catch |err| { + log.err("error showing GTK inspector err={}", .{err}); + }; +} + +fn gtkActionNewWindow( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *App, +) callconv(.c) void { + log.info("received new window action", .{}); + _ = self.core_app.mailbox.push(.{ + .new_window = .{}, + }, .{ .forever = {} }); +} + /// This is called to setup the action map that this application supports. /// This should be called only once on startup. fn initActions(self: *App) void { @@ -1673,7 +1716,10 @@ fn initActions(self: *App) void { .{ "open-config", gtkActionOpenConfig, null }, .{ "reload-config", gtkActionReloadConfig, null }, .{ "present-surface", gtkActionPresentSurface, t }, + .{ "show-gtk-inspector", gtkActionShowGTKInspector, null }, + .{ "new-window", gtkActionNewWindow, null }, }; + inline for (actions) |entry| { const action = gio.SimpleAction.new(entry[0], entry[2]); defer action.unref(); @@ -1688,32 +1734,3 @@ fn initActions(self: *App) void { action_map.addAction(action.as(gio.Action)); } } - -fn isValidAppId(app_id: [:0]const u8) bool { - if (app_id.len > 255 or app_id.len == 0) return false; - if (app_id[0] == '.') return false; - if (app_id[app_id.len - 1] == '.') return false; - - var hasDot = false; - for (app_id) |char| { - switch (char) { - 'a'...'z', 'A'...'Z', '0'...'9', '_', '-' => {}, - '.' => hasDot = true, - else => return false, - } - } - if (!hasDot) return false; - - return true; -} - -test "isValidAppId" { - try testing.expect(isValidAppId("foo.bar")); - try testing.expect(isValidAppId("foo.bar.baz")); - try testing.expect(!isValidAppId("foo")); - try testing.expect(!isValidAppId("foo.bar?")); - try testing.expect(!isValidAppId("foo.")); - try testing.expect(!isValidAppId(".foo")); - try testing.expect(!isValidAppId("")); - try testing.expect(!isValidAppId("foo" ** 86)); -} diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index f10fc79ac..bf1549021 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -17,7 +17,7 @@ const adw_version = @import("adw_version.zig"); const log = std.log.scoped(.gtk); -const DialogType = if (adw_version.atLeast(1, 5, 0)) adw.AlertDialog else adw.MessageDialog; +const DialogType = if (adw_version.supportsDialogs()) adw.AlertDialog else adw.MessageDialog; app: *App, dialog: *DialogType, @@ -28,6 +28,7 @@ text_view: *gtk.TextView, text_view_scroll: *gtk.ScrolledWindow, reveal_button: *gtk.Button, hide_button: *gtk.Button, +remember_choice: if (adw_version.supportsSwitchRow()) ?*adw.SwitchRow else ?*anyopaque, pub fn create( app: *App, @@ -69,16 +70,16 @@ fn init( request: apprt.ClipboardRequest, is_secure_input: bool, ) !void { - var builder = switch (DialogType) { + var builder: Builder = switch (DialogType) { adw.AlertDialog => switch (request) { - .osc_52_read => Builder.init("ccw-osc-52-read", 1, 5), - .osc_52_write => Builder.init("ccw-osc-52-write", 1, 5), - .paste => Builder.init("ccw-paste", 1, 5), + .osc_52_read => .init("ccw-osc-52-read", 1, 5), + .osc_52_write => .init("ccw-osc-52-write", 1, 5), + .paste => .init("ccw-paste", 1, 5), }, adw.MessageDialog => switch (request) { - .osc_52_read => Builder.init("ccw-osc-52-read", 1, 2), - .osc_52_write => Builder.init("ccw-osc-52-write", 1, 2), - .paste => Builder.init("ccw-paste", 1, 2), + .osc_52_read => .init("ccw-osc-52-read", 1, 2), + .osc_52_write => .init("ccw-osc-52-write", 1, 2), + .paste => .init("ccw-paste", 1, 2), }, else => unreachable, }; @@ -89,6 +90,10 @@ fn init( const reveal_button = builder.getObject(gtk.Button, "reveal_button").?; const hide_button = builder.getObject(gtk.Button, "hide_button").?; const text_view_scroll = builder.getObject(gtk.ScrolledWindow, "text_view_scroll").?; + const remember_choice = if (adw_version.supportsSwitchRow()) + builder.getObject(adw.SwitchRow, "remember_choice") + else + null; const copy = try app.core_app.alloc.dupeZ(u8, data); errdefer app.core_app.alloc.free(copy); @@ -102,6 +107,7 @@ fn init( .text_view_scroll = text_view_scroll, .reveal_button = reveal_button, .hide_button = hide_button, + .remember_choice = remember_choice, }; const buffer = gtk.TextBuffer.new(null); @@ -152,8 +158,10 @@ fn init( } } -fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.c) void { - if (std.mem.orderZ(u8, response, "ok") == .eq) { +fn handleResponse(self: *ClipboardConfirmation, response: [*:0]const u8) void { + const is_ok = std.mem.orderZ(u8, response, "ok") == .eq; + + if (is_ok) { self.core_surface.completeClipboardRequest( self.pending_req, self.data, @@ -162,8 +170,30 @@ fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) log.err("Failed to requeue clipboard request: {}", .{err}); }; } + + if (self.remember_choice) |remember| remember: { + if (!adw_version.supportsSwitchRow()) break :remember; + if (remember.getActive() == 0) break :remember; + + switch (self.pending_req) { + .osc_52_read => self.core_surface.config.clipboard_read = if (is_ok) .allow else .deny, + .osc_52_write => self.core_surface.config.clipboard_write = if (is_ok) .allow else .deny, + .paste => {}, + } + } + self.destroy(); } +fn gtkChoose(dialog_: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.C) void { + const dialog = gobject.ext.cast(DialogType, dialog_.?).?; + const self: *ClipboardConfirmation = @ptrCast(@alignCast(ud.?)); + const response = dialog.chooseFinish(result); + self.handleResponse(response); +} + +fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.C) void { + self.handleResponse(response); +} fn gtkRevealButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv(.c) void { self.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true)); diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig index fda2c5ca8..d05f195b3 100644 --- a/src/apprt/gtk/CommandPalette.zig +++ b/src/apprt/gtk/CommandPalette.zig @@ -43,6 +43,7 @@ pub fn init(self: *CommandPalette, window: *Window) !void { _ = Command.getGObjectType(); var builder = Builder.init("command-palette", 1, 5); + defer builder.deinit(); self.* = .{ .window = window, @@ -93,9 +94,8 @@ pub fn deinit(self: *CommandPalette) void { pub fn toggle(self: *CommandPalette) void { self.dialog.present(self.window.window.as(gtk.Widget)); - // Focus on the search bar when opening the dialog - self.dialog.setFocus(self.search.as(gtk.Widget)); + _ = self.search.as(gtk.Widget).grabFocus(); } pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !void { @@ -103,13 +103,17 @@ pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !voi self.source.removeAll(); _ = self.arena.reset(.retain_capacity); - // TODO: Allow user-configured palette entries - for (inputpkg.command.defaults) |command| { + for (config.@"command-palette-entry".value.items) |command| { // Filter out actions that are not implemented // or don't make sense for GTK switch (command.action) { .close_all_windows, .toggle_secure_input, + .check_for_updates, + .redo, + .undo, + .reset_window_size, + .toggle_window_float_on_top, => continue, else => {}, @@ -120,7 +124,9 @@ pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !voi command, config.keybind.set, ); - self.source.append(cmd.as(gobject.Object)); + const cmd_ref = cmd.as(gobject.Object); + self.source.append(cmd_ref); + cmd_ref.unref(); } } diff --git a/src/apprt/gtk/ConfigErrorsDialog.zig b/src/apprt/gtk/ConfigErrorsDialog.zig index ccc5599ad..da70ccce1 100644 --- a/src/apprt/gtk/ConfigErrorsDialog.zig +++ b/src/apprt/gtk/ConfigErrorsDialog.zig @@ -32,9 +32,9 @@ pub fn maybePresent(app: *App, window: ?*Window) void { const config_errors_dialog = config_errors_dialog: { if (app.config_errors_dialog) |config_errors_dialog| break :config_errors_dialog config_errors_dialog; - var builder = switch (DialogType) { - adw.AlertDialog => Builder.init("config-errors-dialog", 1, 5), - adw.MessageDialog => Builder.init("config-errors-dialog", 1, 2), + var builder: Builder = switch (DialogType) { + adw.AlertDialog => .init("config-errors-dialog", 1, 5), + adw.MessageDialog => .init("config-errors-dialog", 1, 2), else => unreachable, }; diff --git a/src/apprt/gtk/GlobalShortcuts.zig b/src/apprt/gtk/GlobalShortcuts.zig index 7d960d7bf..ac9dbaa8a 100644 --- a/src/apprt/gtk/GlobalShortcuts.zig +++ b/src/apprt/gtk/GlobalShortcuts.zig @@ -117,7 +117,9 @@ pub fn refreshSession(self: *GlobalShortcuts, app: *App) !void { ); } - try self.request(.create_session); + if (self.map.count() > 0) { + try self.request(.create_session); + } } fn shortcutActivated( diff --git a/src/apprt/gtk/ResizeOverlay.zig b/src/apprt/gtk/ResizeOverlay.zig index 767cf097d..2ab59624a 100644 --- a/src/apprt/gtk/ResizeOverlay.zig +++ b/src/apprt/gtk/ResizeOverlay.zig @@ -50,12 +50,12 @@ first: bool = true, pub fn init(self: *ResizeOverlay, surface: *Surface, config: *const configpkg.Config) void { self.* = .{ .surface = surface, - .config = DerivedConfig.init(config), + .config = .init(config), }; } pub fn updateConfig(self: *ResizeOverlay, config: *const configpkg.Config) void { - self.config = DerivedConfig.init(config); + self.config = .init(config); } /// De-initialize the ResizeOverlay. This removes any pending idlers/timers that diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig index 9caa9ab56..fb719c3c9 100644 --- a/src/apprt/gtk/Split.zig +++ b/src/apprt/gtk/Split.zig @@ -138,7 +138,7 @@ pub fn init( .container = container, .top_left = .{ .surface = tl }, .bottom_right = .{ .surface = br }, - .orientation = Orientation.fromDirection(direction), + .orientation = .fromDirection(direction), }; // Replace the previous containers element with our split. This allows a diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index bcb78e087..5c886e663 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -41,10 +41,6 @@ const adw_version = @import("adw_version.zig"); const log = std.log.scoped(.gtk_surface); -/// This is detected by the OpenGL renderer to move to a single-threaded -/// draw operation. This basically puts locks around our draw path. -pub const opengl_single_threaded_draw = true; - pub const Options = struct { /// The parent surface to inherit settings such as font size, working /// directory, etc. from. @@ -394,7 +390,10 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { // Various other GL properties gl_area_widget.setCursorFromName("text"); - gl_area.setRequiredVersion(3, 3); + gl_area.setRequiredVersion( + renderer.OpenGL.MIN_VERSION_MAJOR, + renderer.OpenGL.MIN_VERSION_MINOR, + ); gl_area.setHasStencilBuffer(0); gl_area.setHasDepthBuffer(0); gl_area.setUseEs(0); @@ -683,12 +682,13 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { fn realize(self: *Surface) !void { // If this surface has already been realized, then we don't need to - // reinitialize. This can happen if a surface is moved from one GDK surface - // to another (i.e. a tab is pulled out into a window). + // reinitialize. This can happen if a surface is moved from one GDK + // surface to another (i.e. a tab is pulled out into a window). if (self.realized) { // If we have no OpenGL state though, we do need to reinitialize. - // We allow the renderer to figure that out - try self.core_surface.renderer.displayRealize(); + // We allow the renderer to figure that out, and then queue a draw. + try self.core_surface.renderer.displayRealized(); + self.redraw(); return; } @@ -746,7 +746,21 @@ pub fn deinit(self: *Surface) void { self.core_surface.deinit(); self.core_surface = undefined; - if (self.cgroup_path) |path| self.app.core_app.alloc.free(path); + // Remove the cgroup if we have one. We do this after deiniting the core + // surface to ensure all processes have exited. + if (self.cgroup_path) |path| { + internal_os.cgroup.remove(path) catch |err| { + // We don't want this to be fatal in any way so we just log + // and continue. A dangling empty cgroup is not a big deal + // and this should be rare. + log.warn( + "failed to remove cgroup for surface path={s} err={}", + .{ path, err }, + ); + }; + + self.app.core_app.alloc.free(path); + } // Free all our GTK stuff // @@ -780,7 +794,7 @@ pub fn primaryWidget(self: *Surface) *gtk.Widget { } fn render(self: *Surface) !void { - try self.core_surface.renderer.drawFrame(self); + try self.core_surface.renderer.drawFrame(true); } /// Called by core surface to get the cgroup. @@ -1191,7 +1205,7 @@ pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { return; } - self.url_widget = URLWidget.init(self.overlay, uriZ); + self.url_widget = .init(self.overlay, uriZ); } pub fn supportsClipboard( @@ -1563,7 +1577,7 @@ fn gtkMouseMotion( const scaled = self.scaledCoordinates(x, y); const pos: apprt.CursorPos = .{ - .x = @floatCast(@max(0, scaled.x)), + .x = @floatCast(scaled.x), .y = @floatCast(scaled.y), }; @@ -2311,6 +2325,15 @@ pub fn defaultTermioEnv(self: *Surface) !std.process.EnvMap { env.remove("GDK_DISABLE"); env.remove("GSK_RENDERER"); + // Remove some environment variables that are set when Ghostty is launched + // from a `.desktop` file, by D-Bus activation, or systemd. + env.remove("GIO_LAUNCHED_DESKTOP_FILE"); + env.remove("GIO_LAUNCHED_DESKTOP_FILE_PID"); + env.remove("DBUS_STARTER_ADDRESS"); + env.remove("DBUS_STARTER_BUS_TYPE"); + env.remove("INVOCATION_ID"); + env.remove("JOURNAL_STREAM"); + // Unset environment varies set by snaps if we're running in a snap. // This allows Ghostty to further launch additional snaps. if (env.get("SNAP")) |_| { @@ -2440,6 +2463,13 @@ pub fn ringBell(self: *Surface) !void { media_stream.play(); } + if (features.attention) { + // Request user attention + window.winproto.setUrgent(true) catch |err| { + log.err("failed to request user attention={}", .{err}); + }; + } + // Mark tab as needing attention if (self.container.tab()) |tab| tab: { const page = window.notebook.getTabPage(tab) orelse break :tab; diff --git a/src/apprt/gtk/TabView.zig b/src/apprt/gtk/TabView.zig index 29a069a6d..8a4145b5f 100644 --- a/src/apprt/gtk/TabView.zig +++ b/src/apprt/gtk/TabView.zig @@ -7,6 +7,7 @@ const std = @import("std"); const gtk = @import("gtk"); const adw = @import("adw"); const gobject = @import("gobject"); +const glib = @import("glib"); const Window = @import("Window.zig"); const Tab = @import("Tab.zig"); @@ -243,7 +244,14 @@ fn adwClosePage( const child = page.getChild().as(gobject.Object); const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return 0)); self.tab_view.closePageFinish(page, @intFromBool(self.forcing_close)); - if (!self.forcing_close) tab.closeWithConfirmation(); + if (!self.forcing_close) { + // We cannot trigger a close directly in here as the page will stay + // alive until this handler returns, breaking the assumption where + // no pages means they are all destroyed. + // + // Schedule the close request to happen in the next event cycle. + _ = glib.idleAddOnce(glibIdleOnceCloseTab, tab); + } return 1; } @@ -269,3 +277,8 @@ fn adwSelectPage(_: *adw.TabView, _: *gobject.ParamSpec, self: *TabView) callcon const title = page.getTitle(); self.window.setTitle(std.mem.span(title)); } + +fn glibIdleOnceCloseTab(data: ?*anyopaque) callconv(.c) void { + const tab: *Tab = @ptrCast(@alignCast(data orelse return)); + tab.closeWithConfirmation(); +} diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 4a5926a97..555edb1e4 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -55,6 +55,9 @@ window: *adw.ApplicationWindow, /// The header bar for the window. headerbar: HeaderBar, +/// The tab bar for the window. +tab_bar: *adw.TabBar, + /// The tab overview for the window. This is possibly null since there is no /// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0). tab_overview: ?*adw.TabOverview, @@ -86,10 +89,12 @@ pub const DerivedConfig = struct { gtk_tabs_location: configpkg.Config.GtkTabsLocation, gtk_wide_tabs: bool, gtk_toolbar_style: configpkg.Config.GtkToolbarStyle, + window_show_tab_bar: configpkg.Config.WindowShowTabBar, quick_terminal_position: configpkg.Config.QuickTerminalPosition, quick_terminal_size: configpkg.Config.QuickTerminalSize, quick_terminal_autohide: bool, + quick_terminal_keyboard_interactivity: configpkg.Config.QuickTerminalKeyboardInteractivity, maximize: bool, fullscreen: bool, @@ -105,10 +110,12 @@ pub const DerivedConfig = struct { .gtk_tabs_location = config.@"gtk-tabs-location", .gtk_wide_tabs = config.@"gtk-wide-tabs", .gtk_toolbar_style = config.@"gtk-toolbar-style", + .window_show_tab_bar = config.@"window-show-tab-bar", .quick_terminal_position = config.@"quick-terminal-position", .quick_terminal_size = config.@"quick-terminal-size", .quick_terminal_autohide = config.@"quick-terminal-autohide", + .quick_terminal_keyboard_interactivity = config.@"quick-terminal-keyboard-interactivity", .maximize = config.maximize, .fullscreen = config.fullscreen, @@ -136,9 +143,10 @@ pub fn init(self: *Window, app: *App) !void { self.* = .{ .app = app, .last_config = @intFromPtr(&app.config), - .config = DerivedConfig.init(&app.config), + .config = .init(&app.config), .window = undefined, .headerbar = undefined, + .tab_bar = undefined, .tab_overview = null, .notebook = undefined, .titlebar_menu = undefined, @@ -148,7 +156,7 @@ pub fn init(self: *Window, app: *App) !void { }; // Create the window - self.window = adw.ApplicationWindow.new(app.app.as(gtk.Application)); + self.window = .new(app.app.as(gtk.Application)); const gtk_window = self.window.as(gtk.Window); const gtk_widget = self.window.as(gtk.Widget); errdefer gtk_window.destroy(); @@ -223,8 +231,9 @@ pub fn init(self: *Window, app: *App) !void { // If we're using an AdwWindow then we can support the tab overview. if (self.tab_overview) |tab_overview| { if (!adw_version.supportsTabOverview()) unreachable; - const btn = switch (self.config.gtk_tabs_location) { - .top, .bottom => btn: { + + const btn = switch (self.config.window_show_tab_bar) { + .always, .auto => btn: { const btn = gtk.ToggleButton.new(); btn.as(gtk.Widget).setTooltipText(i18n._("View Open Tabs")); btn.as(gtk.Button).setIconName("view-grid-symbolic"); @@ -236,8 +245,7 @@ pub fn init(self: *Window, app: *App) !void { ); break :btn btn.as(gtk.Widget); }, - - .hidden => btn: { + .never => btn: { const btn = adw.TabButton.new(); btn.setView(self.notebook.tab_view); btn.as(gtk.Actionable).setActionName("overview.open"); @@ -333,7 +341,7 @@ pub fn init(self: *Window, app: *App) !void { } // Setup our toast overlay if we have one - self.toast_overlay = adw.ToastOverlay.new(); + self.toast_overlay = .new(); self.toast_overlay.setChild(self.notebook.asWidget()); box.append(self.toast_overlay.as(gtk.Widget)); @@ -383,21 +391,16 @@ pub fn init(self: *Window, app: *App) !void { // Our actions for the menu initActions(self); + self.tab_bar = adw.TabBar.new(); + self.tab_bar.setView(self.notebook.tab_view); + if (adw_version.supportsToolbarView()) { const toolbar_view = adw.ToolbarView.new(); toolbar_view.addTopBar(self.headerbar.asWidget()); - if (self.config.gtk_tabs_location != .hidden) { - const tab_bar = adw.TabBar.new(); - tab_bar.setView(self.notebook.tab_view); - - if (!self.config.gtk_wide_tabs) tab_bar.setExpandTabs(0); - - switch (self.config.gtk_tabs_location) { - .top => toolbar_view.addTopBar(tab_bar.as(gtk.Widget)), - .bottom => toolbar_view.addBottomBar(tab_bar.as(gtk.Widget)), - .hidden => unreachable, - } + switch (self.config.gtk_tabs_location) { + .top => toolbar_view.addTopBar(self.tab_bar.as(gtk.Widget)), + .bottom => toolbar_view.addBottomBar(self.tab_bar.as(gtk.Widget)), } toolbar_view.setContent(box.as(gtk.Widget)); @@ -412,23 +415,18 @@ pub fn init(self: *Window, app: *App) !void { // Set our application window content. self.tab_overview.?.setChild(toolbar_view.as(gtk.Widget)); self.window.setContent(self.tab_overview.?.as(gtk.Widget)); - } else tab_bar: { - if (self.config.gtk_tabs_location == .hidden) break :tab_bar; + } else { // In earlier adwaita versions, we need to add the tabbar manually since we do not use // an AdwToolbarView. - const tab_bar = adw.TabBar.new(); - tab_bar.as(gtk.Widget).addCssClass("inline"); + self.tab_bar.as(gtk.Widget).addCssClass("inline"); + switch (self.config.gtk_tabs_location) { .top => box.insertChildAfter( - tab_bar.as(gtk.Widget), + self.tab_bar.as(gtk.Widget), self.headerbar.asWidget(), ), - .bottom => box.append(tab_bar.as(gtk.Widget)), - .hidden => unreachable, + .bottom => box.append(self.tab_bar.as(gtk.Widget)), } - tab_bar.setView(self.notebook.tab_view); - - if (!self.config.gtk_wide_tabs) tab_bar.setExpandTabs(0); } // If we want the window to be maximized, we do that here. @@ -463,7 +461,7 @@ pub fn updateConfig( if (self.last_config == this_config) return; self.last_config = this_config; - self.config = DerivedConfig.init(config); + self.config = .init(config); // We always resync our appearance whenever the config changes. try self.syncAppearance(); @@ -553,6 +551,16 @@ pub fn syncAppearance(self: *Window) !void { } } + self.tab_bar.setExpandTabs(@intFromBool(self.config.gtk_wide_tabs)); + self.tab_bar.setAutohide(switch (self.config.window_show_tab_bar) { + .auto, .never => @intFromBool(true), + .always => @intFromBool(false), + }); + self.tab_bar.as(gtk.Widget).setVisible(switch (self.config.window_show_tab_bar) { + .always, .auto => @intFromBool(true), + .never => @intFromBool(false), + }); + self.winproto.syncAppearance() catch |err| { log.warn("failed to sync winproto appearance error={}", .{err}); }; @@ -814,11 +822,15 @@ fn gtkWindowNotifyIsActive( _: *gobject.ParamSpec, self: *Window, ) callconv(.c) void { - if (!self.isQuickTerminal()) return; + self.winproto.setUrgent(false) catch |err| { + log.err("failed to unrequest user attention={}", .{err}); + }; - // Hide when we're unfocused - if (self.config.quick_terminal_autohide and self.window.as(gtk.Window).isActive() == 0) { - self.toggleVisibility(); + if (self.isQuickTerminal()) { + // Hide when we're unfocused + if (self.config.quick_terminal_autohide and self.window.as(gtk.Window).isActive() == 0) { + self.toggleVisibility(); + } } } diff --git a/src/apprt/gtk/adw_version.zig b/src/apprt/gtk/adw_version.zig index ff7439a21..7ce88f585 100644 --- a/src/apprt/gtk/adw_version.zig +++ b/src/apprt/gtk/adw_version.zig @@ -109,6 +109,10 @@ pub inline fn supportsTabOverview() bool { return atLeast(1, 4, 0); } +pub inline fn supportsSwitchRow() bool { + return atLeast(1, 4, 0); +} + pub inline fn supportsToolbarView() bool { return atLeast(1, 4, 0); } diff --git a/src/apprt/gtk/flatpak.zig b/src/apprt/gtk/flatpak.zig new file mode 100644 index 000000000..dc47c671b --- /dev/null +++ b/src/apprt/gtk/flatpak.zig @@ -0,0 +1,29 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const build_config = @import("../../build_config.zig"); +const internal_os = @import("../../os/main.zig"); +const glib = @import("glib"); + +pub fn resourcesDir(alloc: Allocator) !internal_os.ResourcesDir { + if (comptime build_config.flatpak) { + // Only consult Flatpak runtime data for host case. + if (internal_os.isFlatpak()) { + var result: internal_os.ResourcesDir = .{ + .app_path = try alloc.dupe(u8, "/app/share/ghostty"), + }; + errdefer alloc.free(result.app_path.?); + + const keyfile = glib.KeyFile.new(); + defer keyfile.unref(); + + if (keyfile.loadFromFile("/.flatpak-info", .{}, null) == 0) return result; + const app_dir = std.mem.span(keyfile.getString("Instance", "app-path", null)) orelse return result; + defer glib.free(app_dir.ptr); + + result.host_path = try std.fs.path.join(alloc, &[_][]const u8{ app_dir, "share", "ghostty" }); + return result; + } + } + + return try internal_os.resourcesDir(alloc); +} diff --git a/src/apprt/gtk/inspector.zig b/src/apprt/gtk/inspector.zig index e3e61e258..3adeb9711 100644 --- a/src/apprt/gtk/inspector.zig +++ b/src/apprt/gtk/inspector.zig @@ -138,7 +138,7 @@ const Window = struct { }; // Create the window - self.window = gtk.ApplicationWindow.new(inspector.surface.app.app.as(gtk.Application)); + self.window = .new(inspector.surface.app.app.as(gtk.Application)); errdefer self.window.as(gtk.Window).destroy(); self.window.as(gtk.Window).setTitle(i18n._("Ghostty: Terminal Inspector")); diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 3dcfaed98..fc3296366 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -388,6 +388,10 @@ const keymap: []const RawEntry = &.{ .{ gdk.KEY_KP_Delete, .numpad_delete }, .{ gdk.KEY_KP_Begin, .numpad_begin }, + .{ gdk.KEY_Copy, .copy }, + .{ gdk.KEY_Cut, .cut }, + .{ gdk.KEY_Paste, .paste }, + .{ gdk.KEY_Shift_L, .shift_left }, .{ gdk.KEY_Control_L, .control_left }, .{ gdk.KEY_Alt_L, .alt_left }, diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index 7c4b53d03..2051ab1e3 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -64,14 +64,18 @@ window.ssd.no-border-radius { padding: 0; } +.clipboard-overlay { + border-radius: 10px; +} + .clipboard-content-view { filter: blur(0px); transition: filter 0.3s ease; + border-radius: 10px; } .clipboard-content-view.blurred { filter: blur(5px); - transition: filter 0.3s ease; } .command-palette-search { diff --git a/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp b/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp index 640556535..ad0b5c01f 100644 --- a/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp +++ b/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp @@ -14,58 +14,72 @@ Adw.AlertDialog clipboard_confirmation_window { default-response: "cancel"; close-response: "cancel"; - extra-child: Overlay { + extra-child: ListBox { + selection-mode: none; + styles [ - "osd", + "boxed-list-separate", ] - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - + Overlay { styles [ - "opaque", + "osd", + "clipboard-overlay", ] - Image { - icon-name: "view-conceal-symbolic"; + ScrolledWindow text_view_scroll { + width-request: 500; + height-request: 200; + + TextView text_view { + cursor-visible: false; + editable: false; + monospace: true; + top-margin: 8; + left-margin: 8; + bottom-margin: 8; + right-margin: 8; + + styles [ + "clipboard-content-view", + ] + } } + + [overlay] + Button reveal_button { + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + Image { + icon-name: "view-reveal-symbolic"; + } + } + + [overlay] + Button hide_button { + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + styles [ + "opaque", + ] + + Image { + icon-name: "view-conceal-symbolic"; + } + } + } + + Adw.SwitchRow remember_choice { + title: _("Remember choice for this split"); + subtitle: _("Reload configuration to show this prompt again"); } }; } diff --git a/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp b/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp index 2e28359ff..b71131940 100644 --- a/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp +++ b/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp @@ -14,58 +14,68 @@ Adw.AlertDialog clipboard_confirmation_window { default-response: "cancel"; close-response: "cancel"; - extra-child: Overlay { + extra-child: ListBox { + selection-mode: none; + styles [ - "osd", + "boxed-list-separate", ] - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; + Overlay { + styles [ + "osd", + "clipboard-overlay", + ] - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; + ScrolledWindow text_view_scroll { + width-request: 500; + height-request: 200; + + TextView text_view { + cursor-visible: false; + editable: false; + monospace: true; + top-margin: 8; + left-margin: 8; + bottom-margin: 8; + right-margin: 8; + + styles [ + "clipboard-content-view", + ] + } + } + + [overlay] + Button reveal_button { + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + Image { + icon-name: "view-reveal-symbolic"; + } + } + + [overlay] + Button hide_button { + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; styles [ - "clipboard-content-view", + "opaque", ] } } - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - - Image { - icon-name: "view-conceal-symbolic"; - } + Adw.SwitchRow remember_choice { + title: _("Remember choice for this split"); + subtitle: _("Reload configuration to show this prompt again"); } }; } diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig index ff83e6851..2dbe5a7a0 100644 --- a/src/apprt/gtk/winproto.zig +++ b/src/apprt/gtk/winproto.zig @@ -146,4 +146,10 @@ pub const Window = union(Protocol) { inline else => |*v| try v.addSubprocessEnv(env), } } + + pub fn setUrgent(self: *Window, urgent: bool) !void { + switch (self.*) { + inline else => |*v| try v.setUrgent(urgent), + } + } }; diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig index 5cb5887c9..fb732b756 100644 --- a/src/apprt/gtk/winproto/noop.zig +++ b/src/apprt/gtk/winproto/noop.zig @@ -70,4 +70,6 @@ pub const Window = struct { } pub fn addSubprocessEnv(_: *Window, _: *std.process.EnvMap) !void {} + + pub fn setUrgent(_: *Window, _: bool) !void {} }; diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 5f5feca6e..ae3c871f2 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -6,8 +6,8 @@ const build_options = @import("build_options"); const gdk = @import("gdk"); const gdk_wayland = @import("gdk_wayland"); const gobject = @import("gobject"); -const gtk4_layer_shell = @import("gtk4-layer-shell"); const gtk = @import("gtk"); +const layer_shell = @import("gtk4-layer-shell"); const wayland = @import("wayland"); const Config = @import("../../../config.zig").Config; @@ -16,6 +16,7 @@ const ApprtWindow = @import("../Window.zig"); const wl = wayland.client.wl; const org = wayland.client.org; +const xdg = wayland.client.xdg; const log = std.log.scoped(.winproto_wayland); @@ -34,6 +35,21 @@ pub const App = struct { 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, }; pub fn init( @@ -45,16 +61,11 @@ pub const App = struct { _ = config; _ = app_id; - // Check if we're actually on Wayland - if (gobject.typeCheckInstanceIsA( - gdk_display.as(gobject.TypeInstance), - gdk_wayland.WaylandDisplay.getGObjectType(), - ) == 0) return null; - const gdk_wayland_display = gobject.ext.cast( gdk_wayland.WaylandDisplay, gdk_display, - ) orelse return error.NoWaylandDisplay; + ) orelse return null; + const display: *wl.Display = @ptrCast(@alignCast( gdk_wayland_display.getWlDisplay() orelse return error.NoWaylandDisplay, )); @@ -73,9 +84,9 @@ pub const App = struct { registry.setListener(*Context, registryListener, context); if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - if (context.kde_decoration_manager != null) { - // FIXME: Roundtrip again because we have to wait for the decoration - // manager to respond with the preferred default mode. Ew. + // 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; } @@ -97,20 +108,45 @@ pub const App = struct { return null; } - pub fn supportsQuickTerminal(_: App) bool { - if (!gtk4_layer_shell.isSupported()) { + pub fn supportsQuickTerminal(self: App) bool { + 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 { const window = apprt_window.window.as(gtk.Window); - gtk4_layer_shell.initForWindow(window); - gtk4_layer_shell.setLayer(window, .top); - gtk4_layer_shell.setKeyboardMode(window, .on_demand); + layer_shell.initForWindow(window); + layer_shell.setLayer(window, .top); + layer_shell.setNamespace(window, "ghostty-quick-terminal"); + } + + 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( @@ -118,71 +154,54 @@ pub const App = struct { event: wl.Registry.Event, context: *Context, ) void { + const ctx_fields = @typeInfo(Context).@"struct".fields; + switch (event) { - // https://wayland.app/protocols/wayland#wl_registry:event:global - .global => |global| { - log.debug("wl_registry.global: interface={s}", .{global.interface}); + .global => |v| global: { + // 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; - if (registryBind( - org.KdeKwinBlurManager, - registry, - global, - )) |blur_manager| { - context.kde_blur_manager = blur_manager; - return; - } + inline for (ctx_fields) |field| { + const T = getInterfaceType(field) orelse continue; - if (registryBind( - org.KdeKwinServerDecorationManager, - registry, - global, - )) |deco_manager| { - context.kde_decoration_manager = deco_manager; - deco_manager.setListener(*Context, decoManagerListener, context); - return; - } + if (std.mem.orderZ( + u8, + v.interface, + T.interface.name, + ) != .eq) break :global; - if (registryBind( - org.KdeKwinSlideManager, - registry, - global, - )) |slide_manager| { - context.kde_slide_manager = slide_manager; - return; + @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; + }; } }, - // We don't handle removal events - .global_remove => {}, + // 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; + } + } + }, } } - /// Bind a Wayland interface to a global object. Returns non-null - /// if the binding was successful, otherwise null. - /// - /// The type T is the Wayland interface type that we're requesting. - /// This function will verify that the global object is the correct - /// interface and version before binding. - fn registryBind( - comptime T: type, - registry: *wl.Registry, - global: anytype, - ) ?*T { - if (std.mem.orderZ( - u8, - global.interface, - T.interface.name, - ) != .eq) return null; - - return registry.bind(global.name, T, T.generated_version) catch |err| { - log.warn("error binding interface {s} error={}", .{ - global.interface, - err, - }); - return null; - }; - } - fn decoManagerListener( _: *org.KdeKwinServerDecorationManager, event: org.KdeKwinServerDecorationManager.Event, @@ -207,15 +226,19 @@ pub const Window = struct { app_context: *App.Context, /// A token that, when present, indicates that the window is blurred. - blur_token: ?*org.KdeKwinBlur, + blur_token: ?*org.KdeKwinBlur = null, /// Object that controls the decoration mode (client/server/auto) /// of the window. - decoration: ?*org.KdeKwinServerDecoration, + decoration: ?*org.KdeKwinServerDecoration = null, /// Object that controls the slide-in/slide-out animations of the /// quick terminal. Always null for windows other than the quick terminal. - slide: ?*org.KdeKwinSlide, + slide: ?*org.KdeKwinSlide = null, + + /// Object that, when present, denotes that the window is currently + /// requesting attention from the user. + activation_token: ?*xdg.ActivationTokenV1 = null, pub fn init( alloc: Allocator, @@ -268,9 +291,7 @@ pub const Window = struct { .apprt_window = apprt_window, .surface = wl_surface, .app_context = app.context, - .blur_token = null, .decoration = deco, - .slide = null, }; } @@ -315,6 +336,21 @@ pub const Window = struct { _ = env; } + pub fn setUrgent(self: *Window, urgent: bool) !void { + const activation = self.app_context.xdg_activation orelse return; + + // If there already is a token, destroy and unset it + if (self.activation_token) |token| token.destroy(); + + self.activation_token = if (urgent) token: { + const token = try activation.getActivationToken(); + token.setSurface(self.surface); + token.setListener(*Window, onActivationTokenEvent, self); + token.commit(); + break :token token; + } else null; + } + /// Update the blur state of the window. fn syncBlur(self: *Window) !void { const manager = self.app_context.kde_blur_manager orelse return; @@ -356,9 +392,24 @@ pub const Window = struct { fn syncQuickTerminal(self: *Window) !void { const window = self.apprt_window.window.as(gtk.Window); - const position = self.apprt_window.config.quick_terminal_position; + const config = &self.apprt_window.config; - const anchored_edge: ?gtk4_layer_shell.ShellEdge = switch (position) { + layer_shell.setKeyboardMode( + window, + switch (config.quick_terminal_keyboard_interactivity) { + .none => .none, + .@"on-demand" => on_demand: { + if (layer_shell.getProtocolVersion() < 4) { + log.warn("your compositor does not support on-demand keyboard access; falling back to exclusive access", .{}); + break :on_demand .exclusive; + } + break :on_demand .on_demand; + }, + .exclusive => .exclusive, + }, + ); + + const anchored_edge: ?layer_shell.ShellEdge = switch (config.quick_terminal_position) { .left => .left, .right => .right, .top => .top, @@ -366,43 +417,41 @@ pub const Window = struct { .center => null, }; - for (std.meta.tags(gtk4_layer_shell.ShellEdge)) |edge| { + for (std.meta.tags(layer_shell.ShellEdge)) |edge| { if (anchored_edge) |anchored| { if (edge == anchored) { - gtk4_layer_shell.setMargin(window, edge, 0); - gtk4_layer_shell.setAnchor(window, edge, true); + layer_shell.setMargin(window, edge, 0); + layer_shell.setAnchor(window, edge, true); continue; } } // Arbitrary margin - could be made customizable? - gtk4_layer_shell.setMargin(window, edge, 20); - gtk4_layer_shell.setAnchor(window, edge, false); + layer_shell.setMargin(window, edge, 20); + layer_shell.setAnchor(window, edge, false); } - if (self.apprt_window.isQuickTerminal()) { - if (self.slide) |slide| slide.release(); + 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; + self.slide = if (anchored_edge) |anchored| slide: { + const mgr = self.app_context.kde_slide_manager orelse break :slide null; - const slide = mgr.create(self.surface) catch |err| { - log.warn("could not create slide object={}", .{err}); - break :slide null; - }; + const slide = mgr.create(self.surface) catch |err| { + log.warn("could not create slide object={}", .{err}); + break :slide null; + }; - const slide_location: org.KdeKwinSlide.Location = switch (anchored) { - .top => .top, - .bottom => .bottom, - .left => .left, - .right => .right, - }; + const slide_location: org.KdeKwinSlide.Location = switch (anchored) { + .top => .top, + .bottom => .bottom, + .left => .left, + .right => .right, + }; - slide.setLocation(@intCast(@intFromEnum(slide_location))); - slide.commit(); - break :slide slide; - } else null; - } + slide.setLocation(@intCast(@intFromEnum(slide_location))); + slide.commit(); + break :slide slide; + } else null; } /// Update the size of the quick terminal based on monitor dimensions. @@ -412,17 +461,41 @@ pub const Window = struct { apprt_window: *ApprtWindow, ) callconv(.c) void { const window = apprt_window.window.as(gtk.Window); - const size = apprt_window.config.quick_terminal_size; - const position = apprt_window.config.quick_terminal_position; + const config = &apprt_window.config; var monitor_size: gdk.Rectangle = undefined; monitor.getGeometry(&monitor_size); - const dims = size.calculate(position, .{ - .width = @intCast(monitor_size.f_width), - .height = @intCast(monitor_size.f_height), - }); + const dims = config.quick_terminal_size.calculate( + config.quick_terminal_position, + .{ + .width = @intCast(monitor_size.f_width), + .height = @intCast(monitor_size.f_height), + }, + ); window.setDefaultSize(@intCast(dims.width), @intCast(dims.height)); } + + fn onActivationTokenEvent( + token: *xdg.ActivationTokenV1, + event: xdg.ActivationTokenV1.Event, + self: *Window, + ) void { + const activation = self.app_context.xdg_activation orelse return; + const current_token = self.activation_token orelse return; + + if (token.getId() != current_token.getId()) { + log.warn("received event for unknown activation token; ignoring", .{}); + return; + } + + switch (event) { + .done => |done| { + activation.activate(done.token, self.surface); + token.destroy(); + self.activation_token = null; + }, + } + } }; diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index c2b6bf416..624de03f8 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -36,16 +36,11 @@ pub const App = struct { config: *const Config, ) !?App { // If the display isn't X11, then we don't need to do anything. - if (gobject.typeCheckInstanceIsA( - gdk_display.as(gobject.TypeInstance), - gdk_x11.X11Display.getGObjectType(), - ) == 0) return null; - - // Get our X11 display const gdk_x11_display = gobject.ext.cast( gdk_x11.X11Display, gdk_display, ) orelse return null; + const xlib_display = gdk_x11_display.getXdisplay(); const x11_program_name: [:0]const u8 = if (config.@"x11-instance-name") |pn| @@ -109,7 +104,7 @@ pub const App = struct { return .{ .display = xlib_display, .base_event_code = base_event_code, - .atoms = Atoms.init(gdk_x11_display), + .atoms = .init(gdk_x11_display), }; } @@ -176,8 +171,8 @@ pub const App = struct { pub const Window = struct { app: *App, config: *const ApprtWindow.DerivedConfig, - window: xlib.Window, gtk_window: *adw.ApplicationWindow, + x11_surface: *gdk_x11.X11Surface, blur_region: Region = .{}, @@ -192,13 +187,6 @@ pub const Window = struct { gtk.Native, ).getSurface() orelse return error.NotX11Surface; - // Check if we're actually on X11 - if (gobject.typeCheckInstanceIsA( - surface.as(gobject.TypeInstance), - gdk_x11.X11Surface.getGObjectType(), - ) == 0) - return error.NotX11Surface; - const x11_surface = gobject.ext.cast( gdk_x11.X11Surface, surface, @@ -207,8 +195,8 @@ pub const Window = struct { return .{ .app = app, .config = &apprt_window.config, - .window = x11_surface.getXid(), .gtk_window = apprt_window.window, + .x11_surface = x11_surface, }; } @@ -279,7 +267,7 @@ pub const Window = struct { const blur = self.config.background_blur; log.debug("set blur={}, window xid={}, region={}", .{ blur, - self.window, + self.x11_surface.getXid(), self.blur_region, }); @@ -335,11 +323,19 @@ pub const Window = struct { pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { var buf: [64]u8 = undefined; - const window_id = try std.fmt.bufPrint(&buf, "{}", .{self.window}); + const window_id = try std.fmt.bufPrint( + &buf, + "{}", + .{self.x11_surface.getXid()}, + ); try env.put("WINDOWID", window_id); } + pub fn setUrgent(self: *Window, urgent: bool) !void { + self.x11_surface.setUrgencyHint(@intFromBool(urgent)); + } + fn getWindowProperty( self: *Window, comptime T: type, @@ -363,7 +359,7 @@ pub const Window = struct { const code = c.XGetWindowProperty( @ptrCast(@alignCast(self.app.display)), - self.window, + self.x11_surface.getXid(), name, options.offset, options.length, @@ -401,7 +397,7 @@ pub const Window = struct { const status = c.XChangeProperty( @ptrCast(@alignCast(self.app.display)), - self.window, + self.x11_surface.getXid(), name, typ, @intFromEnum(format), @@ -419,7 +415,7 @@ pub const Window = struct { fn deleteProperty(self: *Window, name: c.Atom) X11Error!void { const status = c.XDeleteProperty( @ptrCast(@alignCast(self.app.display)), - self.window, + self.x11_surface.getXid(), name, ); if (status == 0) return error.RequestFailed; diff --git a/src/apprt/none.zig b/src/apprt/none.zig index 76a0a8ecb..76faa88af 100644 --- a/src/apprt/none.zig +++ b/src/apprt/none.zig @@ -1,2 +1,4 @@ +const internal_os = @import("../os/main.zig"); +pub const resourcesDir = internal_os.resourcesDir; pub const App = struct {}; pub const Surface = struct {}; diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 6de41c544..fcc67134b 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -43,8 +43,9 @@ pub const Message = union(enum) { close: void, /// The child process running in the surface has exited. This may trigger - /// a surface close, it may not. - child_exited: void, + /// a surface close, it may not. Additional details about the child + /// command are given in the `ChildExited` struct. + child_exited: ChildExited, /// Show a desktop notification. desktop_notification: struct { @@ -74,7 +75,7 @@ pub const Message = union(enum) { /// A terminal color was changed using OSC sequences. color_change: struct { - kind: terminal.osc.Command.ColorKind, + kind: terminal.osc.Command.ColorOperation.Kind, color: terminal.color.RGB, }, @@ -89,6 +90,11 @@ pub const Message = union(enum) { // This enum is a placeholder for future title styles. }; + + pub const ChildExited = struct { + exit_code: u32, + runtime_ms: u64, + }; }; /// A surface mailbox. diff --git a/src/build/Config.zig b/src/build/Config.zig index 8974e1f0c..5f8780af9 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -87,7 +87,7 @@ pub fn init(b: *std.Build) !Config { // 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. - const system_package: bool = b.graph.system_package_mode; + const system_package = b.graph.system_package_mode; // This specifies our target wasm runtime. For now only one semi-usable // one exists so this is hardcoded. @@ -361,7 +361,6 @@ pub fn init(b: *std.Build) !Config { "libpng", "zlib", "oniguruma", - "gtk4-layer-shell", }) |dep| { _ = b.systemIntegrationOption( dep, @@ -387,6 +386,15 @@ pub fn init(b: *std.Build) !Config { }) |dep| { _ = b.systemIntegrationOption(dep, .{ .default = false }); } + + // These are dynamic libraries we default to true, preferring + // to use system packages over building and installing libs + // as they require additional ldconfig of library paths or + // patching the rpath of the program to discover the dynamic library + // at runtime + for (&[_][]const u8{"gtk4-layer-shell"}) |dep| { + _ = b.systemIntegrationOption(dep, .{ .default = true }); + } } return config; diff --git a/src/build/GhosttyI18n.zig b/src/build/GhosttyI18n.zig index daf523938..e0f6b5611 100644 --- a/src/build/GhosttyI18n.zig +++ b/src/build/GhosttyI18n.zig @@ -1,6 +1,7 @@ const GhosttyI18n = @This(); const std = @import("std"); +const builtin = @import("builtin"); const Config = @import("Config.zig"); const gresource = @import("../apprt/gtk/gresource.zig"); const internal_os = @import("../os/main.zig"); @@ -21,6 +22,14 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyI18n { defer steps.deinit(); inline for (internal_os.i18n.locales) |locale| { + // There is no encoding suffix in the LC_MESSAGES path on FreeBSD, + // so we need to remove it from `locale` to have a correct destination string. + // (/usr/local/share/locale/en_AU/LC_MESSAGES) + const target_locale = comptime if (builtin.target.os.tag == .freebsd) + std.mem.trimRight(u8, locale, ".UTF-8") + else + locale; + const msgfmt = b.addSystemCommand(&.{ "msgfmt", "-o", "-" }); msgfmt.addFileArg(b.path("po/" ++ locale ++ ".po")); @@ -28,7 +37,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyI18n { msgfmt.captureStdOut(), std.fmt.comptimePrint( "share/locale/{s}/LC_MESSAGES/{s}.mo", - .{ locale, domain }, + .{ target_locale, domain }, ), ).step); } @@ -54,7 +63,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { "--keyword=C_:1c,2", "--package-name=" ++ domain, "--msgid-bugs-address=m@mitchellh.com", - "--copyright-holder=Mitchell Hashimoto", + "--copyright-holder=\"Mitchell Hashimoto, Ghostty contributors\"", "-o", "-", }); diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 3d6b99a34..34b5e35f8 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -1,6 +1,8 @@ const GhosttyResources = @This(); const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; const buildpkg = @import("main.zig"); const Config = @import("Config.zig"); const config_vim = @import("../config/vim.zig"); @@ -16,6 +18,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { // Terminfo terminfo: { + const os_tag = cfg.target.result.os.tag; + const terminfo_share_dir = if (os_tag == .freebsd) + "site-terminfo" + else + "terminfo"; + // Encode our terminfo var str = std.ArrayList(u8).init(b.allocator); defer str.deinit(); @@ -26,12 +34,19 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { const source = wf.add("ghostty.terminfo", str.items); if (cfg.emit_terminfo) { - const source_install = b.addInstallFile(source, "share/terminfo/ghostty.terminfo"); + const source_install = b.addInstallFile( + source, + if (os_tag == .freebsd) + "share/site-terminfo/ghostty.terminfo" + else + "share/terminfo/ghostty.terminfo", + ); + try steps.append(&source_install.step); } // Windows doesn't have the binaries below. - if (cfg.target.result.os.tag == .windows) break :terminfo; + if (os_tag == .windows) break :terminfo; // Convert to termcap source format if thats helpful to people and // install it. The resulting value here is the termcap source in case @@ -43,7 +58,14 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { const out_source = run_step.captureStdOut(); _ = run_step.captureStdErr(); // so we don't see stderr - const cap_install = b.addInstallFile(out_source, "share/terminfo/ghostty.termcap"); + const cap_install = b.addInstallFile( + out_source, + if (os_tag == .freebsd) + "share/site-terminfo/ghostty.termcap" + else + "share/terminfo/ghostty.termcap", + ); + try steps.append(&cap_install.step); } @@ -51,7 +73,8 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { { const run_step = RunStep.create(b, "tic"); run_step.addArgs(&.{ "tic", "-x", "-o" }); - const path = run_step.addOutputFileArg("terminfo"); + const path = run_step.addOutputFileArg(terminfo_share_dir); + run_step.addFileArg(source); _ = run_step.captureStdErr(); // so we don't see stderr @@ -63,7 +86,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { .windows => mkdir_step.addArgs(&.{"mkdir"}), else => mkdir_step.addArgs(&.{ "mkdir", "-p" }), } - mkdir_step.addArg(b.fmt("{s}/share/terminfo", .{b.install_path})); + + mkdir_step.addArg(b.fmt( + "{s}/share/{s}", + .{ b.install_path, terminfo_share_dir }, + )); + try steps.append(&mkdir_step.step); // Use cp -R instead of Step.InstallDir because we need to preserve @@ -193,83 +221,178 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { } // App (Linux) - if (cfg.target.result.os.tag == .linux) { - // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html + if (cfg.target.result.os.tag == .linux) try addLinuxAppResources( + b, + cfg, + &steps, + ); + + return .{ .steps = steps.items }; +} + +/// Add the resource files needed to make Ghostty a proper +/// Linux desktop application (for various desktop environments). +fn addLinuxAppResources( + b: *std.Build, + cfg: *const Config, + steps: *std.ArrayList(*std.Build.Step), +) !void { + assert(cfg.target.result.os.tag == .linux); + + // Background: + // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html + + const name = b.fmt("Ghostty{s}", .{ + switch (cfg.optimize) { + .Debug, .ReleaseSafe => " (Debug)", + .ReleaseFast, .ReleaseSmall => "", + }, + }); + + const app_id = b.fmt("com.mitchellh.ghostty{s}", .{ + switch (cfg.optimize) { + .Debug, .ReleaseSafe => "-debug", + .ReleaseFast, .ReleaseSmall => "", + }, + }); + + const exe_abs_path = b.fmt( + "{s}/bin/ghostty", + .{b.install_prefix}, + ); + + // The templates that we will process. The templates are in + // cmake format and will be processed and saved to the + // second element of the tuple. + const Template = struct { std.Build.LazyPath, []const u8 }; + const templates: []const Template = templates: { + var ts: std.ArrayList(Template) = .init(b.allocator); // Desktop file so that we have an icon and other metadata - try steps.append(&b.addInstallFile( - b.path("dist/linux/app.desktop"), - "share/applications/com.mitchellh.ghostty.desktop", - ).step); + try ts.append(.{ + b.path("dist/linux/app.desktop.in"), + b.fmt("share/applications/{s}.desktop", .{app_id}), + }); - // AppStream metainfo so that application has rich metadata within app stores - try steps.append(&b.addInstallFile( - b.path("dist/linux/com.mitchellh.ghostty.metainfo.xml"), - "share/metainfo/com.mitchellh.ghostty.metainfo.xml", - ).step); + // Service for DBus activation. + try ts.append(.{ + if (cfg.flatpak) + b.path("dist/linux/dbus.service.flatpak.in") + else + b.path("dist/linux/dbus.service.in"), + b.fmt("share/dbus-1/services/{s}.service", .{app_id}), + }); - // Right click menu action for Plasma desktop - try steps.append(&b.addInstallFile( - b.path("dist/linux/ghostty_dolphin.desktop"), - "share/kio/servicemenus/com.mitchellh.ghostty.desktop", - ).step); + // systemd user service. This is kind of nasty but systemd + // looks for user services in different paths depending on + // if we are installed as a system package or not (lib vs. + // share) so we have to handle that here. We might be able + // to get away with always installing to both because it + // only ever searches in one... but I don't want to do that hack + // until we have to. + if (!cfg.flatpak) try ts.append(.{ + b.path("dist/linux/systemd.service.in"), + b.fmt( + "{s}/systemd/user/{s}.service", + .{ + if (b.graph.system_package_mode) "lib" else "share", + app_id, + }, + ), + }); - // Right click menu action for Nautilus. Note that this _must_ be named - // `ghostty.py`. Using the full app id causes problems (see #5468). - try steps.append(&b.addInstallFile( - b.path("dist/linux/ghostty_nautilus.py"), - "share/nautilus-python/extensions/ghostty.py", - ).step); + // AppStream metainfo so that application has rich metadata + // within app stores + try ts.append(.{ + b.path("dist/linux/com.mitchellh.ghostty.metainfo.xml.in"), + b.fmt("share/metainfo/{s}.metainfo.xml", .{app_id}), + }); - // Various icons that our application can use, including the icon - // that will be used for the desktop. - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_16.png"), - "share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_32.png"), - "share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_128.png"), - "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_256.png"), - "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_512.png"), - "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png", - ).step); - // Flatpaks only support icons up to 512x512. - if (!cfg.flatpak) { - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_1024.png"), - "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png", - ).step); - } + break :templates ts.items; + }; + // Process all our templates + for (templates) |template| { + const tpl = b.addConfigHeader(.{ + .style = .{ .cmake = template[0] }, + }, .{ + .NAME = name, + .APPID = app_id, + .GHOSTTY = exe_abs_path, + }); + + // Template output has a single header line we want to remove. + // We use `tail` to do it since its part of the POSIX standard. + const tail = b.addSystemCommand(&.{ "tail", "-n", "+2" }); + tail.setStdIn(.{ .lazy_path = tpl.getOutput() }); + + const copy = b.addInstallFile( + tail.captureStdOut(), + template[1], + ); + + try steps.append(©.step); + } + + // Right click menu action for Plasma desktop + try steps.append(&b.addInstallFile( + b.path("dist/linux/ghostty_dolphin.desktop"), + "share/kio/servicemenus/com.mitchellh.ghostty.desktop", + ).step); + + // Right click menu action for Nautilus. Note that this _must_ be named + // `ghostty.py`. Using the full app id causes problems (see #5468). + try steps.append(&b.addInstallFile( + b.path("dist/linux/ghostty_nautilus.py"), + "share/nautilus-python/extensions/ghostty.py", + ).step); + + // Various icons that our application can use, including the icon + // that will be used for the desktop. + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_16.png"), + "share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_32.png"), + "share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_128.png"), + "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_256.png"), + "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_512.png"), + "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png", + ).step); + // Flatpaks only support icons up to 512x512. + if (!cfg.flatpak) { try steps.append(&b.addInstallFile( - b.path("images/icons/icon_16@2x.png"), - "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_32@2x.png"), - "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_128@2x.png"), - "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_256@2x.png"), - "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png", + b.path("images/icons/icon_1024.png"), + "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png", ).step); } - return .{ .steps = steps.items }; + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_16@2x.png"), + "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_32@2x.png"), + "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_128@2x.png"), + "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_256@2x.png"), + "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png", + ).step); } pub fn install(self: *const GhosttyResources) void { diff --git a/src/build/MetallibStep.zig b/src/build/MetallibStep.zig index 12adf3edb..6999f8f31 100644 --- a/src/build/MetallibStep.zig +++ b/src/build/MetallibStep.zig @@ -22,13 +22,26 @@ step: *Step, output: LazyPath, pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { - const self = b.allocator.create(MetallibStep) catch @panic("OOM"); - const sdk = switch (opts.target.result.os.tag) { .macos => "macosx", - .ios => "iphoneos", + .ios => switch (opts.target.result.abi) { + // The iOS simulator uses the same SDK for Metal as the device, + // but the minimum version tag causes different behaviors. + .simulator => "iphoneos", + else => "iphoneos", + }, else => return null, }; + const platform_version_arg = switch (opts.target.result.os.tag) { + .macos => "-mmacos-version-min", + .ios => switch (opts.target.result.abi) { + .simulator => "-mios-simulator-version-min", + else => "-mios-version-min", + }, + else => null, + }; + + const self = b.allocator.create(MetallibStep) catch @panic("OOM"); const min_version = if (opts.target.query.os_version_min) |v| b.fmt("{}", .{v.semver}) @@ -46,16 +59,11 @@ pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { const output_ir = run_ir.addOutputFileArg(b.fmt("{s}.ir", .{opts.name})); run_ir.addArgs(&.{"-c"}); for (opts.sources) |source| run_ir.addFileArg(source); - switch (opts.target.result.os.tag) { - .ios => run_ir.addArgs(&.{b.fmt( - "-mios-version-min={s}", - .{min_version}, - )}), - .macos => run_ir.addArgs(&.{b.fmt( - "-mmacos-version-min={s}", - .{min_version}, - )}), - else => {}, + if (platform_version_arg) |arg| { + run_ir.addArgs(&.{b.fmt( + "{s}={s}", + .{ arg, min_version }, + )}); } const run_lib = RunStep.create( diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 0df261600..ec97a9c9f 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -24,9 +24,9 @@ pub const LazyPathList = std.ArrayList(std.Build.LazyPath); pub fn init(b: *std.Build, cfg: *const Config) !SharedDeps { var result: SharedDeps = .{ .config = cfg, - .help_strings = try HelpStrings.init(b, cfg), - .unicode_tables = try UnicodeTables.init(b), - .framedata = try GhosttyFrameData.init(b), + .help_strings = try .init(b, cfg), + .unicode_tables = try .init(b), + .framedata = try .init(b), // Setup by retarget .options = undefined, @@ -72,10 +72,10 @@ fn initTarget( target: std.Build.ResolvedTarget, ) !void { // Update our metallib - self.metallib = MetallibStep.create(b, .{ + self.metallib = .create(b, .{ .name = "Ghostty", .target = target, - .sources = &.{b.path("src/renderer/shaders/cell.metal")}, + .sources = &.{b.path("src/renderer/shaders/shaders.metal")}, }); // Change our config @@ -377,7 +377,7 @@ pub fn add( // We always require the system SDK so that our system headers are available. // This makes things like `os/log.h` available for cross-compiling. if (step.rootModuleTarget().os.tag.isDarwin()) { - try @import("apple_sdk").addPaths(b, step.root_module); + try @import("apple_sdk").addPaths(b, step); const metallib = self.metallib.?; metallib.output.addStepDependencies(&step.step); @@ -609,21 +609,23 @@ fn addGTK( .wayland_protocols = wayland_protocols_dep.path(""), }); - // 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/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"), ); scanner.addCustomProtocol( plasma_wayland_protocols_dep.path("src/protocols/slide.xml"), ); + scanner.addSystemProtocol("staging/xdg-activation/xdg-activation-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("xdg_activation_v1", 1); step.root_module.addImport("wayland", b.createModule(.{ .root_source_file = scanner.result, @@ -650,14 +652,13 @@ fn addGTK( // IMPORTANT: gtk4-layer-shell must be linked BEFORE // wayland-client, as it relies on shimming libwayland's APIs. if (b.systemIntegrationOption("gtk4-layer-shell", .{})) { - step.linkSystemLibrary2( - "gtk4-layer-shell-0", - dynamic_link_opts, - ); + step.linkSystemLibrary2("gtk4-layer-shell-0", dynamic_link_opts); } else { // gtk4-layer-shell *must* be dynamically linked, // so we don't add it as a static library - step.linkLibrary(gtk4_layer_shell.artifact("gtk4-layer-shell")); + const shared_lib = gtk4_layer_shell.artifact("gtk4-layer-shell"); + b.installArtifact(shared_lib); + step.linkLibrary(shared_lib); } } diff --git a/src/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md index 7ace64cd8..f8e502b45 100644 --- a/src/build/mdgen/ghostty_1_footer.md +++ b/src/build/mdgen/ghostty_1_footer.md @@ -44,6 +44,7 @@ See GitHub issues: # AUTHOR Mitchell Hashimoto +Ghostty contributors # SEE ALSO diff --git a/src/build/mdgen/ghostty_5_footer.md b/src/build/mdgen/ghostty_5_footer.md index c5077ab97..380d83a53 100644 --- a/src/build/mdgen/ghostty_5_footer.md +++ b/src/build/mdgen/ghostty_5_footer.md @@ -36,6 +36,7 @@ See GitHub issues: # AUTHOR Mitchell Hashimoto +Ghostty contributors # SEE ALSO diff --git a/src/build/mdgen/mdgen.zig b/src/build/mdgen/mdgen.zig index aca230aa5..e7d966323 100644 --- a/src/build/mdgen/mdgen.zig +++ b/src/build/mdgen/mdgen.zig @@ -26,7 +26,7 @@ pub fn genConfig(writer: anytype, cli: bool) !void { \\ ); - @setEvalBranchQuota(3000); + @setEvalBranchQuota(5000); inline for (@typeInfo(Config).@"struct".fields) |field| { if (field.name[0] == '_') continue; @@ -94,6 +94,7 @@ pub fn genKeybindActions(writer: anytype) !void { const info = @typeInfo(KeybindAction); std.debug.assert(info == .@"union"); + @setEvalBranchQuota(5000); inline for (info.@"union".fields) |field| { if (field.name[0] == '_') continue; diff --git a/src/cli.zig b/src/cli.zig index 4336501a8..151e6e648 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -2,6 +2,8 @@ const diags = @import("cli/diagnostics.zig"); pub const args = @import("cli/args.zig"); pub const Action = @import("cli/action.zig").Action; +pub const CompatibilityHandler = args.CompatibilityHandler; +pub const compatibilityRenamed = args.compatibilityRenamed; pub const DiagnosticList = diags.DiagnosticList; pub const Diagnostic = diags.Diagnostic; pub const Location = diags.Location; diff --git a/src/cli/action.zig b/src/cli/action.zig index a53e55ef8..009afb4c9 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -9,6 +9,7 @@ const list_keybinds = @import("list_keybinds.zig"); const list_themes = @import("list_themes.zig"); const list_colors = @import("list_colors.zig"); const list_actions = @import("list_actions.zig"); +const edit_config = @import("edit_config.zig"); const show_config = @import("show_config.zig"); const validate_config = @import("validate_config.zig"); const crash_report = @import("crash_report.zig"); @@ -40,6 +41,9 @@ pub const Action = enum { /// List keybind actions @"list-actions", + /// Edit the config file in the configured terminal editor. + @"edit-config", + /// Dump the config to stdout @"show-config", @@ -151,6 +155,7 @@ pub const Action = enum { .@"list-themes" => try list_themes.run(alloc), .@"list-colors" => try list_colors.run(alloc), .@"list-actions" => try list_actions.run(alloc), + .@"edit-config" => try edit_config.run(alloc), .@"show-config" => try show_config.run(alloc), .@"validate-config" => try validate_config.run(alloc), .@"crash-report" => try crash_report.run(alloc), @@ -187,6 +192,7 @@ pub const Action = enum { .@"list-themes" => list_themes.Options, .@"list-colors" => list_colors.Options, .@"list-actions" => list_actions.Options, + .@"edit-config" => edit_config.Options, .@"show-config" => show_config.Options, .@"validate-config" => validate_config.Options, .@"crash-report" => crash_report.Options, diff --git a/src/cli/args.zig b/src/cli/args.zig index 4860cdd74..1af74df69 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -40,11 +40,14 @@ pub const Error = error{ /// "DiagnosticList" and any diagnostic messages will be added to that list. /// When diagnostics are present, only allocation errors will be returned. /// -/// If the destination type has a decl "renamed", it must be of type -/// std.StaticStringMap([]const u8) and contains a mapping from the old -/// field name to the new field name. This is used to allow renaming fields -/// while still supporting the old name. If a renamed field is set, parsing -/// will automatically set the new field name. +/// If the destination type has a decl "compatibility", it must be of type +/// std.StaticStringMap(CompatibilityHandler(T)), and it will be used to +/// handle backwards compatibility for fields with the given name. The +/// field name doesn't need to exist (so you can setup compatibility for +/// removed fields). The value is a function that will be called when +/// all other parsing fails for that field. If a field changes such that +/// the old values would NOT error, then the caller should handle that +/// downstream after parsing is done, not through this method. /// /// Note: If the arena is already non-null, then it will be used. In this /// case, in the case of an error some memory might be leaked into the arena. @@ -57,24 +60,6 @@ pub fn parse( const info = @typeInfo(T); assert(info == .@"struct"); - comptime { - // Verify all renamed fields are valid (source does not exist, - // destination does exist). - if (@hasDecl(T, "renamed")) { - for (T.renamed.keys(), T.renamed.values()) |key, value| { - if (@hasField(T, key)) { - @compileLog(key); - @compileError("renamed field source exists"); - } - - if (!@hasField(T, value)) { - @compileLog(value); - @compileError("renamed field destination does not exist"); - } - } - } - } - // Make an arena for all our allocations if we support it. Otherwise, // use an allocator that always fails. If the arena is already set on // the config, then we reuse that. See memory note in parse docs. @@ -84,7 +69,7 @@ pub fn parse( // If the arena is unset, we create it. We mark that we own it // only so that we can clean it up on error. if (dst._arena == null) { - dst._arena = ArenaAllocator.init(alloc); + dst._arena = .init(alloc); arena_owned = true; } @@ -147,7 +132,23 @@ pub fn parse( break :value null; }; - parseIntoField(T, arena_alloc, dst, key, value) catch |err| { + parseIntoField(T, arena_alloc, dst, key, value) catch |err| err: { + // If we get an error parsing a field, then we try to fall + // back to compatibility handlers if able. + if (@hasDecl(T, "compatibility")) { + // If we have a compatibility handler for this key, then + // we call it and see if it handles the error. + if (T.compatibility.get(key)) |handler| { + if (handler(dst, arena_alloc, key, value)) { + log.info( + "compatibility handler for {s} handled error, you may be using a deprecated field: {}", + .{ key, err }, + ); + break :err; + } + } + } + if (comptime !canTrackDiags(T)) return err; // The error set is dependent on comptime T, so we always add @@ -177,6 +178,58 @@ pub fn parse( } } +/// The function type for a compatibility handler. The compatibility +/// handler is documented in the `parse` function documentation. +/// +/// The function type should return bool if the compatibility was +/// handled, and false otherwise. If false is returned then the +/// naturally occurring error will continue to be processed as if +/// this compatibility handler was not present. +/// +/// Compatibility handlers aren't allowed to return errors because +/// they're generally only called in error cases, so we already have +/// an error message to show users. If there is an error in handling +/// the compatibility, then the handler should return false. +pub fn CompatibilityHandler(comptime T: type) type { + return *const fn ( + dst: *T, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, + ) bool; +} + +/// Convenience function to create a compatibility handler that +/// renames a field from `from` to `to`. +pub fn compatibilityRenamed( + comptime T: type, + comptime to: []const u8, +) CompatibilityHandler(T) { + comptime assert(@hasField(T, to)); + + return (struct { + fn compat( + dst: *T, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, + ) bool { + _ = key; + + parseIntoField(T, alloc, dst, to, value) catch |err| { + log.warn("error parsing renamed field {s}: {}", .{ + to, + err, + }); + + return false; + }; + + return true; + } + }).compat; +} + fn formatValueRequired( comptime T: type, arena_alloc: std.mem.Allocator, @@ -401,20 +454,10 @@ pub fn parseIntoField( } } - // Unknown field, is the field renamed? - if (@hasDecl(T, "renamed")) { - for (T.renamed.keys(), T.renamed.values()) |old, new| { - if (mem.eql(u8, old, key)) { - try parseIntoField(T, alloc, dst, new, value); - return; - } - } - } - return error.InvalidField; } -fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T { +pub fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T { const info = @typeInfo(T).@"union"; assert(@typeInfo(info.tag_type.?) == .@"enum"); @@ -481,7 +524,7 @@ pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T { // Keep track of which fields were set so we can error if a required // field was not set. const FieldSet = std.StaticBitSet(info.fields.len); - var fields_set: FieldSet = FieldSet.initEmpty(); + var fields_set: FieldSet = .initEmpty(); // We split each value by "," var iter = std.mem.splitSequence(u8, v, ","); @@ -752,6 +795,77 @@ test "parse: diagnostic location" { } } +test "parse: compatibility handler" { + const testing = std.testing; + + var data: struct { + a: bool = false, + _arena: ?ArenaAllocator = null, + + pub const compatibility: std.StaticStringMap( + CompatibilityHandler(@This()), + ) = .initComptime(&.{ + .{ "a", compat }, + }); + + fn compat( + self: *@This(), + alloc: Allocator, + key: []const u8, + value: ?[]const u8, + ) bool { + _ = alloc; + if (std.mem.eql(u8, key, "a")) { + if (value) |v| { + if (mem.eql(u8, v, "yuh")) { + self.a = true; + return true; + } + } + } + + return false; + } + } = .{}; + defer if (data._arena) |arena| arena.deinit(); + + var iter = try std.process.ArgIteratorGeneral(.{}).init( + testing.allocator, + "--a=yuh", + ); + defer iter.deinit(); + try parse(@TypeOf(data), testing.allocator, &data, &iter); + try testing.expect(data._arena != null); + try testing.expect(data.a); +} + +test "parse: compatibility renamed" { + const testing = std.testing; + + var data: struct { + a: bool = false, + b: bool = false, + _arena: ?ArenaAllocator = null, + + pub const compatibility: std.StaticStringMap( + CompatibilityHandler(@This()), + ) = .initComptime(&.{ + .{ "old", compatibilityRenamed(@This(), "a") }, + }); + } = .{}; + defer if (data._arena) |arena| arena.deinit(); + + var iter = try std.process.ArgIteratorGeneral(.{}).init( + testing.allocator, + "--old=true --b=true", + ); + defer iter.deinit(); + try parse(@TypeOf(data), testing.allocator, &data, &iter); + try testing.expect(data._arena != null); + try testing.expect(data.a); + try testing.expect(data.b); +} + test "parseIntoField: ignore underscore-prefixed fields" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); @@ -1090,6 +1204,7 @@ test "parseIntoField: tagged union" { b: u8, c: void, d: []const u8, + e: [:0]const u8, } = undefined, } = .{}; @@ -1108,6 +1223,10 @@ test "parseIntoField: tagged union" { // Set string field try parseIntoField(@TypeOf(data), alloc, &data, "value", "d:hello"); try testing.expectEqualStrings("hello", data.value.d); + + // Set sentinel string field + try parseIntoField(@TypeOf(data), alloc, &data, "value", "e:hello"); + try testing.expectEqualStrings("hello", data.value.e); } test "parseIntoField: tagged union unknown filed" { @@ -1171,24 +1290,6 @@ test "parseIntoField: tagged union missing tag" { ); } -test "parseIntoField: renamed field" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var data: struct { - a: []const u8, - - const renamed = std.StaticStringMap([]const u8).initComptime(&.{ - .{ "old", "a" }, - }); - } = undefined; - - try parseIntoField(@TypeOf(data), alloc, &data, "old", "42"); - try testing.expectEqualStrings("42", data.a); -} - /// An iterator that considers its location to be CLI args. It /// iterates through an underlying iterator and increments a counter /// to track the current CLI arg index. diff --git a/src/cli/boo.zig b/src/cli/boo.zig index 7ecbf79fb..47c8ab741 100644 --- a/src/cli/boo.zig +++ b/src/cli/boo.zig @@ -176,7 +176,7 @@ const Boo = struct { pub fn run(gpa: Allocator) !u8 { // Disable on non-desktop systems. switch (builtin.os.tag) { - .windows, .macos, .linux => {}, + .windows, .macos, .linux, .freebsd => {}, else => return 1, } diff --git a/src/cli/edit_config.zig b/src/cli/edit_config.zig new file mode 100644 index 000000000..3be88e090 --- /dev/null +++ b/src/cli/edit_config.zig @@ -0,0 +1,159 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const args = @import("args.zig"); +const Allocator = std.mem.Allocator; +const Action = @import("action.zig").Action; +const configpkg = @import("../config.zig"); +const internal_os = @import("../os/main.zig"); +const Config = configpkg.Config; + +pub const Options = struct { + 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 `edit-config` command opens the Ghostty configuration file in the +/// editor specified by the `$VISUAL` or `$EDITOR` environment variables. +/// +/// IMPORTANT: This command will not reload the configuration after +/// editing. You will need to manually reload the configuration using the +/// application menu, configured keybind, or by restarting Ghostty. We +/// plan to auto-reload in the future, but Ghostty isn't capable of +/// this yet. +/// +/// The filepath opened is the default user-specific configuration +/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config`. +/// On macOS, this may also be located at +/// `~/Library/Application Support/com.mitchellh.ghostty/config`. +/// On macOS, whichever path exists and is non-empty will be prioritized, +/// prioritizing the Application Support directory if neither are +/// non-empty. +/// +/// This command prefers the `$VISUAL` environment variable over `$EDITOR`, +/// if both are set. If neither are set, it will print an error +/// and exit. +pub fn run(alloc: Allocator) !u8 { + // Implementation note (by @mitchellh): I do proper memory cleanup + // throughout this command, even though we plan on doing `exec`. + // I do this out of good hygiene in case we ever change this to + // not using `exec` anymore and because this command isn't performance + // critical where setting up the defer cleanup is a problem. + + const stderr = std.io.getStdErr().writer(); + + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try args.argsIterator(alloc); + defer iter.deinit(); + try args.parse(Options, alloc, &opts, &iter); + } + + // We load the configuration once because that will write our + // default configuration files to disk. We don't use the config. + var config = try Config.load(alloc); + defer config.deinit(); + + // Find the preferred path. + const path = try Config.preferredDefaultFilePath(alloc); + defer alloc.free(path); + + // We don't currently support Windows because we use the exec syscall. + if (comptime builtin.os.tag == .windows) { + try stderr.print( + \\The `ghostty +edit-config` command is not supported on Windows. + \\Please edit the configuration file manually at the following path: + \\ + \\{s} + \\ + , + .{path}, + ); + return 1; + } + + // Get our editor + const get_env_: ?internal_os.GetEnvResult = env: { + // VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference + if (try internal_os.getenv(alloc, "VISUAL")) |v| { + if (v.value.len > 0) break :env v; + v.deinit(alloc); + } + + if (try internal_os.getenv(alloc, "EDITOR")) |v| { + if (v.value.len > 0) break :env v; + v.deinit(alloc); + } + + break :env null; + }; + defer if (get_env_) |v| v.deinit(alloc); + const editor: []const u8 = if (get_env_) |v| v.value else ""; + + // If we don't have `$EDITOR` set then we can't do anything + // but we can still print a helpful message. + if (editor.len == 0) { + try stderr.print( + \\The $EDITOR or $VISUAL environment variable is not set or is empty. + \\This environment variable is required to edit the Ghostty configuration + \\via this CLI command. + \\ + \\Please set the environment variable to your preferred terminal + \\text editor and try again. + \\ + \\If you prefer to edit the configuration file another way, + \\you can find the configuration file at the following path: + \\ + \\ + , + .{}, + ); + + // Output the path using the OSC8 sequence so that it is linked. + try stderr.print( + "\x1b]8;;file://{s}\x1b\\{s}\x1b]8;;\x1b\\\n", + .{ path, path }, + ); + + return 1; + } + + // 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 }, + std.c.environ, + ); + + // If we reached this point then exec failed. + try stderr.print( + \\Failed to execute the editor. Error code={}. + \\ + \\This is usually due to the executable path not existing, invalid + \\permissions, or the shell environment not being set up + \\correctly. + \\ + \\Editor: {s} + \\Path: {s} + \\ + , .{ err, editor, path }); + return 1; +} diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 54f4c0969..e80a92286 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -77,7 +77,7 @@ const ThemeListElement = struct { /// Two different directories will be searched for themes. /// /// The first directory is the `themes` subdirectory of your Ghostty -/// configuration directory. This is `$XDG_CONFIG_DIR/ghostty/themes` or +/// configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or /// `~/.config/ghostty/themes`. /// /// The second directory is the `themes` subdirectory of the Ghostty resources @@ -115,7 +115,8 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { const stderr = std.io.getStdErr().writer(); const stdout = std.io.getStdOut().writer(); - if (global_state.resources_dir == null) + const resources_dir = global_state.resources_dir.app(); + if (resources_dir == null) try stderr.print("Could not find the Ghostty resources directory. Please ensure " ++ "that Ghostty is installed correctly.\n", .{}); diff --git a/src/config.zig b/src/config.zig index fb7359b3e..7f390fb08 100644 --- a/src/config.zig +++ b/src/config.zig @@ -3,6 +3,7 @@ const builtin = @import("builtin"); const formatter = @import("config/formatter.zig"); pub const Config = @import("config/Config.zig"); pub const conditional = @import("config/conditional.zig"); +pub const io = @import("config/io.zig"); pub const string = @import("config/string.zig"); pub const edit = @import("config/edit.zig"); pub const url = @import("config/url.zig"); @@ -30,8 +31,11 @@ pub const RepeatableFontVariation = Config.RepeatableFontVariation; pub const RepeatableString = Config.RepeatableString; pub const RepeatableStringMap = @import("config/RepeatableStringMap.zig"); pub const RepeatablePath = Config.RepeatablePath; +pub const Path = Config.Path; pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures; pub const WindowPaddingColor = Config.WindowPaddingColor; +pub const BackgroundImagePosition = Config.BackgroundImagePosition; +pub const BackgroundImageFit = Config.BackgroundImageFit; // Alternate APIs pub const CAPI = @import("config/CAPI.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index 6f1e89d41..14ab5219d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -34,6 +34,7 @@ const ErrorList = @import("ErrorList.zig"); const MetricModifier = fontpkg.Metrics.Modifier; const help_strings = @import("help_strings"); pub const Command = @import("command.zig").Command; +const RepeatableReadableIO = @import("io.zig").RepeatableReadableIO; const RepeatableStringMap = @import("RepeatableStringMap.zig"); pub const Path = @import("path.zig").Path; pub const RepeatablePath = @import("path.zig").RepeatablePath; @@ -45,14 +46,22 @@ const c = @cImport({ @cInclude("unistd.h"); }); -/// Renamed fields, used by cli.parse -pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{ +pub const compatibility = std.StaticStringMap( + cli.CompatibilityHandler(Config), +).initComptime(&.{ // Ghostty 1.1 introduced background-blur support for Linux which // doesn't support a specific radius value. The renaming is to let // one field be used for both platforms (macOS retained the ability // to set a radius). - .{ "background-blur-radius", "background-blur" }, - .{ "adw-toolbar-style", "gtk-toolbar-style" }, + .{ "background-blur-radius", cli.compatibilityRenamed(Config, "background-blur") }, + + // Ghostty 1.2 renamed all our adw options to gtk because we now have + // a hard dependency on libadwaita. + .{ "adw-toolbar-style", cli.compatibilityRenamed(Config, "gtk-toolbar-style") }, + + // Ghostty 1.2 removed the `hidden` value from `gtk-tabs-location` and + // moved it to `window-show-tab-bar`. + .{ "gtk-tabs-location", compatGtkTabsLocation }, }); /// The font families to use. @@ -266,6 +275,9 @@ pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{ /// This affects the appearance of text and of any images with transparency. /// Additionally, custom shaders will receive colors in the configured space. /// +/// On macOS the default is `native`, on all other platforms the default is +/// `linear-corrected`. +/// /// Valid values: /// /// * `native` - Perform alpha blending in the native color space for the OS. @@ -276,12 +288,15 @@ pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{ /// when certain color combinations are used (e.g. red / green), but makes /// dark text look much thinner than normal and light text much thicker. /// This is also sometimes known as "gamma correction". -/// (Currently only supported on macOS. Has no effect on Linux.) /// /// * `linear-corrected` - Same as `linear`, but with a correction step applied /// for text that makes it look nearly or completely identical to `native`, /// but without any of the darkening artifacts. -@"alpha-blending": AlphaBlending = .native, +@"alpha-blending": AlphaBlending = + if (builtin.os.tag == .macos) + .native + else + .@"linear-corrected", /// All of the configurations behavior adjust various metrics determined by the /// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%, @@ -410,7 +425,7 @@ pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{ /// include path separators unless it is an absolute pathname. /// /// The first directory is the `themes` subdirectory of your Ghostty -/// configuration directory. This is `$XDG_CONFIG_DIR/ghostty/themes` or +/// configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or /// `~/.config/ghostty/themes`. /// /// The second directory is the `themes` subdirectory of the Ghostty resources @@ -459,6 +474,93 @@ background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 }, /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, +/// Background image for the terminal. +/// +/// This should be a path to a PNG or JPEG file, other image formats are +/// not yet supported. +/// +/// The background image is currently per-terminal, not per-window. If +/// you are a heavy split user, the background image will be repeated across +/// splits. A future improvement to Ghostty will address this. +/// +/// WARNING: Background images are currently duplicated in VRAM per-terminal. +/// For sufficiently large images, this could lead to a large increase in +/// memory usage (specifically VRAM usage). A future Ghostty improvement +/// will resolve this by sharing image textures across terminals. +@"background-image": ?Path = null, + +/// Background image opacity. +/// +/// This is relative to the value of `background-opacity`. +/// +/// A value of `1.0` (the default) will result in the background image being +/// placed on top of the general background color, and then the combined result +/// will be adjusted to the opacity specified by `background-opacity`. +/// +/// A value less than `1.0` will result in the background image being mixed +/// with the general background color before the combined result is adjusted +/// to the configured `background-opacity`. +/// +/// A value greater than `1.0` will result in the background image having a +/// higher opacity than the general background color. For instance, if the +/// configured `background-opacity` is `0.5` and `background-image-opacity` +/// is set to `1.5`, then the final opacity of the background image will be +/// `0.5 * 1.5 = 0.75`. +@"background-image-opacity": f32 = 1.0, + +/// Background image position. +/// +/// Valid values are: +/// * `top-left` +/// * `top-center` +/// * `top-right` +/// * `center-left` +/// * `center` +/// * `center-right` +/// * `bottom-left` +/// * `bottom-center` +/// * `bottom-right` +/// +/// The default value is `center`. +@"background-image-position": BackgroundImagePosition = .center, + +/// Background image fit. +/// +/// Valid values are: +/// +/// * `contain` +/// +/// Preserving the aspect ratio, scale the background image to the largest +/// size that can still be contained within the terminal, so that the whole +/// image is visible. +/// +/// * `cover` +/// +/// Preserving the aspect ratio, scale the background image to the smallest +/// size that can completely cover the terminal. This may result in one or +/// more edges of the image being clipped by the edge of the terminal. +/// +/// * `stretch` +/// +/// Stretch the background image to the full size of the terminal, without +/// preserving the aspect ratio. +/// +/// * `none` +/// +/// Don't scale the background image. +/// +/// The default value is `contain`. +@"background-image-fit": BackgroundImageFit = .contain, + +/// Whether to repeat the background image or not. +/// +/// If this is set to true, the background image will be repeated if there +/// would otherwise be blank space around it because it doesn't completely +/// fill the terminal area. +/// +/// The default value is `false`. +@"background-image-repeat": bool = false, + /// The foreground and background color for selection. If this is not set, then /// the selection color is just the inverted window background and foreground /// (note: not to be confused with the cell bg/fg). @@ -801,6 +903,47 @@ command: ?Command = null, /// browser. env: RepeatableStringMap = .{}, +/// Data to send as input to the command on startup. +/// +/// The configured `command` will be launched using the typical rules, +/// then the data specified as this input will be written to the pty +/// before any other input can be provided. +/// +/// The bytes are sent as-is with no additional encoding. Therefore, be +/// cautious about input that can contain control characters, because this +/// can be used to execute programs in a shell. +/// +/// The format of this value is: +/// +/// * `raw:` - Send raw text as-is. This uses Zig string literal +/// syntax so you can specify control characters and other standard +/// escapes. +/// +/// * `path:` - Read a filepath and send the contents. The path +/// must be to a file with finite length. e.g. don't use a device +/// such as `/dev/stdin` or `/dev/urandom` as these will block +/// terminal startup indefinitely. Files are limited to 10MB +/// in size to prevent excessive memory usage. If you have files +/// larger than this you should write a script to read the file +/// and send it to the terminal. +/// +/// If no valid prefix is found, it is assumed to be a `raw:` input. +/// This is an ergonomic choice to allow you to simply write +/// `input = "Hello, world!"` (a common case) without needing to prefix +/// every value with `raw:`. +/// +/// This can be repeated multiple times to send more data. The data +/// is concatenated directly with no separator characters in between +/// (e.g. no newline). +/// +/// If any of the input sources do not exist, then none of the input +/// will be sent. Input sources are not verified until the terminal +/// is starting, so missing paths will not show up in config validation. +/// +/// Changing this configuration at runtime will only affect new +/// terminals. +input: RepeatableReadableIO = .{}, + /// If true, keep the terminal open after the command exits. Normally, the /// terminal window closes when the running command (such as a shell) exits. /// With this true, the terminal window will stay open until any keypress is @@ -894,12 +1037,17 @@ title: ?[:0]const u8 = null, /// The setting that will change the application class value. /// /// This controls the class field of the `WM_CLASS` X11 property (when running -/// under X11), and the Wayland application ID (when running under Wayland). +/// under X11), the Wayland application ID (when running under Wayland), and the +/// bus name that Ghostty uses to connect to DBus. /// /// Note that changing this value between invocations will create new, separate /// instances, of Ghostty when running with `gtk-single-instance=true`. See that /// option for more details. /// +/// Changing this value may break launching Ghostty from `.desktop` files, via +/// DBus activation, or systemd user services as the system is expecting Ghostty +/// to connect to DBus using the default `class` when it is launched. +/// /// The class name must follow the requirements defined [in the GTK /// documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html). /// @@ -1446,6 +1594,27 @@ keybind: Keybinds = .{}, /// * `end` - Insert the new tab at the end of the tab list. @"window-new-tab-position": WindowNewTabPosition = .current, +/// Whether to show the tab bar. +/// +/// Valid values: +/// +/// - `always` +/// +/// Always display the tab bar, even when there's only one tab. +/// +/// - `auto` *(default)* +/// +/// Automatically show and hide the tab bar. The tab bar is only +/// shown when there are two or more tabs present. +/// +/// - `never` +/// +/// Never show the tab bar. Tabs are only accessible via the tab +/// overview or by keybind actions. +/// +/// Currently only supported on Linux (GTK). +@"window-show-tab-bar": WindowShowTabBar = .auto, + /// Background color for the window titlebar. This only takes effect if /// window-theme is set to ghostty. Currently only supported in the GTK app /// runtime. @@ -1705,6 +1874,52 @@ keybind: Keybinds = .{}, /// window is ever created. Only implemented on Linux and macOS. @"initial-window": bool = true, +/// The duration that undo operations remain available. After this +/// time, the operation will be removed from the undo stack and +/// cannot be undone. +/// +/// The default value is 5 seconds. +/// +/// This timeout applies per operation, meaning that if you perform +/// multiple operations, each operation will have its own timeout. +/// New operations do not reset the timeout of previous operations. +/// +/// A timeout of zero will effectively disable undo operations. It is +/// not possible to set an infinite timeout, but you can set a very +/// large timeout to effectively disable the timeout (on the order of years). +/// This is highly discouraged, as it will cause the undo stack to grow +/// indefinitely, memory usage to grow unbounded, and terminal sessions +/// to never actually quit. +/// +/// The duration is specified as a series of numbers followed by time units. +/// Whitespace is allowed between numbers and units. Each number and unit will +/// be added together to form the total duration. +/// +/// The allowed time units are as follows: +/// +/// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments +/// are made for leap years or leap seconds. +/// * `d` - one SI day, or 86400 seconds. +/// * `h` - one hour, or 3600 seconds. +/// * `m` - one minute, or 60 seconds. +/// * `s` - one second. +/// * `ms` - one millisecond, or 0.001 second. +/// * `us` or `µs` - one microsecond, or 0.000001 second. +/// * `ns` - one nanosecond, or 0.000000001 second. +/// +/// Examples: +/// * `1h30m` +/// * `45s` +/// +/// Units can be repeated and will be added together. This means that +/// `1h1h` is equivalent to `2h`. This is confusing and should be avoided. +/// A future update may disallow this. +/// +/// This configuration is only supported on macOS. Linux doesn't +/// support undo operations at all so this configuration has no +/// effect. +@"undo-timeout": Duration = .{ .duration = 5 * std.time.ns_per_s }, + /// The position of the "quick" terminal window. To learn more about the /// quick terminal, see the documentation for the `toggle_quick_terminal` /// binding action. @@ -1777,7 +1992,7 @@ keybind: Keybinds = .{}, /// Automatically hide the quick terminal when focus shifts to another window. /// Set it to false for the quick terminal to remain open even when it loses focus. /// -/// Defaults to true on macOS and on false on Linux. This is because global +/// Defaults to true on macOS and on false on Linux/BSD. This is because global /// shortcuts on Linux require system configuration and are considerably less /// accessible than on macOS, meaning that it is more preferable to keep the /// quick terminal open until the user has completed their task. @@ -1808,6 +2023,34 @@ keybind: Keybinds = .{}, /// On Linux the behavior is always equivalent to `move`. @"quick-terminal-space-behavior": QuickTerminalSpaceBehavior = .move, +/// Determines under which circumstances that the quick terminal should receive +/// keyboard input. See the corresponding [Wayland documentation](https://wayland.app/protocols/wlr-layer-shell-unstable-v1#zwlr_layer_surface_v1:enum:keyboard_interactivity) +/// for a more detailed explanation of the behavior of each option. +/// +/// > [!NOTE] +/// > The exact behavior of each option may differ significantly across +/// > compositors -- experiment with them on your system to find one that +/// > suits your liking! +/// +/// Valid values are: +/// +/// * `none` +/// +/// The quick terminal will not receive any keyboard input. +/// +/// * `on-demand` (default) +/// +/// The quick terminal would only receive keyboard input when it is focused. +/// +/// * `exclusive` +/// +/// The quick terminal will always receive keyboard input, even when another +/// window is currently focused. +/// +/// Only has an effect on Linux Wayland. +/// On macOS the behavior is always equivalent to `on-demand`. +@"quick-terminal-keyboard-interactivity": QuickTerminalKeyboardInteractivity = .@"on-demand", + /// Whether to enable shell integration auto-injection or not. Shell integration /// greatly enhances the terminal experience by enabling a number of features: /// @@ -1853,6 +2096,28 @@ keybind: Keybinds = .{}, /// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` @"shell-integration-features": ShellIntegrationFeatures = .{}, +/// Custom entries into the command palette. +/// +/// Each entry requires the title, the corresponding action, and an optional +/// description. Each field should be prefixed with the field name, a colon +/// (`:`), and then the specified value. The syntax for actions is identical +/// to the one for keybind actions. Whitespace in between fields is ignored. +/// +/// ```ini +/// command-palette-entry = title:Reset Font Style, action:csi:0m +/// command-palette-entry = title:Crash on Main Thread,description:Causes a crash on the main (UI) thread.,action:crash:main +/// ``` +/// +/// By default, the command palette is preloaded with most actions that might +/// be useful in an interactive setting yet do not have easily accessible or +/// memorizable shortcuts. The default entries can be cleared by setting this +/// setting to an empty value: +/// +/// ```ini +/// command-palette-entry = +/// ``` +@"command-palette-entry": RepeatableCommand = .{}, + /// Sets the reporting format for OSC sequences that request color information. /// Ghostty currently supports OSC 10 (foreground), OSC 11 (background), and /// OSC 4 (256 color palette) queries, and by default the reported values @@ -1887,12 +2152,59 @@ keybind: Keybinds = .{}, /// causing the window to be completely black. If this happens, you can /// unset this configuration to disable the shader. /// -/// On Linux, this requires OpenGL 4.2. Ghostty typically only requires -/// OpenGL 3.3, but custom shaders push that requirement up to 4.2. +/// Custom shader support is based on and compatible with the Shadertoy shaders. +/// Shaders should specify a `mainImage` function and the available uniforms +/// largely match Shadertoy, with some caveats and Ghostty-specific extensions. /// -/// The shader API is identical to the Shadertoy API: you specify a `mainImage` -/// function and the available uniforms match Shadertoy. The iChannel0 uniform -/// is a texture containing the rendered terminal screen. +/// The uniform values available to shaders are as follows: +/// +/// * `sampler2D iChannel0` - Input texture. +/// +/// A texture containing the current terminal screen. If multiple custom +/// shaders are specified, the output of previous shaders is written to +/// this texture, to allow combining multiple effects. +/// +/// * `vec3 iResolution` - Output texture size, `[width, height, 1]` (in px). +/// +/// * `float iTime` - Time in seconds since first frame was rendered. +/// +/// * `float iTimeDelta` - Time in seconds since previous frame was rendered. +/// +/// * `float iFrameRate` - Average framerate. (NOT CURRENTLY SUPPORTED) +/// +/// * `int iFrame` - Number of frames that have been rendered so far. +/// +/// * `float iChannelTime[4]` - Current time for video or sound input. (N/A) +/// +/// * `vec3 iChannelResolution[4]` - Resolutions of the 4 input samplers. +/// +/// Currently only `iChannel0` exists, and `iChannelResolution[0]` is +/// identical to `iResolution`. +/// +/// * `vec4 iMouse` - Mouse input info. (NOT CURRENTLY SUPPORTED) +/// +/// * `vec4 iDate` - Date/time info. (NOT CURRENTLY SUPPORTED) +/// +/// * `float iSampleRate` - Sample rate for audio. (N/A) +/// +/// Ghostty-specific extensions: +/// +/// * `vec4 iCurrentCursor` - Info about the terminal cursor. +/// +/// - `iCurrentCursor.xy` is the -X, +Y corner of the current cursor. +/// - `iCurrentCursor.zw` is the width and height of the current cursor. +/// +/// * `vec4 iPreviousCursor` - Info about the previous terminal cursor. +/// +/// * `vec4 iCurrentCursorColor` - Color of the terminal cursor. +/// +/// * `vec4 iPreviousCursorColor` - Color of the previous terminal cursor. +/// +/// * `float iTimeCursorChange` - Timestamp of terminal cursor change. +/// +/// When the terminal cursor changes position or color, this is set to +/// the same time as the `iTime` uniform, allowing you to compute the +/// time since the change by subtracting this from `iTime`. /// /// If the shader fails to compile, the shader will be ignored. Any errors /// related to shader compilation will not show up as configuration errors @@ -1903,8 +2215,7 @@ keybind: Keybinds = .{}, /// This can be repeated multiple times to load multiple shaders. The shaders /// will be run in the order they are specified. /// -/// Changing this value at runtime and reloading the configuration will only -/// affect new windows, tabs, and splits. +/// This can be changed at runtime and will affect all open terminals. @"custom-shader": RepeatablePath = .{}, /// If `true` (default), the focused terminal surface will run an animation @@ -1922,8 +2233,7 @@ keybind: Keybinds = .{}, /// will use more CPU per terminal surface and can become quite expensive /// depending on the shader and your terminal usage. /// -/// This value can be changed at runtime and will affect all currently -/// open terminals. +/// This can be changed at runtime and will affect all open terminals. @"custom-shader-animation": CustomShaderAnimation = .true, /// Bell features to enable if bell support is available in your runtime. Not @@ -1935,7 +2245,7 @@ keybind: Keybinds = .{}, /// /// * `system` /// -/// Instructs the system to notify the user using built-in system functions. +/// Instruct the system to notify the user using built-in system functions. /// This could result in an audiovisual effect, a notification, or something /// else entirely. Changing these effects require altering system settings: /// for instance under the "Sound > Alert Sound" setting in GNOME, @@ -1945,15 +2255,31 @@ keybind: Keybinds = .{}, /// /// Play a custom sound. (GTK only) /// -/// Example: `audio`, `no-audio`, `system`, `no-system`: +/// * `attention` *(enabled by default)* /// -/// On macOS, if the app is unfocused, it will bounce the app icon in the dock -/// once. Additionally, the title of the window with the alerted terminal -/// surface will contain a bell emoji (🔔) until the terminal is focused -/// or a key is pressed. These are not currently configurable since they're -/// considered unobtrusive. +/// Request the user's attention when Ghostty is unfocused, until it has +/// received focus again. On macOS, this will bounce the app icon in the +/// dock once. On Linux, the behavior depends on the desktop environment +/// and/or the window manager/compositor: /// -/// By default, no bell features are enabled. +/// - On KDE, the background of the desktop icon in the task bar would be +/// highlighted; +/// +/// - On GNOME, you may receive a notification that, when clicked, would +/// bring the Ghostty window into focus; +/// +/// - On Sway, the window may be decorated with a distinctly colored border; +/// +/// - On other systems this may have no effect at all. +/// +/// * `title` *(enabled by default)* +/// +/// Prepend a bell emoji (🔔) to the title of the alerted surface until the +/// terminal is re-focused or interacted with (such as on keyboard input). +/// +/// Only implemented on macOS. +/// +/// Example: `audio`, `no-audio`, `system`, `no-system` @"bell-features": BellFeatures = .{}, /// If `audio` is an enabled bell feature, this is a path to an audio file. If @@ -2025,6 +2351,25 @@ keybind: Keybinds = .{}, /// it will retain the previous setting until fullscreen is exited. @"macos-non-native-fullscreen": NonNativeFullscreen = .false, +/// Whether the window buttons in the macOS titlebar are visible. The window +/// buttons are the colored buttons in the upper left corner of most macOS apps, +/// also known as the traffic lights, that allow you to close, miniaturize, and +/// zoom the window. +/// +/// This setting has no effect when `window-decoration = false` or +/// `macos-titlebar-style = hidden`, as the window buttons are always hidden in +/// these modes. +/// +/// Valid values are: +/// +/// * `visible` - Show the window buttons. +/// * `hidden` - Hide the window buttons. +/// +/// The default value is `visible`. +/// +/// Changing this option at runtime only applies to new windows. +@"macos-window-buttons": MacWindowButtons = .visible, + /// The style of the macOS titlebar. Available values are: "native", /// "transparent", "tabs", and "hidden". /// @@ -2246,6 +2591,29 @@ keybind: Keybinds = .{}, /// @"macos-icon-screen-color": ?ColorList = null, +/// Whether macOS Shortcuts are allowed to control Ghostty. +/// +/// Ghostty exposes a number of actions that allow Shortcuts to +/// control and interact with Ghostty. This includes creating new +/// terminals, sending text to terminals, running commands, invoking +/// any keybind action, etc. +/// +/// This is a powerful feature but can be a security risk if a malicious +/// shortcut is able to be installed and executed. Therefore, this +/// configuration allows you to disable this feature. +/// +/// Valid values are: +/// +/// * `ask` - Ask the user whether for permission. Ghostty will remember +/// this choice and never ask again. This is similar to other macOS +/// permissions such as microphone access, camera access, etc. +/// +/// * `allow` - Allow Shortcuts to control Ghostty without asking. +/// +/// * `deny` - Deny Shortcuts from controlling Ghostty. +/// +@"macos-shortcuts": MacShortcuts = .ask, + /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// /// This makes it so that resource management can be done on a per-surface @@ -2272,7 +2640,10 @@ keybind: Keybinds = .{}, /// * `single-instance` - Enable cgroups only for Ghostty instances launched /// as single-instance applications (see gtk-single-instance). /// -@"linux-cgroup": LinuxCgroup = .@"single-instance", +@"linux-cgroup": LinuxCgroup = if (builtin.os.tag == .linux) + .@"single-instance" +else + .never, /// Memory limit for any individual terminal process (tab, split, window, /// etc.) in bytes. If this is unset then no memory limit will be set. @@ -2400,6 +2771,23 @@ term: []const u8 = "xterm-ghostty", /// running. Defaults to an empty string if not set. @"enquiry-response": []const u8 = "", +/// The mechanism used to launch Ghostty. This should generally not be +/// set by users, see the warning below. +/// +/// WARNING: This is a low-level configuration that is not intended to be +/// modified by users. All the values will be automatically detected as they +/// are needed by Ghostty. This is only here in case our detection logic is +/// incorrect for your environment or for developers who want to test +/// Ghostty's behavior in different, forced environments. +/// +/// This is set using the standard `no-[value]`, `[value]` syntax separated +/// by commas. Example: "no-desktop,systemd". Specific details about the +/// available values are documented on LaunchProperties in the code. Since +/// this isn't intended to be modified by users, the documentation is +/// lighter than the other configurations and users are expected to +/// refer to the code for details. +@"launched-from": ?LaunchSource = null, + /// Configures the low-level API to use for async IO, eventing, etc. /// /// Most users should leave this set to `auto`. This will automatically detect @@ -2531,7 +2919,7 @@ pub fn load(alloc_gpa: Allocator) !Config { pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { // Build up our basic config var result: Config = .{ - ._arena = ArenaAllocator.init(alloc_gpa), + ._arena = .init(alloc_gpa), }; errdefer result.deinit(); const alloc = result._arena.?.allocator(); @@ -2539,6 +2927,9 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { // Add our default keybindings try result.keybind.init(alloc); + // Add our default command palette entries + try result.@"command-palette-entry".init(alloc); + // Add our default link for URL detection try result.link.links.append(alloc, .{ .regex = url.regex, @@ -2564,24 +2955,20 @@ pub fn loadIter( /// `path` must be resolved and absolute. pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void { assert(std.fs.path.isAbsolute(path)); - - var file = try std.fs.openFileAbsolute(path, .{}); - defer file.close(); - - const stat = try file.stat(); - switch (stat.kind) { - .file => {}, - else => |kind| { - log.warn("config-file {s}: not reading because file type is {s}", .{ - path, - @tagName(kind), - }); + var file = openFile(path) catch |err| switch (err) { + error.NotAFile => { + log.warn( + "config-file {s}: not reading because it is not a file", + .{path}, + ); return; }, - } + + else => return err, + }; + defer file.close(); std.log.info("reading configuration file path={s}", .{path}); - var buf_reader = std.io.bufferedReader(file.reader()); const reader = buf_reader.reader(); const Iter = cli.args.LineIterator(@TypeOf(reader)); @@ -2636,13 +3023,13 @@ fn writeConfigTemplate(path: []const u8) !void { /// is also loaded. pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { // Load XDG first - const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" }); + const xdg_path = try defaultXdgPath(alloc); defer alloc.free(xdg_path); const xdg_action = self.loadOptionalFile(alloc, xdg_path); // On macOS load the app support directory as well if (comptime builtin.os.tag == .macos) { - const app_support_path = try internal_os.macos.appSupportDir(alloc, "config"); + const app_support_path = try defaultAppSupportPath(alloc); defer alloc.free(app_support_path); const app_support_action = self.loadOptionalFile(alloc, app_support_path); @@ -2662,13 +3049,109 @@ pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { } } +/// Default path for the XDG home configuration file. Returned value +/// must be freed by the caller. +fn defaultXdgPath(alloc: Allocator) ![]const u8 { + return try internal_os.xdg.config( + alloc, + .{ .subdir = "ghostty/config" }, + ); +} + +/// Default path for the macOS Application Support configuration file. +/// Returned value must be freed by the caller. +fn defaultAppSupportPath(alloc: Allocator) ![]const u8 { + return try internal_os.macos.appSupportDir(alloc, "config"); +} + +/// Returns the path to the preferred default configuration file. +/// This is the file where users should place their configuration. +/// +/// This doesn't create or populate the file with any default +/// contents; downstream callers must handle this. +/// +/// The returned value must be freed by the caller. +pub fn preferredDefaultFilePath(alloc: Allocator) ![]const u8 { + switch (builtin.os.tag) { + .macos => { + // macOS prefers the Application Support directory + // if it exists. + const app_support_path = try defaultAppSupportPath(alloc); + if (openFile(app_support_path)) |f| { + f.close(); + return app_support_path; + } else |_| {} + + // Try the XDG path if it exists + const xdg_path = try defaultXdgPath(alloc); + if (openFile(xdg_path)) |f| { + f.close(); + alloc.free(app_support_path); + return xdg_path; + } else |_| {} + defer alloc.free(xdg_path); + + // Neither exist, use app support + return app_support_path; + }, + + // All other platforms use XDG only + else => return try defaultXdgPath(alloc), + } +} + +const OpenFileError = error{ + FileNotFound, + FileIsEmpty, + FileOpenFailed, + NotAFile, +}; + +/// Opens the file at the given path and returns the file handle +/// if it exists and is non-empty. This also constrains the possible +/// errors to a smaller set that we can explicitly handle. +fn openFile(path: []const u8) OpenFileError!std.fs.File { + assert(std.fs.path.isAbsolute(path)); + + var file = std.fs.openFileAbsolute( + path, + .{}, + ) catch |err| switch (err) { + error.FileNotFound => return OpenFileError.FileNotFound, + else => { + log.warn("unexpected file open error path={s} err={}", .{ + path, + err, + }); + return OpenFileError.FileOpenFailed; + }, + }; + errdefer file.close(); + + const stat = file.stat() catch |err| { + log.warn("error getting file stat path={s} err={}", .{ + path, + err, + }); + return OpenFileError.FileOpenFailed; + }; + switch (stat.kind) { + .file => {}, + else => return OpenFileError.NotAFile, + } + + if (stat.size == 0) return OpenFileError.FileIsEmpty; + + return file; +} + /// Load and parse the CLI args. pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { switch (builtin.os.tag) { .windows => {}, // Fast-path if we are Linux and have no args. - .linux => if (std.os.argv.len <= 1) return, + .linux, .freebsd => if (std.os.argv.len <= 1) return, // Everything else we have to at least try because it may // not use std.os.argv. @@ -2686,7 +3169,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { // styling, etc. based on the command. // // See: https://github.com/Vladimir-csp/xdg-terminal-exec - if (comptime builtin.os.tag == .linux) { + if ((comptime builtin.os.tag == .linux) or (comptime builtin.os.tag == .freebsd)) { if (internal_os.xdg.parseTerminalExec(std.os.argv)) |args| { const arena_alloc = self._arena.?.allocator(); @@ -2722,19 +3205,18 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { // can replay if we are discarding the default files. const replay_len_start = self._replay_steps.items.len; - // Keep track of font families because if they are set from the CLI - // then we clear the previously set values. This avoids a UX oddity - // where on the CLI you have to specify `font-family=""` to clear the - // font families before setting a new one. + // font-family settings set via the CLI overwrite any prior values + // rather than append. This avoids a UX oddity where you have to + // specify `font-family=""` to clear the font families. const fields = &[_][]const u8{ "font-family", "font-family-bold", "font-family-italic", "font-family-bold-italic", }; - var counter: [fields.len]usize = undefined; - inline for (fields, 0..) |field, i| { - counter[i] = @field(self, field).list.items.len; + inline for (fields) |field| @field(self, field).overwrite_next = true; + defer { + inline for (fields) |field| @field(self, field).overwrite_next = false; } // Initialize our CLI iterator. @@ -2759,28 +3241,6 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { try new_config.loadIter(alloc_gpa, &it); self.deinit(); self.* = new_config; - } else { - // If any of our font family settings were changed, then we - // replace the entire list with the new list. - inline for (fields, 0..) |field, i| { - const v = &@field(self, field); - - // The list can be empty if it was reset, i.e. --font-family="" - if (v.list.items.len > 0) { - const len = v.list.items.len - counter[i]; - if (len > 0) { - // Note: we don't have to worry about freeing the memory - // that we overwrite or cut off here because its all in - // an arena. - v.list.replaceRangeAssumeCapacity( - 0, - len, - v.list.items[counter[i]..], - ); - v.list.items.len = len; - } - } - } } // Any paths referenced from the CLI are relative to the current working @@ -2901,6 +3361,11 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { } } +/// Get the arena allocator associated with the configuration. +pub fn arenaAlloc(self: *Config) Allocator { + return self._arena.?.allocator(); +} + /// Change the state of conditionals and reload the configuration /// based on the new state. This returns a new configuration based /// on the new state. The caller must free the old configuration if they @@ -2979,6 +3444,15 @@ fn expandPaths(self: *Config, base: []const u8) !void { &self._diagnostics, ); }, + ?RepeatablePath, ?Path => { + if (@field(self, field.name)) |*path| { + try path.expand( + arena_alloc, + base, + &self._diagnostics, + ); + } + }, else => {}, } } @@ -3106,6 +3580,11 @@ pub fn finalize(self: *Config) !void { const alloc = self._arena.?.allocator(); + // Ensure our launch source is properly set. + if (self.@"launched-from" == null) { + self.@"launched-from" = .detect(); + } + // If we have a font-family set and don't set the others, default // the others to the font family. This way, if someone does // --font-family=foo, then we try to get the stylized versions of @@ -3130,14 +3609,11 @@ pub fn finalize(self: *Config) !void { } // The default for the working directory depends on the system. - const wd = self.@"working-directory" orelse wd: { + const wd = self.@"working-directory" orelse switch (self.@"launched-from".?) { // If we have no working directory set, our default depends on - // whether we were launched from the desktop or CLI. - if (internal_os.launchedFromDesktop()) { - break :wd "home"; - } - - break :wd "inherit"; + // whether we were launched from the desktop or elsewhere. + .desktop => "home", + .cli, .dbus, .systemd => "inherit", }; // If we are missing either a command or home directory, we need @@ -3160,7 +3636,10 @@ pub fn finalize(self: *Config) !void { // If we were launched from the desktop, our SHELL env var // will represent our SHELL at login time. We want to use the // latest shell from /etc/passwd or directory services. - if (internal_os.launchedFromDesktop()) break :shell_env; + switch (self.@"launched-from".?) { + .desktop, .dbus, .systemd => break :shell_env, + .cli => {}, + } if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { log.info("default shell source=env value={s}", .{value}); @@ -3321,6 +3800,27 @@ pub fn parseManuallyHook( return true; } +/// parseFieldManuallyFallback is a fallback called only when +/// parsing the field directly failed. It can be used to implement +/// backward compatibility. Since this is only called when parsing +/// fails, it doesn't impact happy-path performance. +fn compatGtkTabsLocation( + self: *Config, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "gtk-tabs-location")); + + if (std.mem.eql(u8, value orelse "", "hidden")) { + self.@"window-show-tab-bar" = .never; + return true; + } + + return false; +} + /// Create a shallow copy of this config. This will share all the memory /// allocated with the previous config but will have a new arena for /// any changes or new allocations. The config should have `deinit` @@ -3332,7 +3832,7 @@ pub fn parseManuallyHook( /// be deallocated while shallow clones exist. pub fn shallowClone(self: *const Config, alloc_gpa: Allocator) Config { var result = self.*; - result._arena = ArenaAllocator.init(alloc_gpa); + result._arena = .init(alloc_gpa); return result; } @@ -4172,6 +4672,11 @@ pub const RepeatableString = struct { // Allocator for the list is the arena for the parent config. list: std.ArrayListUnmanaged([:0]const u8) = .{}, + // If true, then the next value will clear the list and start over + // rather than append. This is a bit of a hack but is here to make + // the font-family set of configurations work with CLI parsing. + overwrite_next: bool = false, + pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { const value = input orelse return error.ValueRequired; @@ -4181,6 +4686,12 @@ pub const RepeatableString = struct { return; } + // If we're overwriting then we clear before appending + if (self.overwrite_next) { + self.list.clearRetainingCapacity(); + self.overwrite_next = false; + } + const copy = try alloc.dupeZ(u8, value); try self.list.append(alloc, copy); } @@ -4247,6 +4758,24 @@ pub const RepeatableString = struct { try testing.expectEqual(@as(usize, 0), list.list.items.len); } + test "parseCLI overwrite" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "A"); + + // Set our overwrite flag + list.overwrite_next = true; + + try list.parseCLI(alloc, "B"); + try testing.expectEqual(@as(usize, 1), list.list.items.len); + try list.parseCLI(alloc, "C"); + try testing.expectEqual(@as(usize, 2), list.list.items.len); + } + test "formatConfig empty" { const testing = std.testing; var buf = std.ArrayList(u8).init(testing.allocator); @@ -4503,6 +5032,12 @@ pub const Keybinds = struct { .{ .reset_font_size = {} }, ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'j' }, .mods = .{ .shift = true, .ctrl = true, .super = true } }, + .{ .write_screen_file = .copy }, + ); + try self.set.put( alloc, .{ .key = .{ .unicode = 'j' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, @@ -4609,25 +5144,29 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'w' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .close_tab = {} }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .shift = true } }, .{ .previous_tab = {} }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .shift = true } }, .{ .next_tab = {} }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .page_up }, .mods = .{ .ctrl = true } }, .{ .previous_tab = {} }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .page_down }, .mods = .{ .ctrl = true } }, .{ .next_tab = {} }, + .{ .performable = true }, ); try self.set.put( alloc, @@ -4639,57 +5178,67 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'e' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_split = .down }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .bracket_left }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .previous }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .bracket_right }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .next }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .up }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_down }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .down }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .left }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .right }, + .{ .performable = true }, ); // Resizing splits - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .up, 10 } }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .down, 10 } }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .left, 10 } }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .right, 10 } }, + .{ .performable = true }, ); // Viewport scrolling @@ -4760,22 +5309,24 @@ pub const Keybinds = struct { const end: u21 = '8'; var i: u21 = start; while (i <= end) : (i += 1) { - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .unicode = i }, .mods = mods, }, .{ .goto_tab = (i - start) + 1 }, + .{ .performable = true }, ); } - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .unicode = '9' }, .mods = mods, }, .{ .last_tab = {} }, + .{ .performable = true }, ); } @@ -4819,6 +5370,26 @@ pub const Keybinds = struct { .{ .select_all = {} }, ); + // Undo/redo + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 't' }, .mods = .{ .super = true, .shift = true } }, + .{ .undo = {} }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true } }, + .{ .undo = {} }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true, .shift = true } }, + .{ .redo = {} }, + .{ .performable = true }, + ); + // Viewport scrolling try self.set.put( alloc, @@ -5725,6 +6296,150 @@ pub const ShellIntegrationFeatures = packed struct { title: bool = true, }; +pub const RepeatableCommand = struct { + value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, + + pub fn init(self: *RepeatableCommand, alloc: Allocator) !void { + self.value = .empty; + try self.value.appendSlice(alloc, inputpkg.command.defaults); + } + + pub fn parseCLI( + self: *RepeatableCommand, + alloc: Allocator, + input_: ?[]const u8, + ) !void { + // Unset or empty input clears the list + const input = input_ orelse ""; + if (input.len == 0) { + self.value.clearRetainingCapacity(); + return; + } + + const cmd = try cli.args.parseAutoStruct( + inputpkg.Command, + alloc, + input, + ); + try self.value.append(alloc, cmd); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const RepeatableCommand, alloc: Allocator) Allocator.Error!RepeatableCommand { + const value = try self.value.clone(alloc); + for (value.items) |*item| { + item.* = try item.clone(alloc); + } + + return .{ .value = value }; + } + + /// Compare if two of our value are equal. Required by Config. + pub fn equal(self: RepeatableCommand, other: RepeatableCommand) bool { + if (self.value.items.len != other.value.items.len) return false; + for (self.value.items, other.value.items) |a, b| { + if (!a.equal(b)) return false; + } + + return true; + } + + /// Used by Formatter + pub fn formatEntry(self: RepeatableCommand, formatter: anytype) !void { + if (self.value.items.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var buf: [4096]u8 = undefined; + for (self.value.items) |item| { + const str = if (item.description.len > 0) std.fmt.bufPrint( + &buf, + "title:{s},description:{s},action:{}", + .{ item.title, item.description, item.action }, + ) else std.fmt.bufPrint( + &buf, + "title:{s},action:{}", + .{ item.title, item.action }, + ); + try formatter.formatEntry([]const u8, str catch return error.OutOfMemory); + } + } + + test "RepeatableCommand parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Foo,action:ignore"); + try list.parseCLI(alloc, "title:Bar,description:bobr,action:text:ale bydle"); + try list.parseCLI(alloc, "title:Quux,description:boo,action:increase_font_size:2.5"); + + try testing.expectEqual(@as(usize, 3), list.value.items.len); + + try testing.expectEqual(inputpkg.Binding.Action.ignore, list.value.items[0].action); + try testing.expectEqualStrings("Foo", list.value.items[0].title); + + try testing.expect(list.value.items[1].action == .text); + try testing.expectEqualStrings("ale bydle", list.value.items[1].action.text); + try testing.expectEqualStrings("Bar", list.value.items[1].title); + try testing.expectEqualStrings("bobr", list.value.items[1].description); + + try testing.expectEqual( + inputpkg.Binding.Action{ .increase_font_size = 2.5 }, + list.value.items[2].action, + ); + try testing.expectEqualStrings("Quux", list.value.items[2].title); + try testing.expectEqualStrings("boo", list.value.items[2].description); + + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.value.items.len); + } + + test "RepeatableCommand formatConfig empty" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var list: RepeatableCommand = .{}; + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = \n", buf.items); + } + + test "RepeatableCommand formatConfig single item" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Bobr, action:text:Bober"); + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:Bober\n", buf.items); + } + + test "RepeatableCommand formatConfig multiple items" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Bobr, action:text:kurwa"); + try list.parseCLI(alloc, "title:Ja, description: pierdole, action:text:jakie bydle"); + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:kurwa\na = title:Ja,description:pierdole,action:text:jakie bydle\n", buf.items); + } +}; + /// OSC 4, 10, 11, and 12 default color reporting format. pub const OSCColorReportFormat = enum { none, @@ -5747,6 +6462,12 @@ pub const WindowColorspace = enum { @"display-p3", }; +/// See macos-window-buttons +pub const MacWindowButtons = enum { + visible, + hidden, +}; + /// See macos-titlebar-style pub const MacTitlebarStyle = enum { native, @@ -5793,6 +6514,13 @@ pub const MacAppIconFrame = enum { chrome, }; +/// See macos-shortcuts +pub const MacShortcuts = enum { + allow, + deny, + ask, +}; + /// See gtk-single-instance pub const GtkSingleInstance = enum { desktop, @@ -5804,7 +6532,6 @@ pub const GtkSingleInstance = enum { pub const GtkTabsLocation = enum { top, bottom, - hidden, }; /// See gtk-toolbar-style @@ -5823,6 +6550,8 @@ pub const AppNotifications = packed struct { pub const BellFeatures = packed struct { system: bool = false, audio: bool = false, + attention: bool = true, + title: bool = true, }; /// See mouse-shift-capture @@ -5853,6 +6582,13 @@ pub const WindowNewTabPosition = enum { end, }; +/// See window-show-tab-bar +pub const WindowShowTabBar = enum { + always, + auto, + never, +}; + /// See resize-overlay pub const ResizeOverlay = enum { always, @@ -5975,7 +6711,7 @@ pub const QuickTerminalSize = struct { it.next() orelse return error.ValueRequired, cli.args.whitespace, ); - self.primary = try Size.parse(primary); + self.primary = try .parse(primary); self.secondary = secondary: { const secondary = std.mem.trim( @@ -5983,7 +6719,7 @@ pub const QuickTerminalSize = struct { it.next() orelse break :secondary null, cli.args.whitespace, ); - break :secondary try Size.parse(secondary); + break :secondary try .parse(secondary); }; if (it.next()) |_| return error.TooManyArguments; @@ -6138,6 +6874,13 @@ pub const QuickTerminalSpaceBehavior = enum { move, }; +/// See quick-terminal-keyboard-interactivity +pub const QuickTerminalKeyboardInteractivity = enum { + none, + @"on-demand", + exclusive, +}; + /// See grapheme-width-method pub const GraphemeWidthMethod = enum { legacy, @@ -6158,6 +6901,28 @@ pub const AlphaBlending = enum { } }; +/// See background-image-position +pub const BackgroundImagePosition = enum { + @"top-left", + @"top-center", + @"top-right", + @"center-left", + @"center-center", + @"center-right", + @"bottom-left", + @"bottom-center", + @"bottom-right", + center, +}; + +/// See background-image-fit +pub const BackgroundImageFit = enum { + contain, + cover, + stretch, + none, +}; + /// See freetype-load-flag pub const FreetypeLoadFlags = packed struct { // The defaults here at the time of writing this match the defaults @@ -6465,7 +7230,7 @@ pub const Duration = struct { if (remaining.len == 0) break; // Find the longest number - const number = number: { + const number: u64 = number: { var prev_number: ?u64 = null; var prev_remaining: ?[]const u8 = null; for (1..remaining.len + 1) |index| { @@ -6479,8 +7244,17 @@ pub const Duration = struct { break :number prev_number; } orelse return error.InvalidValue; - // A number without a unit is invalid - if (remaining.len == 0) return error.InvalidValue; + // A number without a unit is invalid unless the number is + // exactly zero. In that case, the unit is unambiguous since + // its all the same. + if (remaining.len == 0) { + if (number == 0) { + value = 0; + break; + } + + return error.InvalidValue; + } // Find the longest matching unit. Needs to be the longest matching // to distinguish 'm' from 'ms'. @@ -6554,6 +7328,34 @@ pub const Duration = struct { } }; +pub const LaunchSource = enum { + /// Ghostty was launched via the CLI. This is the default if + /// no other source is detected. + cli, + + /// Ghostty was launched in a desktop environment (not via the CLI). + /// This is used to determine some behaviors such as how to read + /// settings, whether single instance defaults to true, etc. + desktop, + + /// Ghostty was started via dbus activation. + dbus, + + /// Ghostty was started via systemd activation. + systemd, + + pub fn detect() LaunchSource { + return if (internal_os.launchedFromDesktop()) + .desktop + else if (internal_os.launchedByDbusActivation()) + .dbus + else if (internal_os.launchedBySystemd()) + .systemd + else + .cli; + } +}; + pub const WindowPadding = struct { const Self = @This(); @@ -6662,6 +7464,11 @@ test "parse duration" { try std.testing.expectEqual(unit.factor, d.duration); } + { + const d = try Duration.parseCLI("0"); + try std.testing.expectEqual(@as(u64, 0), d.duration); + } + { const d = try Duration.parseCLI("100ns"); try std.testing.expectEqual(@as(u64, 100), d.duration); diff --git a/src/config/edit.zig b/src/config/edit.zig index 871a1a755..ae4394942 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -20,10 +20,10 @@ pub fn open(alloc_gpa: Allocator) !void { // Use an arena to make memory management easier in here. var arena = ArenaAllocator.init(alloc_gpa); defer arena.deinit(); - const alloc = arena.allocator(); + const alloc_arena = arena.allocator(); // Get the path we should open - const config_path = try configPath(alloc); + const config_path = try configPath(alloc_arena); // Create config directory recursively. if (std.fs.path.dirname(config_path)) |config_dir| { @@ -41,7 +41,7 @@ pub fn open(alloc_gpa: Allocator) !void { } }; - try internal_os.open(alloc, .text, config_path); + try internal_os.open(alloc_gpa, .text, config_path); } /// Returns the config path to use for open for the current OS. diff --git a/src/config/formatter.zig b/src/config/formatter.zig index ca3da1d91..cabf80953 100644 --- a/src/config/formatter.zig +++ b/src/config/formatter.zig @@ -153,7 +153,7 @@ pub const FileFormatter = struct { // If we're change-tracking then we need the default config to // compare against. var default: ?Config = if (self.changed) - try Config.default(self.alloc) + try .default(self.alloc) else null; defer if (default) |*v| v.deinit(); diff --git a/src/config/io.zig b/src/config/io.zig new file mode 100644 index 000000000..8be4be551 --- /dev/null +++ b/src/config/io.zig @@ -0,0 +1,256 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const string = @import("string.zig"); +const formatterpkg = @import("formatter.zig"); +const cli = @import("../cli.zig"); + +/// ReadableIO is some kind of IO source that is readable. +/// +/// It can be either a direct string or a filepath. The filepath will +/// be deferred and read later, so it won't be checked for existence +/// or readability at configuration time. This allows using a path that +/// might be produced in an intermediate state. +pub const ReadableIO = union(enum) { + const Self = @This(); + + raw: [:0]const u8, + path: [:0]const u8, + + pub fn parseCLI( + self: *Self, + alloc: Allocator, + input_: ?[]const u8, + ) !void { + const input = input_ orelse return error.ValueRequired; + if (input.len == 0) return error.ValueRequired; + + // We create a buffer only to do string parsing and validate + // it works. We store the value as raw so that our formatting + // can recreate it. + { + const buf = try alloc.alloc(u8, input.len); + defer alloc.free(buf); + _ = try string.parse(buf, input); + } + + // Next, parse the tagged union using normal rules. + self.* = cli.args.parseTaggedUnion( + Self, + alloc, + input, + ) catch |err| switch (err) { + // Invalid values in the tagged union are interpreted as + // raw values. This lets users pass in simple string values + // without needing to tag them. + error.InvalidValue => .{ .raw = try alloc.dupeZ(u8, input) }, + else => return err, + }; + } + + pub fn clone(self: Self, alloc: Allocator) Allocator.Error!Self { + return switch (self) { + .raw => |v| .{ .raw = try alloc.dupeZ(u8, v) }, + .path => |v| .{ .path = try alloc.dupeZ(u8, v) }, + }; + } + + /// Same as clone but also parses the values as Zig strings in + /// the final resulting value all at once so we can avoid extra + /// allocations. + pub fn cloneParsed( + self: Self, + alloc: Allocator, + ) Allocator.Error!Self { + switch (self) { + inline else => |v, tag| { + // Parsing can't fail because we validate it in parseCLI + const copied = try alloc.dupeZ(u8, v); + const parsed = string.parse(copied, v) catch unreachable; + assert(copied.ptr == parsed.ptr); + + // If we parsed less than our original length we need + // to keep it null-terminated. + if (parsed.len < copied.len) copied[parsed.len] = 0; + + return @unionInit( + Self, + @tagName(tag), + copied[0..parsed.len :0], + ); + }, + } + } + + pub fn equal(self: Self, other: Self) bool { + if (std.meta.activeTag(self) != std.meta.activeTag(other)) { + return false; + } + + return switch (self) { + .raw => |v| std.mem.eql(u8, v, other.raw), + .path => |v| std.mem.eql(u8, v, other.path), + }; + } + + pub fn formatEntry(self: Self, formatter: anytype) !void { + var buf: [4096]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const writer = fbs.writer(); + switch (self) { + inline else => |v, tag| { + writer.writeAll(@tagName(tag)) catch return error.OutOfMemory; + writer.writeByte(':') catch return error.OutOfMemory; + writer.writeAll(v) catch return error.OutOfMemory; + }, + } + + const written = fbs.getWritten(); + try formatter.formatEntry( + []const u8, + written, + ); + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + { + var io: Self = undefined; + try Self.parseCLI(&io, alloc, "foo"); + try testing.expect(io == .raw); + try testing.expectEqualStrings("foo", io.raw); + } + { + var io: Self = undefined; + try Self.parseCLI(&io, alloc, "raw:foo"); + try testing.expect(io == .raw); + try testing.expectEqualStrings("foo", io.raw); + } + { + var io: Self = undefined; + try Self.parseCLI(&io, alloc, "path:foo"); + try testing.expect(io == .path); + try testing.expectEqualStrings("foo", io.path); + } + } + + test "formatEntry" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var buf = std.ArrayList(u8).init(alloc); + defer buf.deinit(); + + var v: Self = undefined; + try v.parseCLI(alloc, "raw:foo"); + try v.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = raw:foo\n", buf.items); + } +}; + +pub const RepeatableReadableIO = struct { + const Self = @This(); + + // Allocator for the list is the arena for the parent config. + list: std.ArrayListUnmanaged(ReadableIO) = .{}, + + pub fn parseCLI( + self: *Self, + alloc: Allocator, + input: ?[]const u8, + ) !void { + const value = input orelse return error.ValueRequired; + + // Empty value resets the list + if (value.len == 0) { + self.list.clearRetainingCapacity(); + return; + } + + var io: ReadableIO = undefined; + try ReadableIO.parseCLI(&io, alloc, value); + try self.list.append(alloc, io); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + var list = try std.ArrayListUnmanaged(ReadableIO).initCapacity( + alloc, + self.list.items.len, + ); + for (self.list.items) |item| { + const copy = try item.clone(alloc); + list.appendAssumeCapacity(copy); + } + + return .{ .list = list }; + } + + /// See ReadableIO.cloneParsed + pub fn cloneParsed( + self: *const Self, + alloc: Allocator, + ) Allocator.Error!Self { + var list = try std.ArrayListUnmanaged(ReadableIO).initCapacity( + alloc, + self.list.items.len, + ); + for (self.list.items) |item| { + const copy = try item.cloneParsed(alloc); + list.appendAssumeCapacity(copy); + } + + return .{ .list = list }; + } + + /// Compare if two of our value are requal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + const itemsA = self.list.items; + const itemsB = other.list.items; + if (itemsA.len != itemsB.len) return false; + for (itemsA, itemsB) |a, b| { + if (!a.equal(b)) return false; + } else return true; + } + + /// Used by Formatter + pub fn formatEntry( + self: Self, + formatter: anytype, + ) !void { + if (self.list.items.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + for (self.list.items) |value| { + try formatter.formatEntry(ReadableIO, value); + } + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "raw:A"); + try list.parseCLI(alloc, "path:B"); + try testing.expectEqual(@as(usize, 2), list.list.items.len); + + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.list.items.len); + } +}; + +test { + _ = ReadableIO; + _ = RepeatableReadableIO; +} diff --git a/src/config/string.zig b/src/config/string.zig index 5e0d40e55..71826f005 100644 --- a/src/config/string.zig +++ b/src/config/string.zig @@ -3,7 +3,7 @@ const std = @import("std"); /// Parse a string literal into a byte array. The string can contain /// any valid Zig string literal escape sequences. /// -/// The output buffer never needs sto be larger than the input buffer. +/// The output buffer never needs to be larger than the input buffer. /// The buffers may alias. pub fn parse(out: []u8, bytes: []const u8) ![]u8 { var dst_i: usize = 0; diff --git a/src/config/theme.zig b/src/config/theme.zig index 21d6faf08..8fa7c93dc 100644 --- a/src/config/theme.zig +++ b/src/config/theme.zig @@ -56,7 +56,7 @@ pub const Location = enum { }, .resources => try std.fs.path.join(arena_alloc, &.{ - global_state.resources_dir orelse return null, + global_state.resources_dir.app() orelse return null, "themes", }), }; diff --git a/src/crash/sentry.zig b/src/crash/sentry.zig index c29184020..820c3e9a1 100644 --- a/src/crash/sentry.zig +++ b/src/crash/sentry.zig @@ -81,6 +81,13 @@ pub fn init(gpa: Allocator) !void { fn initThread(gpa: Allocator) !void { if (comptime !build_options.sentry) return; + // Right now, on Darwin, `std.Thread.setName` can only name the current + // thread, and we have no way to get the current thread from within it, + // so instead we use this code to name the thread instead. + if (builtin.os.tag.isDarwin()) { + internal_os.macos.pthread_setname_np(&"sentry-init".*); + } + var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const alloc = arena.allocator(); diff --git a/src/crash/sentry_envelope.zig b/src/crash/sentry_envelope.zig index 70eb99f51..6b675554c 100644 --- a/src/crash/sentry_envelope.zig +++ b/src/crash/sentry_envelope.zig @@ -331,7 +331,7 @@ pub const Item = union(enum) { // Decode the item. self.* = switch (encoded.type) { - .attachment => .{ .attachment = try Attachment.decode( + .attachment => .{ .attachment = try .decode( alloc, encoded, ) }, diff --git a/src/datastruct/array_list_collection.zig b/src/datastruct/array_list_collection.zig new file mode 100644 index 000000000..d3fbddb13 --- /dev/null +++ b/src/datastruct/array_list_collection.zig @@ -0,0 +1,44 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// A collection of ArrayLists with methods for bulk operations. +pub fn ArrayListCollection(comptime T: type) type { + return struct { + const Self = ArrayListCollection(T); + const ArrayListT = std.ArrayListUnmanaged(T); + + // An array containing the lists that belong to this collection. + lists: []ArrayListT, + + // The collection will be initialized with empty ArrayLists. + pub fn init( + alloc: Allocator, + list_count: usize, + initial_capacity: usize, + ) Allocator.Error!Self { + const self: Self = .{ + .lists = try alloc.alloc(ArrayListT, list_count), + }; + + for (self.lists) |*list| { + list.* = try .initCapacity(alloc, initial_capacity); + } + + return self; + } + + pub fn deinit(self: *Self, alloc: Allocator) void { + for (self.lists) |*list| { + list.deinit(alloc); + } + alloc.free(self.lists); + } + + /// Clear all lists in the collection, retaining capacity. + pub fn reset(self: *Self) void { + for (self.lists) |*list| { + list.clearRetainingCapacity(); + } + } + }; +} diff --git a/src/datastruct/cache_table.zig b/src/datastruct/cache_table.zig index 40d36cc24..fbfb30d71 100644 --- a/src/datastruct/cache_table.zig +++ b/src/datastruct/cache_table.zig @@ -70,7 +70,7 @@ pub fn CacheTable( /// become a pointless check, but hopefully branch prediction picks /// up on it at that point. The memory cost isn't too bad since it's /// just bytes, so should be a fraction the size of the main table. - lengths: [bucket_count]u8 = [_]u8{0} ** bucket_count, + lengths: [bucket_count]u8 = @splat(0), /// An instance of the context structure. /// Must be initialized before calling any operations. diff --git a/src/datastruct/circ_buf.zig b/src/datastruct/circ_buf.zig index 065bf6a1d..646a00940 100644 --- a/src/datastruct/circ_buf.zig +++ b/src/datastruct/circ_buf.zig @@ -152,7 +152,7 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { /// If larger, new values will be set to the default value. pub fn resize(self: *Self, alloc: Allocator, size: usize) Allocator.Error!void { // Rotate to zero so it is aligned. - try self.rotateToZero(alloc); + try self.rotateToZero(); // Reallocate, this adds to the end so we're ready to go. const prev_len = self.len(); @@ -173,29 +173,16 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { } /// Rotate the data so that it is zero-aligned. - fn rotateToZero(self: *Self, alloc: Allocator) Allocator.Error!void { - // TODO: this does this in the worst possible way by allocating. - // rewrite to not allocate, its possible, I'm just lazy right now. - + fn rotateToZero(self: *Self) Allocator.Error!void { // If we're already at zero then do nothing. if (self.tail == 0) return; - var buf = try alloc.alloc(T, self.storage.len); - defer { - self.head = if (self.full) 0 else self.len(); - self.tail = 0; - alloc.free(self.storage); - self.storage = buf; - } + // We use std.mem.rotate to rotate our storage in-place. + std.mem.rotate(T, self.storage, self.tail); - if (!self.full and self.head >= self.tail) { - fastmem.copy(T, buf, self.storage[self.tail..self.head]); - return; - } - - const middle = self.storage.len - self.tail; - fastmem.copy(T, buf, self.storage[self.tail..]); - fastmem.copy(T, buf[middle..], self.storage[0..self.head]); + // Then fix up our head and tail. + self.head = self.len() % self.storage.len; + self.tail = 0; } /// Returns if the buffer is currently empty. To check if its @@ -589,7 +576,7 @@ test "CircBuf rotateToZero" { defer buf.deinit(alloc); _ = buf.getPtrSlice(0, 11); - try buf.rotateToZero(alloc); + try buf.rotateToZero(); } test "CircBuf rotateToZero offset" { @@ -611,7 +598,7 @@ test "CircBuf rotateToZero offset" { try testing.expect(buf.tail > 0 and buf.head >= buf.tail); // Rotate to zero - try buf.rotateToZero(alloc); + try buf.rotateToZero(); try testing.expectEqual(@as(usize, 0), buf.tail); try testing.expectEqual(@as(usize, 1), buf.head); } @@ -645,7 +632,7 @@ test "CircBuf rotateToZero wraps" { } // Rotate to zero - try buf.rotateToZero(alloc); + try buf.rotateToZero(); try testing.expectEqual(@as(usize, 0), buf.tail); try testing.expectEqual(@as(usize, 3), buf.head); { @@ -681,7 +668,7 @@ test "CircBuf rotateToZero full no wrap" { } // Rotate to zero - try buf.rotateToZero(alloc); + try buf.rotateToZero(); try testing.expect(buf.full); try testing.expectEqual(@as(usize, 0), buf.tail); try testing.expectEqual(@as(usize, 0), buf.head); diff --git a/src/file_type.zig b/src/file_type.zig new file mode 100644 index 000000000..18dd7a4a5 --- /dev/null +++ b/src/file_type.zig @@ -0,0 +1,102 @@ +const std = @import("std"); + +const type_details: []const struct { + typ: FileType, + sigs: []const []const ?u8, + exts: []const []const u8, +} = &.{ + .{ + .typ = .jpeg, + .sigs = &.{ + &.{ 0xFF, 0xD8, 0xFF, 0xDB }, + &.{ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01 }, + &.{ 0xFF, 0xD8, 0xFF, 0xEE }, + &.{ 0xFF, 0xD8, 0xFF, 0xE1, null, null, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00 }, + &.{ 0xFF, 0xD8, 0xFF, 0xE0 }, + }, + .exts = &.{ ".jpg", ".jpeg", ".jfif" }, + }, + .{ + .typ = .png, + .sigs = &.{&.{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }}, + .exts = &.{".png"}, + }, + .{ + .typ = .gif, + .sigs = &.{ + &.{ 'G', 'I', 'F', '8', '7', 'a' }, + &.{ 'G', 'I', 'F', '8', '9', 'a' }, + }, + .exts = &.{".gif"}, + }, + .{ + .typ = .bmp, + .sigs = &.{&.{ 'B', 'M' }}, + .exts = &.{".bmp"}, + }, + .{ + .typ = .qoi, + .sigs = &.{&.{ 'q', 'o', 'i', 'f' }}, + .exts = &.{".qoi"}, + }, + .{ + .typ = .webp, + .sigs = &.{ + &.{ 0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50 }, + }, + .exts = &.{".webp"}, + }, +}; + +/// This is a helper for detecting file types based on magic bytes. +/// +/// Ref: https://en.wikipedia.org/wiki/List_of_file_signatures +pub const FileType = enum { + /// JPEG image file. + jpeg, + + /// PNG image file. + png, + + /// GIF image file. + gif, + + /// BMP image file. + bmp, + + /// QOI image file. + qoi, + + /// WebP image file. + webp, + + /// Unknown file format. + unknown, + + /// Detect file type based on the magic bytes + /// at the start of the provided file contents. + pub fn detect(contents: []const u8) FileType { + inline for (type_details) |typ| { + inline for (typ.sigs) |signature| { + if (contents.len >= signature.len) { + for (contents[0..signature.len], signature) |f, sig| { + if (sig) |s| if (f != s) break; + } else { + return typ.typ; + } + } + } + } + return .unknown; + } + + /// Guess file type from its extension. + pub fn guessFromExtension(extension: []const u8) FileType { + inline for (type_details) |typ| { + inline for (typ.exts) |ext| { + if (std.ascii.eqlIgnoreCase(extension, ext)) return typ.typ; + } + } + return .unknown; + } +}; diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index 327ce225f..969318943 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -50,15 +50,18 @@ modified: std.atomic.Value(usize) = .{ .raw = 0 }, resized: std.atomic.Value(usize) = .{ .raw = 0 }, pub const Format = enum(u8) { + /// 1 byte per pixel grayscale. grayscale = 0, - rgb = 1, - rgba = 2, + /// 3 bytes per pixel BGR. + bgr = 1, + /// 4 bytes per pixel BGRA. + bgra = 2, pub fn depth(self: Format) u8 { return switch (self) { .grayscale => 1, - .rgb => 3, - .rgba => 4, + .bgr => 3, + .bgra => 4, }; } }; @@ -303,7 +306,12 @@ pub fn clear(self: *Atlas) void { } /// Dump the atlas as a PPM to a writer, for debug purposes. -/// Only supports grayscale and rgb atlases. +/// Only supports grayscale and bgr atlases. +/// +/// NOTE: BGR atlases will have the red and blue channels +/// swapped because PPM expects RGB. This would be +/// easy enough to fix so next time someone needs +/// to debug a color atlas they should fix it. pub fn dump(self: Atlas, writer: anytype) !void { try writer.print( \\P{c} @@ -313,7 +321,7 @@ pub fn dump(self: Atlas, writer: anytype) !void { , .{ @as(u8, switch (self.format) { .grayscale => '5', - .rgb => '6', + .bgr => '6', else => { log.err("Unsupported format for dump: {}", .{self.format}); @panic("Cannot dump this atlas format."); @@ -418,8 +426,16 @@ pub const Wasm = struct { // We need to draw pixels so this is format dependent. const buf: []u8 = switch (self.format) { - // RGBA is the native ImageData format - .rgba => self.data, + .bgra => buf: { + // Convert from BGRA to RGBA by swapping every R and B. + var buf: []u8 = try alloc.dupe(u8, self.data); + errdefer alloc.free(buf); + var i: usize = 0; + while (i < self.data.len) : (i += 4) { + std.mem.swap(u8, &buf[i], &buf[i + 2]); + } + break :buf buf; + }, .grayscale => buf: { // Convert from A8 to RGBA so every 4th byte is set to a value. @@ -572,12 +588,12 @@ test "grow" { try testing.expectEqual(@as(u8, 4), atlas.data[atlas.size * 2 + 2]); } -test "writing RGB data" { +test "writing BGR data" { const alloc = testing.allocator; - var atlas = try init(alloc, 32, .rgb); + var atlas = try init(alloc, 32, .bgr); defer atlas.deinit(alloc); - // This is RGB so its 3 bpp + // This is BGR so its 3 bpp const reg = try atlas.reserve(alloc, 1, 2); atlas.set(reg, &[_]u8{ 1, 2, 3, @@ -594,18 +610,18 @@ test "writing RGB data" { try testing.expectEqual(@as(u8, 6), atlas.data[65 * depth + 2]); } -test "grow RGB" { +test "grow BGR" { const alloc = testing.allocator; // Atlas is 4x4 so its a 1px border meaning we only have 2x2 available - var atlas = try init(alloc, 4, .rgb); + var atlas = try init(alloc, 4, .bgr); defer atlas.deinit(alloc); // Get our 2x2, which should be ALL our usable space const reg = try atlas.reserve(alloc, 2, 2); try testing.expectError(Error.AtlasFull, atlas.reserve(alloc, 1, 1)); - // This is RGB so its 3 bpp + // This is BGR so its 3 bpp atlas.set(reg, &[_]u8{ 10, 11, 12, // (0, 0) (x, y) from top-left 13, 14, 15, // (1, 0) diff --git a/src/font/CodepointResolver.zig b/src/font/CodepointResolver.zig index 37093b59a..16536300c 100644 --- a/src/font/CodepointResolver.zig +++ b/src/font/CodepointResolver.zig @@ -37,7 +37,7 @@ collection: Collection, /// The set of statuses and whether they're enabled or not. This defaults /// to true. This can be changed at runtime with no ill effect. -styles: StyleStatus = StyleStatus.initFill(true), +styles: StyleStatus = .initFill(true), /// If discovery is available, we'll look up fonts where we can't find /// the codepoint. This can be set after initialization. @@ -140,7 +140,7 @@ pub fn getIndex( // handle this. if (self.sprite) |sprite| { if (sprite.hasCodepoint(cp, p)) { - return Collection.Index.initSpecial(.sprite); + return .initSpecial(.sprite); } } @@ -388,7 +388,7 @@ test getIndex { { errdefer c.deinit(alloc); - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -398,7 +398,7 @@ test getIndex { _ = try c.add( alloc, .regular, - .{ .loaded = try Face.init( + .{ .loaded = try .init( lib, testEmoji, .{ .size = .{ .points = 12 } }, @@ -408,7 +408,7 @@ test getIndex { _ = try c.add( alloc, .regular, - .{ .loaded = try Face.init( + .{ .loaded = try .init( lib, testEmojiText, .{ .size = .{ .points = 12 } }, @@ -467,17 +467,17 @@ test "getIndex disabled font style" { var c = Collection.init(); c.load_options = .{ .library = lib }; - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, ) }); - _ = try c.add(alloc, .bold, .{ .loaded = try Face.init( + _ = try c.add(alloc, .bold, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, ) }); - _ = try c.add(alloc, .italic, .{ .loaded = try Face.init( + _ = try c.add(alloc, .italic, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, diff --git a/src/font/Collection.zig b/src/font/Collection.zig index 59f89d402..8533331bc 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -55,7 +55,7 @@ load_options: ?LoadOptions = null, pub fn init() Collection { // Initialize our styles array, preallocating some space that is // likely to be used. - return .{ .faces = StyleArray.initFill(.{}) }; + return .{ .faces = .initFill(.{}) }; } pub fn deinit(self: *Collection, alloc: Allocator) void { @@ -707,7 +707,7 @@ test "add full" { defer c.deinit(alloc); for (0..Index.Special.start - 1) |_| { - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12 } }, @@ -755,7 +755,7 @@ test getFace { var c = init(); defer c.deinit(alloc); - const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init( + const idx = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -779,7 +779,7 @@ test getIndex { var c = init(); defer c.deinit(alloc); - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -811,7 +811,7 @@ test completeStyles { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -838,7 +838,7 @@ test setSize { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -861,7 +861,7 @@ test hasCodepoint { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init( + const idx = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -885,7 +885,7 @@ test "hasCodepoint emoji default graphical" { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init( + const idx = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmoji, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -908,7 +908,7 @@ test "metrics" { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, diff --git a/src/font/DeferredFace.zig b/src/font/DeferredFace.zig index 8794ccea9..f9ce0bff5 100644 --- a/src/font/DeferredFace.zig +++ b/src/font/DeferredFace.zig @@ -254,7 +254,7 @@ fn loadWebCanvas( opts: font.face.Options, ) !Face { const wc = self.wc.?; - return try Face.initNamed(wc.alloc, wc.font_str, opts, wc.presentation); + return try .initNamed(wc.alloc, wc.font_str, opts, wc.presentation); } /// Returns true if this face can satisfy the given codepoint and diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 72e97fad8..dcfa0a551 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -40,7 +40,7 @@ const log = std.log.scoped(.font_shared_grid); codepoints: std.AutoHashMapUnmanaged(CodepointKey, ?Collection.Index) = .{}, /// Cache for glyph renders into the atlas. -glyphs: std.AutoHashMapUnmanaged(GlyphKey, Render) = .{}, +glyphs: std.HashMapUnmanaged(GlyphKey, Render, GlyphKey.Context, 80) = .{}, /// The texture atlas to store renders in. The Glyph data in the glyphs /// cache is dependent on the atlas matching. @@ -79,7 +79,7 @@ pub fn init( var atlas_grayscale = try Atlas.init(alloc, 512, .grayscale); errdefer atlas_grayscale.deinit(alloc); - var atlas_color = try Atlas.init(alloc, 512, .rgba); + var atlas_color = try Atlas.init(alloc, 512, .bgra); errdefer atlas_color.deinit(alloc); var result: SharedGrid = .{ @@ -307,6 +307,39 @@ const GlyphKey = struct { index: Collection.Index, glyph: u32, opts: RenderOptions, + + const Context = struct { + pub fn hash(_: Context, key: GlyphKey) u64 { + return @bitCast(Packed.from(key)); + } + + pub fn eql(_: Context, a: GlyphKey, b: GlyphKey) bool { + return Packed.from(a) == Packed.from(b); + } + }; + + const Packed = packed struct(u64) { + index: Collection.Index, + glyph: u32, + opts: packed struct(u16) { + cell_width: u2, + thicken: bool, + thicken_strength: u8, + _padding: u5 = 0, + }, + + inline fn from(key: GlyphKey) Packed { + return .{ + .index = key.index, + .glyph = key.glyph, + .opts = .{ + .cell_width = key.opts.cell_width orelse 0, + .thicken = key.opts.thicken, + .thicken_strength = key.opts.thicken_strength, + }, + }; + } + }; }; const TestMode = enum { normal }; @@ -319,7 +352,7 @@ fn testGrid(mode: TestMode, alloc: Allocator, lib: Library) !SharedGrid { switch (mode) { .normal => { - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig index 8ad30629e..e3e61907b 100644 --- a/src/font/SharedGridSet.zig +++ b/src/font/SharedGridSet.zig @@ -126,7 +126,7 @@ pub fn ref( .ref = 1, }; - grid.* = try SharedGrid.init(self.alloc, resolver: { + grid.* = try .init(self.alloc, resolver: { // Build our collection. This is the expensive operation that // involves finding fonts, loading them (maybe, some are deferred), // etc. @@ -258,7 +258,7 @@ fn collection( _ = try c.add( self.alloc, .regular, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.regular, load_options.faceOptions(), @@ -267,7 +267,7 @@ fn collection( _ = try c.add( self.alloc, .bold, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.bold, load_options.faceOptions(), @@ -276,7 +276,7 @@ fn collection( _ = try c.add( self.alloc, .italic, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.italic, load_options.faceOptions(), @@ -285,7 +285,7 @@ fn collection( _ = try c.add( self.alloc, .bold_italic, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.bold_italic, load_options.faceOptions(), @@ -318,7 +318,7 @@ fn collection( _ = try c.add( self.alloc, .regular, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.emoji, load_options.faceOptions(), @@ -327,7 +327,7 @@ fn collection( _ = try c.add( self.alloc, .regular, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.emoji_text, load_options.faceOptions(), @@ -391,7 +391,7 @@ fn discover(self: *SharedGridSet) !?*Discover { // If we initialized, use it if (self.font_discover) |*v| return v; - self.font_discover = Discover.init(); + self.font_discover = .init(); return &self.font_discover.?; } @@ -498,7 +498,7 @@ pub const Key = struct { /// each style. For example, bold is from /// offsets[@intFromEnum(.bold) - 1] to /// offsets[@intFromEnum(.bold)]. - style_offsets: StyleOffsets = .{0} ** style_offsets_len, + style_offsets: StyleOffsets = @splat(0), /// The codepoint map configuration. codepoint_map: CodepointMap = .{}, diff --git a/src/font/discovery.zig b/src/font/discovery.zig index 384799da5..9284f9486 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator; const assert = std.debug.assert; const fontconfig = @import("fontconfig"); const macos = @import("macos"); +const opentype = @import("opentype.zig"); const options = @import("main.zig").options; const Collection = @import("main.zig").Collection; const DeferredFace = @import("main.zig").DeferredFace; @@ -562,149 +563,266 @@ pub const CoreText = struct { desc: *const Descriptor, list: []*macos.text.FontDescriptor, ) void { - var desc_mut = desc.*; - if (desc_mut.style == null) { - // If there is no explicit style set, we set a preferred - // based on the style bool attributes. - // - // TODO: doesn't handle i18n font names well, we should have - // another mechanism that uses the weight attribute if it exists. - // Wait for this to be a real problem. - desc_mut.style = if (desc_mut.bold and desc_mut.italic) - "Bold Italic" - else if (desc_mut.bold) - "Bold" - else if (desc_mut.italic) - "Italic" - else - null; - } - - std.mem.sortUnstable(*macos.text.FontDescriptor, list, &desc_mut, struct { + std.mem.sortUnstable(*macos.text.FontDescriptor, list, desc, struct { fn lessThan( desc_inner: *const Descriptor, lhs: *macos.text.FontDescriptor, rhs: *macos.text.FontDescriptor, ) bool { - const lhs_score = score(desc_inner, lhs); - const rhs_score = score(desc_inner, rhs); + const lhs_score: Score = .score(desc_inner, lhs); + const rhs_score: Score = .score(desc_inner, rhs); // Higher score is "less" (earlier) return lhs_score.int() > rhs_score.int(); } }.lessThan); } - /// We represent our sorting score as a packed struct so that we can - /// compare scores numerically but build scores symbolically. + /// We represent our sorting score as a packed struct so that we + /// can compare scores numerically but build scores symbolically. + /// + /// Note that packed structs store their fields from least to most + /// significant, so the fields here are defined in increasing order + /// of precedence. const Score = packed struct { const Backing = @typeInfo(@This()).@"struct".backing_integer.?; - glyph_count: u16 = 0, // clamped if > intmax - traits: Traits = .unmatched, - style: Style = .unmatched, + /// Number of glyphs in the font, if two fonts have identical + /// scores otherwise then we prefer the one with more glyphs. + /// + /// (Number of glyphs clamped at u16 intmax) + glyph_count: u16 = 0, + /// A fuzzy match on the style string, less important than + /// an exact match, and less important than trait matches. + fuzzy_style: u8 = 0, + /// Whether the bold-ness of the font matches the descriptor. + /// This is less important than italic because a font that's italic + /// when it shouldn't be or not italic when it should be is a bigger + /// problem (subjectively) than being the wrong weight. + bold: bool = false, + /// Whether the italic-ness of the font matches the descriptor. + /// This is less important than an exact match on the style string + /// because we want users to be allowed to override trait matching + /// for the bold/italic/bold italic styles if they want. + italic: bool = false, + /// An exact (case-insensitive) match on the style string. + exact_style: bool = false, + /// Whether the font is monospace, this is more important than any of + /// the other fields unless we're looking for a specific codepoint, + /// in which case that is the most important thing. monospace: bool = false, + /// If we're looking for a codepoint, whether this font has it. codepoint: bool = false, - const Traits = enum(u8) { unmatched = 0, _ }; - const Style = enum(u8) { unmatched = 0, match = 0xFF, _ }; - pub fn int(self: Score) Backing { return @bitCast(self); } - }; - fn score(desc: *const Descriptor, ct_desc: *const macos.text.FontDescriptor) Score { - var score_acc: Score = .{}; + fn score(desc: *const Descriptor, ct_desc: *const macos.text.FontDescriptor) Score { + var self: Score = .{}; - // We always load the font if we can since some things can only be - // inspected on the font itself. - const font_: ?*macos.text.Font = macos.text.Font.createWithFontDescriptor( - ct_desc, - 12, - ) catch null; - defer if (font_) |font| font.release(); + // We always load the font if we can since some things can only be + // inspected on the font itself. Fonts that can't be loaded score + // 0 automatically because we don't want a font we can't load. + const font: *macos.text.Font = macos.text.Font.createWithFontDescriptor( + ct_desc, + 12, + ) catch return self; + defer font.release(); - // If we have a font, prefer the font with more glyphs. - if (font_) |font| { - const Type = @TypeOf(score_acc.glyph_count); - score_acc.glyph_count = std.math.cast( - Type, - font.getGlyphCount(), - ) orelse std.math.maxInt(Type); - } - - // If we're searching for a codepoint, prioritize fonts that - // have that codepoint. - if (desc.codepoint > 0) codepoint: { - const font = font_ orelse break :codepoint; - - // Turn UTF-32 into UTF-16 for CT API - var unichars: [2]u16 = undefined; - const pair = macos.foundation.stringGetSurrogatePairForLongCharacter( - desc.codepoint, - &unichars, - ); - const len: usize = if (pair) 2 else 1; - - // Get our glyphs - var glyphs = [2]macos.graphics.Glyph{ 0, 0 }; - score_acc.codepoint = font.getGlyphsForCharacters(unichars[0..len], glyphs[0..len]); - } - - // Get our symbolic traits for the descriptor so we can compare - // boolean attributes like bold, monospace, etc. - const symbolic_traits: macos.text.FontSymbolicTraits = traits: { - const traits = ct_desc.copyAttribute(.traits) orelse break :traits .{}; - defer traits.release(); - - const key = macos.text.FontTraitKey.symbolic.key(); - const symbolic = traits.getValue(macos.foundation.Number, key) orelse - break :traits .{}; - - break :traits macos.text.FontSymbolicTraits.init(symbolic); - }; - - score_acc.monospace = symbolic_traits.monospace; - - score_acc.style = style: { - const style = ct_desc.copyAttribute(.style_name) orelse - break :style .unmatched; - defer style.release(); - - // Get our style string - var buf: [128]u8 = undefined; - const style_str = style.cstring(&buf, .utf8) orelse break :style .unmatched; - - // If we have a specific desired style, attempt to search for that. - if (desc.style) |desired_style| { - // Matching style string gets highest score - if (std.mem.eql(u8, desired_style, style_str)) break :style .match; - } else if (!desc.bold and !desc.italic) { - // If we do not, and we have no symbolic traits, then we try - // to find "regular" (or no style). If we have symbolic traits - // we do nothing but we can improve scoring by taking that into - // account, too. - if (std.mem.eql(u8, "Regular", style_str)) { - break :style .match; - } + // We prefer fonts with more glyphs, all else being equal. + { + const Type = @TypeOf(self.glyph_count); + self.glyph_count = std.math.cast( + Type, + font.getGlyphCount(), + ) orelse std.math.maxInt(Type); } - // Otherwise the score is based on the length of the style string. - // Shorter styles are scored higher. This is a heuristic that - // if we don't have a desired style then shorter tends to be - // more often the "regular" style. - break :style @enumFromInt(100 -| style_str.len); - }; + // If we're searching for a codepoint, then we + // prioritize fonts that have that codepoint. + if (desc.codepoint > 0) { + // Turn UTF-32 into UTF-16 for CT API + var unichars: [2]u16 = undefined; + const pair = macos.foundation.stringGetSurrogatePairForLongCharacter( + desc.codepoint, + &unichars, + ); + const len: usize = if (pair) 2 else 1; - score_acc.traits = traits: { - var count: u8 = 0; - if (desc.bold == symbolic_traits.bold) count += 1; - if (desc.italic == symbolic_traits.italic) count += 1; - break :traits @enumFromInt(count); - }; + // Get our glyphs + var glyphs = [2]macos.graphics.Glyph{ 0, 0 }; + self.codepoint = font.getGlyphsForCharacters( + unichars[0..len], + glyphs[0..len], + ); + } - return score_acc; - } + // Get our symbolic traits for the descriptor so we can + // compare boolean attributes like bold, monospace, etc. + const symbolic_traits: macos.text.FontSymbolicTraits = traits: { + const traits = ct_desc.copyAttribute(.traits) orelse break :traits .{}; + defer traits.release(); + + const key = macos.text.FontTraitKey.symbolic.key(); + const symbolic = traits.getValue(macos.foundation.Number, key) orelse + break :traits .{}; + + break :traits macos.text.FontSymbolicTraits.init(symbolic); + }; + + self.monospace = symbolic_traits.monospace; + + // We try to derived data from the font itself, which is generally + // more reliable than only using the symbolic traits for this. + const is_bold: bool, const is_italic: bool = derived: { + // We start with initial guesses based on the symbolic traits, + // but refine these with more information if we can get it. + var is_italic = symbolic_traits.italic; + var is_bold = symbolic_traits.bold; + + // Read the 'head' table out of the font data if it's available. + if (head: { + const tag = macos.text.FontTableTag.init("head"); + const data = font.copyTable(tag) orelse break :head null; + defer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + break :head opentype.Head.init(ptr[0..len]) catch |err| { + log.warn("error parsing head table: {}", .{err}); + break :head null; + }; + }) |head_| { + const head: opentype.Head = head_; + is_bold = is_bold or (head.macStyle & 1 == 1); + is_italic = is_italic or (head.macStyle & 2 == 2); + } + + // Read the 'OS/2' table out of the font data if it's available. + if (os2: { + const tag = macos.text.FontTableTag.init("OS/2"); + const data = font.copyTable(tag) orelse break :os2 null; + defer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + break :os2 opentype.OS2.init(ptr[0..len]) catch |err| { + log.warn("error parsing OS/2 table: {}", .{err}); + break :os2 null; + }; + }) |os2| { + is_bold = is_bold or os2.fsSelection.bold; + is_italic = is_italic or os2.fsSelection.italic; + } + + // Check if we have variation axes in our descriptor, if we + // do then we can derive weight italic-ness or both from them. + if (font.copyAttribute(.variation_axes)) |axes| variations: { + defer axes.release(); + + // Copy the variation values for this instance of the font. + // if there are none then we just break out immediately. + const values: *macos.foundation.Dictionary = + font.copyAttribute(.variation) orelse break :variations; + defer values.release(); + + var buf: [1024]u8 = undefined; + + // If we see the 'ital' value then we ignore 'slnt'. + var ital_seen = false; + + const len = axes.getCount(); + for (0..len) |i| { + const dict = axes.getValueAtIndex(macos.foundation.Dictionary, i); + const Key = macos.text.FontVariationAxisKey; + const cf_id = dict.getValue(Key.identifier.Value(), Key.identifier.key()).?; + const cf_name = dict.getValue(Key.name.Value(), Key.name.key()).?; + const cf_def = dict.getValue(Key.default_value.Value(), Key.default_value.key()).?; + + const name_str = cf_name.cstring(&buf, .utf8) orelse ""; + + // Default value + var def: f64 = 0; + _ = cf_def.getValue(.double, &def); + // Value in this font + var val: f64 = def; + if (values.getValue( + macos.foundation.Number, + cf_id, + )) |cf_val| _ = cf_val.getValue(.double, &val); + + if (std.mem.eql(u8, "wght", name_str)) { + // Somewhat subjective threshold, we consider fonts + // bold if they have a 'wght' set greater than 600. + is_bold = val > 600; + continue; + } + if (std.mem.eql(u8, "ital", name_str)) { + is_italic = val > 0.5; + ital_seen = true; + continue; + } + if (!ital_seen and std.mem.eql(u8, "slnt", name_str)) { + // Arbitrary threshold of anything more than a 5 + // degree clockwise slant is considered italic. + is_italic = val <= -5.0; + continue; + } + } + } + + break :derived .{ is_bold, is_italic }; + }; + + self.bold = desc.bold == is_bold; + self.italic = desc.italic == is_italic; + + // Get the style string from the font. + var style_str_buf: [128]u8 = undefined; + const style_str: []const u8 = style_str: { + const style = ct_desc.copyAttribute(.style_name) orelse + break :style_str ""; + defer style.release(); + + break :style_str style.cstring(&style_str_buf, .utf8) orelse ""; + }; + + // The first string in this slice will be used for the exact match, + // and for the fuzzy match, all matching substrings will increase + // the rank. + const desired_styles: []const [:0]const u8 = desired: { + if (desc.style) |s| break :desired &.{s}; + + // If we don't have an explicitly desired style name, we base + // it on the bold and italic properties, this isn't ideal since + // fonts may use style names other than these, but it helps in + // some edge cases. + if (desc.bold) { + if (desc.italic) break :desired &.{ "bold italic", "bold", "italic", "oblique" }; + break :desired &.{ "bold", "upright" }; + } else if (desc.italic) { + break :desired &.{ "italic", "regular", "oblique" }; + } + break :desired &.{ "regular", "upright" }; + }; + + self.exact_style = std.ascii.eqlIgnoreCase( + style_str, + desired_styles[0], + ); + // Our "fuzzy match" score is 0 if the desired style isn't present + // in the string, otherwise we give higher priority for styles that + // have fewer characters not in the desired_styles list. + const fuzzy_type = @TypeOf(self.fuzzy_style); + self.fuzzy_style = @intCast(style_str.len); + for (desired_styles) |s| { + if (std.ascii.indexOfIgnoreCase(style_str, s) != null) { + self.fuzzy_style -|= @intCast(s.len); + } + } + self.fuzzy_style = std.math.maxInt(fuzzy_type) -| self.fuzzy_style; + + return self; + } + }; pub const DiscoverIterator = struct { alloc: Allocator, @@ -837,3 +955,85 @@ test "coretext codepoint" { // Should have other codepoints too try testing.expect(face.hasCodepoint('B', null)); } + +test "coretext sorting" { + if (options.backend != .coretext and options.backend != .coretext_freetype) + return error.SkipZigTest; + + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!// + // FIXME: Disabled for now because SF Pro is not available in CI + // The solution likely involves directly testing that the + // `sortMatchingDescriptors` function sorts a bundled test + // font correctly, instead of relying on the system fonts. + if (true) return error.SkipZigTest; + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!// + + const testing = std.testing; + const alloc = testing.allocator; + + var ct = CoreText.init(); + defer ct.deinit(); + + // We try to get a Regular, Italic, Bold, & Bold Italic version of SF Pro, + // which should be installed on all Macs, and has many styles which makes + // it a good test, since there will be many results for each discovery. + + // Regular + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Regular", name); + } + + // Regular Italic + // + // NOTE: This makes sure that we don't accidentally prefer "Thin Italic", + // which we previously did, because it has a shorter name. + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + .italic = true, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Regular Italic", name); + } + + // Bold + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + .bold = true, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Bold", name); + } + + // Bold Italic + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + .bold = true, + .italic = true, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Bold Italic", name); + } +} diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 639eae43c..06bba661f 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -97,7 +97,7 @@ pub const Face = struct { errdefer if (comptime harfbuzz_shaper) hb_font.destroy(); const color: ?ColorState = if (traits.color_glyphs) - try ColorState.init(ct_font) + try .init(ct_font) else null; errdefer if (color) |v| v.deinit(); diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index bf86b88de..accb891a4 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -391,7 +391,7 @@ pub const Face = struct { const format: ?font.Atlas.Format = switch (bitmap_ft.pixel_mode) { freetype.c.FT_PIXEL_MODE_MONO => null, freetype.c.FT_PIXEL_MODE_GRAY => .grayscale, - freetype.c.FT_PIXEL_MODE_BGRA => .rgba, + freetype.c.FT_PIXEL_MODE_BGRA => .bgra, else => { log.warn("glyph={} pixel mode={}", .{ glyph_index, bitmap_ft.pixel_mode }); @panic("unsupported pixel mode"); @@ -925,7 +925,7 @@ test "color emoji" { var lib = try Library.init(alloc); defer lib.deinit(); - var atlas = try font.Atlas.init(alloc, 512, .rgba); + var atlas = try font.Atlas.init(alloc, 512, .bgra); defer atlas.deinit(alloc); var ft_font = try Face.init( @@ -973,14 +973,14 @@ test "color emoji" { } } -test "mono to rgba" { +test "mono to bgra" { const alloc = testing.allocator; const testFont = font.embedded.emoji; var lib = try Library.init(alloc); defer lib.deinit(); - var atlas = try font.Atlas.init(alloc, 512, .rgba); + var atlas = try font.Atlas.init(alloc, 512, .bgra); defer atlas.deinit(alloc); var ft_font = try Face.init(lib, testFont, .{ .size = .{ .points = 12, .xdpi = 72, .ydpi = 72 } }); diff --git a/src/font/face/freetype_convert.zig b/src/font/face/freetype_convert.zig index 6df350bfa..3a7cf8c98 100644 --- a/src/font/face/freetype_convert.zig +++ b/src/font/face/freetype_convert.zig @@ -30,7 +30,7 @@ fn genMap() Map { // Initialize to no converter var i: usize = 0; while (i < freetype.c.FT_PIXEL_MODE_MAX) : (i += 1) { - result[i] = AtlasArray.initFill(null); + result[i] = .initFill(null); } // Map our converters diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index f2ac5b85d..8e2c45c69 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -191,7 +191,7 @@ pub const Shaper = struct { // Create the CF release thread. var cf_release_thread = try alloc.create(CFReleaseThread); errdefer alloc.destroy(cf_release_thread); - cf_release_thread.* = try CFReleaseThread.init(alloc); + cf_release_thread.* = try .init(alloc); errdefer cf_release_thread.deinit(); // Start the CF release thread. @@ -1768,7 +1768,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { c.load_options = .{ .library = lib }; // Setup group - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12 } }, @@ -1776,7 +1776,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { if (font.options.backend != .coretext) { // Coretext doesn't support Noto's format - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmoji, .{ .size = .{ .points = 12 } }, @@ -1795,7 +1795,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { errdefer face.deinit(); _ = try c.add(alloc, .regular, .{ .deferred = face }); } - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmojiText, .{ .size = .{ .points = 12 } }, @@ -1803,7 +1803,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { const grid_ptr = try alloc.create(SharedGrid); errdefer alloc.destroy(grid_ptr); - grid_ptr.* = try SharedGrid.init(alloc, .{ .collection = c }); + grid_ptr.* = try .init(alloc, .{ .collection = c }); errdefer grid_ptr.*.deinit(alloc); var shaper = try Shaper.init(alloc, .{}); diff --git a/src/font/shaper/feature.zig b/src/font/shaper/feature.zig index 8e70d51da..66d0cb1f7 100644 --- a/src/font/shaper/feature.zig +++ b/src/font/shaper/feature.zig @@ -21,7 +21,7 @@ pub const Feature = struct { pub fn fromString(str: []const u8) ?Feature { var fbs = std.io.fixedBufferStream(str); const reader = fbs.reader(); - return Feature.fromReader(reader); + return .fromReader(reader); } /// Parse a single font feature setting from a std.io.Reader, with a version @@ -35,190 +35,156 @@ pub const Feature = struct { /// /// Ref: https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string pub fn fromReader(reader: anytype) ?Feature { - var tag: [4]u8 = undefined; + var tag_buf: [4]u8 = undefined; + var tag: []u8 = tag_buf[0..0]; var value: ?u32 = null; - // TODO: when we move to Zig 0.14 this can be replaced with a - // labeled switch continue pattern rather than this loop. - var state: union(enum) { + state: switch ((enum { /// Initial state. - start: void, - /// Parsing the tag, data is index. - tag: u2, + start, + /// Parsing the tag. + tag, /// In the space between the tag and the value. - space: void, + space, /// Parsing an integer parameter directly in to `value`. - int: void, + int, /// Parsing a boolean keyword parameter ("on"/"off"). - bool: void, + bool, /// Encountered an unrecoverable syntax error, advancing to boundary. - err: void, - /// Done parsing feature. - done: void, - } = .start; - while (true) { - // If we hit the end of the stream we just pretend it's a comma. - const byte = reader.readByte() catch ','; - switch (state) { - // If we're done then we skip whitespace until we see a ','. - .done => switch (byte) { - ' ', '\t' => continue, - ',' => break, - // If we see something other than whitespace or a ',' - // then this is an error since the intent is unclear. - else => { - state = .err; - continue; - }, + err, + /// Done parsing feature, skip whitespace until end. + done, + }).start) { + // If we're done then we skip whitespace until we see a ','. + .done => while (true) switch (reader.readByte() catch ',') { + ' ', '\t' => continue, + ',' => break, + // If we see something other than whitespace or a ',' + // then this is an error since the intent is unclear. + else => continue :state .err, + }, + + // If we're fast-forwarding from an error we just wanna + // stop at the first boundary and ignore all other bytes. + .err => { + reader.skipUntilDelimiterOrEof(',') catch {}; + return null; + }, + + .start => while (true) switch (reader.readByte() catch ',') { + // Ignore leading whitespace. + ' ', '\t' => continue, + // Empty feature string. + ',' => return null, + // '+' prefix to explicitly enable feature. + '+' => { + value = 1; + continue :state .tag; }, + // '-' prefix to explicitly disable feature. + '-' => { + value = 0; + continue :state .tag; + }, + // Quote mark introducing a tag. + '"', '\'' => { + continue :state .tag; + }, + // First letter of tag. + else => |byte| { + tag.len = 1; + tag[0] = byte; + continue :state .tag; + }, + }, - // If we're fast-forwarding from an error we just wanna - // stop at the first boundary and ignore all other bytes. - .err => if (byte == ',') return null, + .tag => while (true) switch (reader.readByte() catch ',') { + // If the tag is interrupted by a comma it's invalid. + ',' => return null, + // Ignore quote marks. This does technically ignore cases like + // "'k'e'r'n' = 0", but it's unambiguous so if someone really + // wants to do that in their config then... sure why not. + '"', '\'' => continue, + // In all other cases we add the byte to our tag. + else => |byte| { + tag.len += 1; + tag[tag.len - 1] = byte; + if (tag.len == 4) continue :state .space; + }, + }, - .start => switch (byte) { - // Ignore leading whitespace. - ' ', '\t' => continue, - // Empty feature string. - ',' => return null, - // '+' prefix to explicitly enable feature. - '+' => { - value = 1; - state = .{ .tag = 0 }; - continue; - }, - // '-' prefix to explicitly disable feature. - '-' => { + .space => while (true) switch (reader.readByte() catch ',') { + ' ', '\t' => continue, + // Ignore quote marks since we might have a + // closing quote from the tag still ahead. + '"', '\'' => continue, + // Allow an '=' (which we can safely ignore) + // only if we don't already have a value due + // to a '+' or '-' prefix. + '=' => if (value != null) continue :state .err, + ',' => { + // Specifying only a tag turns a feature on. + if (value == null) value = 1; + break; + }, + '0'...'9' => |byte| { + // If we already have value because of a + // '+' or '-' prefix then this is an error. + if (value != null) continue :state .err; + value = byte - '0'; + continue :state .int; + }, + 'o', 'O' => { + // If we already have value because of a + // '+' or '-' prefix then this is an error. + if (value != null) continue :state .err; + continue :state .bool; + }, + else => continue :state .err, + }, + + .int => while (true) switch (reader.readByte() catch ',') { + ',' => break, + '0'...'9' => |byte| { + // If our value gets too big while + // parsing we consider it an error. + value = std.math.mul(u32, value.?, 10) catch { + continue :state .err; + }; + value.? += byte - '0'; + }, + else => continue :state .err, + }, + + .bool => while (true) switch (reader.readByte() catch ',') { + ',' => return null, + 'n', 'N' => { + // "ofn" + if (value != null) { + assert(value == 0); + continue :state .err; + } + value = 1; + continue :state .done; + }, + 'f', 'F' => { + // To make sure we consume two 'f's. + if (value == null) { value = 0; - state = .{ .tag = 0 }; - continue; - }, - // Quote mark introducing a tag. - '"', '\'' => { - state = .{ .tag = 0 }; - continue; - }, - // First letter of tag. - else => { - tag[0] = byte; - state = .{ .tag = 1 }; - continue; - }, + } else { + assert(value == 0); + continue :state .done; + } }, - - .tag => |*i| switch (byte) { - // If the tag is interrupted by a comma it's invalid. - ',' => return null, - // Ignore quote marks. - '"', '\'' => continue, - // A prefix of '+' or '-' - // In all other cases we add the byte to our tag. - else => { - tag[i.*] = byte; - if (i.* == 3) { - state = .space; - continue; - } - i.* += 1; - }, - }, - - .space => switch (byte) { - ' ', '\t' => continue, - // Ignore quote marks since we might have a - // closing quote from the tag still ahead. - '"', '\'' => continue, - // Allow an '=' (which we can safely ignore) - // only if we don't already have a value due - // to a '+' or '-' prefix. - '=' => if (value != null) { - state = .err; - continue; - }, - ',' => { - // Specifying only a tag turns a feature on. - if (value == null) value = 1; - break; - }, - '0'...'9' => { - // If we already have value because of a - // '+' or '-' prefix then this is an error. - if (value != null) { - state = .err; - continue; - } - value = byte - '0'; - state = .int; - continue; - }, - 'o', 'O' => { - // If we already have value because of a - // '+' or '-' prefix then this is an error. - if (value != null) { - state = .err; - continue; - } - state = .bool; - continue; - }, - else => { - state = .err; - continue; - }, - }, - - .int => switch (byte) { - ',' => break, - '0'...'9' => { - // If our value gets too big while - // parsing we consider it an error. - value = std.math.mul(u32, value.?, 10) catch { - state = .err; - continue; - }; - value.? += byte - '0'; - }, - else => { - state = .err; - continue; - }, - }, - - .bool => switch (byte) { - ',' => return null, - 'n', 'N' => { - // "ofn" - if (value != null) { - assert(value == 0); - state = .err; - continue; - } - value = 1; - state = .done; - continue; - }, - 'f', 'F' => { - // To make sure we consume two 'f's. - if (value == null) { - value = 0; - } else { - assert(value == 0); - state = .done; - continue; - } - }, - else => { - state = .err; - continue; - }, - }, - } + else => continue :state .err, + }, } assert(value != null); + assert(tag.len == 4); return .{ - .tag = tag, + .tag = tag_buf, .value = value.?, }; } diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index eb8130f79..361cbbe93 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1227,7 +1227,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { c.load_options = .{ .library = lib }; // Setup group - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12 } }, @@ -1235,7 +1235,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { if (comptime !font.options.backend.hasCoretext()) { // Coretext doesn't support Noto's format - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmoji, .{ .size = .{ .points = 12 } }, @@ -1254,7 +1254,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { errdefer face.deinit(); _ = try c.add(alloc, .regular, .{ .deferred = face }); } - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmojiText, .{ .size = .{ .points = 12 } }, @@ -1262,7 +1262,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { const grid_ptr = try alloc.create(SharedGrid); errdefer alloc.destroy(grid_ptr); - grid_ptr.* = try SharedGrid.init(alloc, .{ .collection = c }); + grid_ptr.* = try .init(alloc, .{ .collection = c }); errdefer grid_ptr.*.deinit(alloc); var shaper = try Shaper.init(alloc, .{}); diff --git a/src/font/sprite/Box.zig b/src/font/sprite/Box.zig index 68acdabe5..f5140091d 100644 --- a/src/font/sprite/Box.zig +++ b/src/font/sprite/Box.zig @@ -516,40 +516,40 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void 0x257f => self.draw_lines(canvas, .{ .up = .heavy, .down = .light }), // '▀' UPPER HALF BLOCK - 0x2580 => self.draw_block(canvas, Alignment.upper, 1, half), + 0x2580 => self.draw_block(canvas, .upper, 1, half), // '▁' LOWER ONE EIGHTH BLOCK - 0x2581 => self.draw_block(canvas, Alignment.lower, 1, one_eighth), + 0x2581 => self.draw_block(canvas, .lower, 1, one_eighth), // '▂' LOWER ONE QUARTER BLOCK - 0x2582 => self.draw_block(canvas, Alignment.lower, 1, one_quarter), + 0x2582 => self.draw_block(canvas, .lower, 1, one_quarter), // '▃' LOWER THREE EIGHTHS BLOCK - 0x2583 => self.draw_block(canvas, Alignment.lower, 1, three_eighths), + 0x2583 => self.draw_block(canvas, .lower, 1, three_eighths), // '▄' LOWER HALF BLOCK - 0x2584 => self.draw_block(canvas, Alignment.lower, 1, half), + 0x2584 => self.draw_block(canvas, .lower, 1, half), // '▅' LOWER FIVE EIGHTHS BLOCK - 0x2585 => self.draw_block(canvas, Alignment.lower, 1, five_eighths), + 0x2585 => self.draw_block(canvas, .lower, 1, five_eighths), // '▆' LOWER THREE QUARTERS BLOCK - 0x2586 => self.draw_block(canvas, Alignment.lower, 1, three_quarters), + 0x2586 => self.draw_block(canvas, .lower, 1, three_quarters), // '▇' LOWER SEVEN EIGHTHS BLOCK - 0x2587 => self.draw_block(canvas, Alignment.lower, 1, seven_eighths), + 0x2587 => self.draw_block(canvas, .lower, 1, seven_eighths), // '█' FULL BLOCK 0x2588 => self.draw_full_block(canvas), // '▉' LEFT SEVEN EIGHTHS BLOCK - 0x2589 => self.draw_block(canvas, Alignment.left, seven_eighths, 1), + 0x2589 => self.draw_block(canvas, .left, seven_eighths, 1), // '▊' LEFT THREE QUARTERS BLOCK - 0x258a => self.draw_block(canvas, Alignment.left, three_quarters, 1), + 0x258a => self.draw_block(canvas, .left, three_quarters, 1), // '▋' LEFT FIVE EIGHTHS BLOCK - 0x258b => self.draw_block(canvas, Alignment.left, five_eighths, 1), + 0x258b => self.draw_block(canvas, .left, five_eighths, 1), // '▌' LEFT HALF BLOCK - 0x258c => self.draw_block(canvas, Alignment.left, half, 1), + 0x258c => self.draw_block(canvas, .left, half, 1), // '▍' LEFT THREE EIGHTHS BLOCK - 0x258d => self.draw_block(canvas, Alignment.left, three_eighths, 1), + 0x258d => self.draw_block(canvas, .left, three_eighths, 1), // '▎' LEFT ONE QUARTER BLOCK - 0x258e => self.draw_block(canvas, Alignment.left, one_quarter, 1), + 0x258e => self.draw_block(canvas, .left, one_quarter, 1), // '▏' LEFT ONE EIGHTH BLOCK - 0x258f => self.draw_block(canvas, Alignment.left, one_eighth, 1), + 0x258f => self.draw_block(canvas, .left, one_eighth, 1), // '▐' RIGHT HALF BLOCK - 0x2590 => self.draw_block(canvas, Alignment.right, half, 1), + 0x2590 => self.draw_block(canvas, .right, half, 1), // '░' 0x2591 => self.draw_light_shade(canvas), // '▒' @@ -557,9 +557,9 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void // '▓' 0x2593 => self.draw_dark_shade(canvas), // '▔' UPPER ONE EIGHTH BLOCK - 0x2594 => self.draw_block(canvas, Alignment.upper, 1, one_eighth), + 0x2594 => self.draw_block(canvas, .upper, 1, one_eighth), // '▕' RIGHT ONE EIGHTH BLOCK - 0x2595 => self.draw_block(canvas, Alignment.right, one_eighth, 1), + 0x2595 => self.draw_block(canvas, .right, one_eighth, 1), // '▖' 0x2596 => self.draw_quadrant(canvas, .{ .bl = true }), // '▗' @@ -581,6 +581,120 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void // '▟' 0x259f => self.draw_quadrant(canvas, .{ .tr = true, .bl = true, .br = true }), + // '◢' + 0x25e2 => self.draw_corner_triangle_shade(canvas, .br, .on), + // '◣' + 0x25e3 => self.draw_corner_triangle_shade(canvas, .bl, .on), + // '◤' + 0x25e4 => self.draw_corner_triangle_shade(canvas, .tl, .on), + // '◥' + 0x25e5 => self.draw_corner_triangle_shade(canvas, .tr, .on), + + // '◸' + 0x25f8 => { + const thickness_px = Thickness.light.height(self.metrics.box_thickness); + // top edge + self.rect( + canvas, + 0, + 0, + self.metrics.cell_width, + thickness_px, + ); + // left edge + self.rect( + canvas, + 0, + 0, + thickness_px, + self.metrics.cell_height -| 1, + ); + // diagonal + self.draw_cell_diagonal( + canvas, + .lower_left, + .upper_right, + ); + }, + // '◹' + 0x25f9 => { + const thickness_px = Thickness.light.height(self.metrics.box_thickness); + // top edge + self.rect( + canvas, + 0, + 0, + self.metrics.cell_width, + thickness_px, + ); + // right edge + self.rect( + canvas, + self.metrics.cell_width -| thickness_px, + 0, + self.metrics.cell_width, + self.metrics.cell_height -| 1, + ); + // diagonal + self.draw_cell_diagonal( + canvas, + .upper_left, + .lower_right, + ); + }, + // '◺' + 0x25fa => { + const thickness_px = Thickness.light.height(self.metrics.box_thickness); + // bottom edge + self.rect( + canvas, + 0, + self.metrics.cell_height -| thickness_px, + self.metrics.cell_width, + self.metrics.cell_height, + ); + // left edge + self.rect( + canvas, + 0, + 1, + thickness_px, + self.metrics.cell_height, + ); + // diagonal + self.draw_cell_diagonal( + canvas, + .upper_left, + .lower_right, + ); + }, + // '◿' + 0x25ff => { + const thickness_px = Thickness.light.height(self.metrics.box_thickness); + // bottom edge + self.rect( + canvas, + 0, + self.metrics.cell_height -| thickness_px, + self.metrics.cell_width, + self.metrics.cell_height, + ); + // right edge + self.rect( + canvas, + self.metrics.cell_width -| thickness_px, + 1, + self.metrics.cell_width, + self.metrics.cell_height, + ); + // diagonal + self.draw_cell_diagonal( + canvas, + .lower_left, + .upper_right, + ); + }, + 0x2800...0x28ff => self.draw_braille(canvas, cp), 0x1fb00...0x1fb3b => self.draw_sextant(canvas, cp), @@ -588,35 +702,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void octant_min...octant_max => self.draw_octant(canvas, cp), // '🬼' - 0x1fb3c => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb3c => try self.draw_smooth_mosaic(canvas, .from( \\... \\... \\#.. \\##. )), // '🬽' - 0x1fb3d => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb3d => try self.draw_smooth_mosaic(canvas, .from( \\... \\... \\#\. \\### )), // '🬾' - 0x1fb3e => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb3e => try self.draw_smooth_mosaic(canvas, .from( \\... \\#.. \\#\. \\##. )), // '🬿' - 0x1fb3f => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb3f => try self.draw_smooth_mosaic(canvas, .from( \\... \\#.. \\##. \\### )), // '🭀' - 0x1fb40 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb40 => try self.draw_smooth_mosaic(canvas, .from( \\#.. \\#.. \\##. @@ -624,42 +738,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭁' - 0x1fb41 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb41 => try self.draw_smooth_mosaic(canvas, .from( \\/## \\### \\### \\### )), // '🭂' - 0x1fb42 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb42 => try self.draw_smooth_mosaic(canvas, .from( \\./# \\### \\### \\### )), // '🭃' - 0x1fb43 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb43 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\.## \\### \\### )), // '🭄' - 0x1fb44 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb44 => try self.draw_smooth_mosaic(canvas, .from( \\..# \\.## \\### \\### )), // '🭅' - 0x1fb45 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb45 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\.## \\.## \\### )), // '🭆' - 0x1fb46 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb46 => try self.draw_smooth_mosaic(canvas, .from( \\... \\./# \\### @@ -667,35 +781,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭇' - 0x1fb47 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb47 => try self.draw_smooth_mosaic(canvas, .from( \\... \\... \\..# \\.## )), // '🭈' - 0x1fb48 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb48 => try self.draw_smooth_mosaic(canvas, .from( \\... \\... \\./# \\### )), // '🭉' - 0x1fb49 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb49 => try self.draw_smooth_mosaic(canvas, .from( \\... \\..# \\./# \\.## )), // '🭊' - 0x1fb4a => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4a => try self.draw_smooth_mosaic(canvas, .from( \\... \\..# \\.## \\### )), // '🭋' - 0x1fb4b => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4b => try self.draw_smooth_mosaic(canvas, .from( \\..# \\..# \\.## @@ -703,42 +817,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭌' - 0x1fb4c => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4c => try self.draw_smooth_mosaic(canvas, .from( \\##\ \\### \\### \\### )), // '🭍' - 0x1fb4d => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4d => try self.draw_smooth_mosaic(canvas, .from( \\#\. \\### \\### \\### )), // '🭎' - 0x1fb4e => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4e => try self.draw_smooth_mosaic(canvas, .from( \\##. \\##. \\### \\### )), // '🭏' - 0x1fb4f => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4f => try self.draw_smooth_mosaic(canvas, .from( \\#.. \\##. \\### \\### )), // '🭐' - 0x1fb50 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb50 => try self.draw_smooth_mosaic(canvas, .from( \\##. \\##. \\##. \\### )), // '🭑' - 0x1fb51 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb51 => try self.draw_smooth_mosaic(canvas, .from( \\... \\#\. \\### @@ -746,35 +860,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭒' - 0x1fb52 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb52 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\### \\\## )), // '🭓' - 0x1fb53 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb53 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\### \\.\# )), // '🭔' - 0x1fb54 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb54 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\.## \\.## )), // '🭕' - 0x1fb55 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb55 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\.## \\..# )), // '🭖' - 0x1fb56 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb56 => try self.draw_smooth_mosaic(canvas, .from( \\### \\.## \\.## @@ -782,35 +896,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭗' - 0x1fb57 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb57 => try self.draw_smooth_mosaic(canvas, .from( \\##. \\#.. \\... \\... )), // '🭘' - 0x1fb58 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb58 => try self.draw_smooth_mosaic(canvas, .from( \\### \\#/. \\... \\... )), // '🭙' - 0x1fb59 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb59 => try self.draw_smooth_mosaic(canvas, .from( \\##. \\#/. \\#.. \\... )), // '🭚' - 0x1fb5a => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5a => try self.draw_smooth_mosaic(canvas, .from( \\### \\##. \\#.. \\... )), // '🭛' - 0x1fb5b => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5b => try self.draw_smooth_mosaic(canvas, .from( \\##. \\##. \\#.. @@ -818,42 +932,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭜' - 0x1fb5c => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5c => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\#/. \\... )), // '🭝' - 0x1fb5d => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5d => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\### \\##/ )), // '🭞' - 0x1fb5e => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5e => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\### \\#/. )), // '🭟' - 0x1fb5f => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5f => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\##. \\##. )), // '🭠' - 0x1fb60 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb60 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\##. \\#.. )), // '🭡' - 0x1fb61 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb61 => try self.draw_smooth_mosaic(canvas, .from( \\### \\##. \\##. @@ -861,42 +975,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭢' - 0x1fb62 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb62 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\..# \\... \\... )), // '🭣' - 0x1fb63 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb63 => try self.draw_smooth_mosaic(canvas, .from( \\### \\.\# \\... \\... )), // '🭤' - 0x1fb64 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb64 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\.\# \\..# \\... )), // '🭥' - 0x1fb65 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb65 => try self.draw_smooth_mosaic(canvas, .from( \\### \\.## \\..# \\... )), // '🭦' - 0x1fb66 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb66 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\.## \\..# \\..# )), // '🭧' - 0x1fb67 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb67 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\.\# @@ -959,79 +1073,79 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void 0x1fb7b => self.draw_horizontal_one_eighth_block_n(canvas, 6), // '🮂' UPPER ONE QUARTER BLOCK - 0x1fb82 => self.draw_block(canvas, Alignment.upper, 1, one_quarter), + 0x1fb82 => self.draw_block(canvas, .upper, 1, one_quarter), // '🮃' UPPER THREE EIGHTHS BLOCK - 0x1fb83 => self.draw_block(canvas, Alignment.upper, 1, three_eighths), + 0x1fb83 => self.draw_block(canvas, .upper, 1, three_eighths), // '🮄' UPPER FIVE EIGHTHS BLOCK - 0x1fb84 => self.draw_block(canvas, Alignment.upper, 1, five_eighths), + 0x1fb84 => self.draw_block(canvas, .upper, 1, five_eighths), // '🮅' UPPER THREE QUARTERS BLOCK - 0x1fb85 => self.draw_block(canvas, Alignment.upper, 1, three_quarters), + 0x1fb85 => self.draw_block(canvas, .upper, 1, three_quarters), // '🮆' UPPER SEVEN EIGHTHS BLOCK - 0x1fb86 => self.draw_block(canvas, Alignment.upper, 1, seven_eighths), + 0x1fb86 => self.draw_block(canvas, .upper, 1, seven_eighths), // '🭼' LEFT AND LOWER ONE EIGHTH BLOCK 0x1fb7c => { - self.draw_block(canvas, Alignment.left, one_eighth, 1); - self.draw_block(canvas, Alignment.lower, 1, one_eighth); + self.draw_block(canvas, .left, one_eighth, 1); + self.draw_block(canvas, .lower, 1, one_eighth); }, // '🭽' LEFT AND UPPER ONE EIGHTH BLOCK 0x1fb7d => { - self.draw_block(canvas, Alignment.left, one_eighth, 1); - self.draw_block(canvas, Alignment.upper, 1, one_eighth); + self.draw_block(canvas, .left, one_eighth, 1); + self.draw_block(canvas, .upper, 1, one_eighth); }, // '🭾' RIGHT AND UPPER ONE EIGHTH BLOCK 0x1fb7e => { - self.draw_block(canvas, Alignment.right, one_eighth, 1); - self.draw_block(canvas, Alignment.upper, 1, one_eighth); + self.draw_block(canvas, .right, one_eighth, 1); + self.draw_block(canvas, .upper, 1, one_eighth); }, // '🭿' RIGHT AND LOWER ONE EIGHTH BLOCK 0x1fb7f => { - self.draw_block(canvas, Alignment.right, one_eighth, 1); - self.draw_block(canvas, Alignment.lower, 1, one_eighth); + self.draw_block(canvas, .right, one_eighth, 1); + self.draw_block(canvas, .lower, 1, one_eighth); }, // '🮀' UPPER AND LOWER ONE EIGHTH BLOCK 0x1fb80 => { - self.draw_block(canvas, Alignment.upper, 1, one_eighth); - self.draw_block(canvas, Alignment.lower, 1, one_eighth); + self.draw_block(canvas, .upper, 1, one_eighth); + self.draw_block(canvas, .lower, 1, one_eighth); }, // '🮁' 0x1fb81 => self.draw_horizontal_one_eighth_1358_block(canvas), // '🮇' RIGHT ONE QUARTER BLOCK - 0x1fb87 => self.draw_block(canvas, Alignment.right, one_quarter, 1), + 0x1fb87 => self.draw_block(canvas, .right, one_quarter, 1), // '🮈' RIGHT THREE EIGHTHS BLOCK - 0x1fb88 => self.draw_block(canvas, Alignment.right, three_eighths, 1), + 0x1fb88 => self.draw_block(canvas, .right, three_eighths, 1), // '🮉' RIGHT FIVE EIGHTHS BLOCK - 0x1fb89 => self.draw_block(canvas, Alignment.right, five_eighths, 1), + 0x1fb89 => self.draw_block(canvas, .right, five_eighths, 1), // '🮊' RIGHT THREE QUARTERS BLOCK - 0x1fb8a => self.draw_block(canvas, Alignment.right, three_quarters, 1), + 0x1fb8a => self.draw_block(canvas, .right, three_quarters, 1), // '🮋' RIGHT SEVEN EIGHTHS BLOCK - 0x1fb8b => self.draw_block(canvas, Alignment.right, seven_eighths, 1), + 0x1fb8b => self.draw_block(canvas, .right, seven_eighths, 1), // '🮌' - 0x1fb8c => self.draw_block_shade(canvas, Alignment.left, half, 1, .medium), + 0x1fb8c => self.draw_block_shade(canvas, .left, half, 1, .medium), // '🮍' - 0x1fb8d => self.draw_block_shade(canvas, Alignment.right, half, 1, .medium), + 0x1fb8d => self.draw_block_shade(canvas, .right, half, 1, .medium), // '🮎' - 0x1fb8e => self.draw_block_shade(canvas, Alignment.upper, 1, half, .medium), + 0x1fb8e => self.draw_block_shade(canvas, .upper, 1, half, .medium), // '🮏' - 0x1fb8f => self.draw_block_shade(canvas, Alignment.lower, 1, half, .medium), + 0x1fb8f => self.draw_block_shade(canvas, .lower, 1, half, .medium), // '🮐' 0x1fb90 => self.draw_medium_shade(canvas), // '🮑' 0x1fb91 => { self.draw_medium_shade(canvas); - self.draw_block(canvas, Alignment.upper, 1, half); + self.draw_block(canvas, .upper, 1, half); }, // '🮒' 0x1fb92 => { self.draw_medium_shade(canvas); - self.draw_block(canvas, Alignment.lower, 1, half); + self.draw_block(canvas, .lower, 1, half); }, // '🮔' 0x1fb94 => { self.draw_medium_shade(canvas); - self.draw_block(canvas, Alignment.right, half, 1); + self.draw_block(canvas, .right, half, 1); }, // '🮕' 0x1fb95 => self.draw_checkerboard_fill(canvas, 0), @@ -1117,194 +1231,194 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void }, // '🯎' - 0x1fbce => self.draw_block(canvas, Alignment.left, two_thirds, 1), + 0x1fbce => self.draw_block(canvas, .left, two_thirds, 1), // '🯏' - 0x1fbcf => self.draw_block(canvas, Alignment.left, one_third, 1), + 0x1fbcf => self.draw_block(canvas, .left, one_third, 1), // '🯐' 0x1fbd0 => self.draw_cell_diagonal( canvas, - Alignment.middle_right, - Alignment.lower_left, + .middle_right, + .lower_left, ), // '🯑' 0x1fbd1 => self.draw_cell_diagonal( canvas, - Alignment.upper_right, - Alignment.middle_left, + .upper_right, + .middle_left, ), // '🯒' 0x1fbd2 => self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.middle_right, + .upper_left, + .middle_right, ), // '🯓' 0x1fbd3 => self.draw_cell_diagonal( canvas, - Alignment.middle_left, - Alignment.lower_right, + .middle_left, + .lower_right, ), // '🯔' 0x1fbd4 => self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.lower_center, + .upper_left, + .lower_center, ), // '🯕' 0x1fbd5 => self.draw_cell_diagonal( canvas, - Alignment.upper_center, - Alignment.lower_right, + .upper_center, + .lower_right, ), // '🯖' 0x1fbd6 => self.draw_cell_diagonal( canvas, - Alignment.upper_right, - Alignment.lower_center, + .upper_right, + .lower_center, ), // '🯗' 0x1fbd7 => self.draw_cell_diagonal( canvas, - Alignment.upper_center, - Alignment.lower_left, + .upper_center, + .lower_left, ), // '🯘' 0x1fbd8 => { self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.middle_center, + .upper_left, + .middle_center, ); self.draw_cell_diagonal( canvas, - Alignment.middle_center, - Alignment.upper_right, + .middle_center, + .upper_right, ); }, // '🯙' 0x1fbd9 => { self.draw_cell_diagonal( canvas, - Alignment.upper_right, - Alignment.middle_center, + .upper_right, + .middle_center, ); self.draw_cell_diagonal( canvas, - Alignment.middle_center, - Alignment.lower_right, + .middle_center, + .lower_right, ); }, // '🯚' 0x1fbda => { self.draw_cell_diagonal( canvas, - Alignment.lower_left, - Alignment.middle_center, + .lower_left, + .middle_center, ); self.draw_cell_diagonal( canvas, - Alignment.middle_center, - Alignment.lower_right, + .middle_center, + .lower_right, ); }, // '🯛' 0x1fbdb => { self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.middle_center, + .upper_left, + .middle_center, ); self.draw_cell_diagonal( canvas, - Alignment.middle_center, - Alignment.lower_left, + .middle_center, + .lower_left, ); }, // '🯜' 0x1fbdc => { self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.lower_center, + .upper_left, + .lower_center, ); self.draw_cell_diagonal( canvas, - Alignment.lower_center, - Alignment.upper_right, + .lower_center, + .upper_right, ); }, // '🯝' 0x1fbdd => { self.draw_cell_diagonal( canvas, - Alignment.upper_right, - Alignment.middle_left, + .upper_right, + .middle_left, ); self.draw_cell_diagonal( canvas, - Alignment.middle_left, - Alignment.lower_right, + .middle_left, + .lower_right, ); }, // '🯞' 0x1fbde => { self.draw_cell_diagonal( canvas, - Alignment.lower_left, - Alignment.upper_center, + .lower_left, + .upper_center, ); self.draw_cell_diagonal( canvas, - Alignment.upper_center, - Alignment.lower_right, + .upper_center, + .lower_right, ); }, // '🯟' 0x1fbdf => { self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.middle_right, + .upper_left, + .middle_right, ); self.draw_cell_diagonal( canvas, - Alignment.middle_right, - Alignment.lower_left, + .middle_right, + .lower_left, ); }, // '🯠' - 0x1fbe0 => self.draw_circle(canvas, Alignment.top, false), + 0x1fbe0 => self.draw_circle(canvas, .top, false), // '🯡' - 0x1fbe1 => self.draw_circle(canvas, Alignment.right, false), + 0x1fbe1 => self.draw_circle(canvas, .right, false), // '🯢' - 0x1fbe2 => self.draw_circle(canvas, Alignment.bottom, false), + 0x1fbe2 => self.draw_circle(canvas, .bottom, false), // '🯣' - 0x1fbe3 => self.draw_circle(canvas, Alignment.left, false), + 0x1fbe3 => self.draw_circle(canvas, .left, false), // '🯤' - 0x1fbe4 => self.draw_block(canvas, Alignment.upper_center, 0.5, 0.5), + 0x1fbe4 => self.draw_block(canvas, .upper_center, 0.5, 0.5), // '🯥' - 0x1fbe5 => self.draw_block(canvas, Alignment.lower_center, 0.5, 0.5), + 0x1fbe5 => self.draw_block(canvas, .lower_center, 0.5, 0.5), // '🯦' - 0x1fbe6 => self.draw_block(canvas, Alignment.middle_left, 0.5, 0.5), + 0x1fbe6 => self.draw_block(canvas, .middle_left, 0.5, 0.5), // '🯧' - 0x1fbe7 => self.draw_block(canvas, Alignment.middle_right, 0.5, 0.5), + 0x1fbe7 => self.draw_block(canvas, .middle_right, 0.5, 0.5), // '🯨' - 0x1fbe8 => self.draw_circle(canvas, Alignment.top, true), + 0x1fbe8 => self.draw_circle(canvas, .top, true), // '🯩' - 0x1fbe9 => self.draw_circle(canvas, Alignment.right, true), + 0x1fbe9 => self.draw_circle(canvas, .right, true), // '🯪' - 0x1fbea => self.draw_circle(canvas, Alignment.bottom, true), + 0x1fbea => self.draw_circle(canvas, .bottom, true), // '🯫' - 0x1fbeb => self.draw_circle(canvas, Alignment.left, true), + 0x1fbeb => self.draw_circle(canvas, .left, true), // '🯬' - 0x1fbec => self.draw_circle(canvas, Alignment.top_right, true), + 0x1fbec => self.draw_circle(canvas, .top_right, true), // '🯭' - 0x1fbed => self.draw_circle(canvas, Alignment.bottom_left, true), + 0x1fbed => self.draw_circle(canvas, .bottom_left, true), // '🯮' - 0x1fbee => self.draw_circle(canvas, Alignment.bottom_right, true), + 0x1fbee => self.draw_circle(canvas, .bottom_right, true), // '🯯' - 0x1fbef => self.draw_circle(canvas, Alignment.top_left, true), + 0x1fbef => self.draw_circle(canvas, .top_left, true), // (Below:) // Branch drawing character set, used for drawing git-like @@ -2488,10 +2602,10 @@ fn draw_sextant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { if (sex.tl) self.rect(canvas, 0, 0, x_halfs[0], y_thirds[0]); if (sex.tr) self.rect(canvas, x_halfs[1], 0, self.metrics.cell_width, y_thirds[0]); - if (sex.ml) self.rect(canvas, 0, y_thirds[0], x_halfs[0], y_thirds[1]); - if (sex.mr) self.rect(canvas, x_halfs[1], y_thirds[0], self.metrics.cell_width, y_thirds[1]); - if (sex.bl) self.rect(canvas, 0, y_thirds[1], x_halfs[0], self.metrics.cell_height); - if (sex.br) self.rect(canvas, x_halfs[1], y_thirds[1], self.metrics.cell_width, self.metrics.cell_height); + if (sex.ml) self.rect(canvas, 0, y_thirds[1], x_halfs[0], y_thirds[2]); + if (sex.mr) self.rect(canvas, x_halfs[1], y_thirds[1], self.metrics.cell_width, y_thirds[2]); + if (sex.bl) self.rect(canvas, 0, y_thirds[3], x_halfs[0], self.metrics.cell_height); + if (sex.br) self.rect(canvas, x_halfs[1], y_thirds[3], self.metrics.cell_width, self.metrics.cell_height); } fn draw_octant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { @@ -2517,7 +2631,7 @@ fn draw_octant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { const octants: [octants_len]Octant = comptime octants: { @setEvalBranchQuota(10_000); - var result: [octants_len]Octant = .{Octant{}} ** octants_len; + var result: [octants_len]Octant = @splat(.{}); var i: usize = 0; const data = @embedFile("octants.txt"); @@ -2545,42 +2659,58 @@ fn draw_octant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { const oct = octants[cp - octant_min]; if (oct.@"1") self.rect(canvas, 0, 0, x_halfs[0], y_quads[0]); if (oct.@"2") self.rect(canvas, x_halfs[1], 0, self.metrics.cell_width, y_quads[0]); - if (oct.@"3") self.rect(canvas, 0, y_quads[0], x_halfs[0], y_quads[1]); - if (oct.@"4") self.rect(canvas, x_halfs[1], y_quads[0], self.metrics.cell_width, y_quads[1]); - if (oct.@"5") self.rect(canvas, 0, y_quads[1], x_halfs[0], y_quads[2]); - if (oct.@"6") self.rect(canvas, x_halfs[1], y_quads[1], self.metrics.cell_width, y_quads[2]); - if (oct.@"7") self.rect(canvas, 0, y_quads[2], x_halfs[0], self.metrics.cell_height); - if (oct.@"8") self.rect(canvas, x_halfs[1], y_quads[2], self.metrics.cell_width, self.metrics.cell_height); + if (oct.@"3") self.rect(canvas, 0, y_quads[1], x_halfs[0], y_quads[2]); + if (oct.@"4") self.rect(canvas, x_halfs[1], y_quads[1], self.metrics.cell_width, y_quads[2]); + if (oct.@"5") self.rect(canvas, 0, y_quads[3], x_halfs[0], y_quads[4]); + if (oct.@"6") self.rect(canvas, x_halfs[1], y_quads[3], self.metrics.cell_width, y_quads[4]); + if (oct.@"7") self.rect(canvas, 0, y_quads[5], x_halfs[0], self.metrics.cell_height); + if (oct.@"8") self.rect(canvas, x_halfs[1], y_quads[5], self.metrics.cell_width, self.metrics.cell_height); } +/// xHalfs[0] should be used as the right edge of a left-aligned half. +/// xHalfs[1] should be used as the left edge of a right-aligned half. fn xHalfs(self: Box) [2]u32 { + const float_width: f64 = @floatFromInt(self.metrics.cell_width); + const half_width: u32 = @intFromFloat(@round(0.5 * float_width)); + return .{ half_width, self.metrics.cell_width - half_width }; +} + +/// Use these values as such: +/// yThirds[0] bottom edge of the first third. +/// yThirds[1] top edge of the second third. +/// yThirds[2] bottom edge of the second third. +/// yThirds[3] top edge of the final third. +fn yThirds(self: Box) [4]u32 { + const float_height: f64 = @floatFromInt(self.metrics.cell_height); + const one_third_height: u32 = @intFromFloat(@round(one_third * float_height)); + const two_thirds_height: u32 = @intFromFloat(@round(two_thirds * float_height)); return .{ - @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2))), - @as(u32, @intFromFloat(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2)), + one_third_height, + self.metrics.cell_height - two_thirds_height, + two_thirds_height, + self.metrics.cell_height - one_third_height, }; } -fn yThirds(self: Box) [2]u32 { - return switch (@mod(self.metrics.cell_height, 3)) { - 0 => .{ self.metrics.cell_height / 3, 2 * self.metrics.cell_height / 3 }, - 1 => .{ self.metrics.cell_height / 3, 2 * self.metrics.cell_height / 3 + 1 }, - 2 => .{ self.metrics.cell_height / 3 + 1, 2 * self.metrics.cell_height / 3 }, - else => unreachable, - }; -} - -// assume octants might be striped across multiple rows of cells. to maximize -// distance between excess pixellines, we want (1) an arbitrary region (there -// will be a pattern of 1'-3-1'-3-1'-3 no matter what), (2) discontiguous -// regions (0 and 2 or 1 and 3), and (3) an arbitrary three regions (there will -// be a pattern of 3-1-3-1-3-1 no matter what). -fn yQuads(self: Box) [3]u32 { - return switch (@mod(self.metrics.cell_height, 4)) { - 0 => .{ self.metrics.cell_height / 4, 2 * self.metrics.cell_height / 4, 3 * self.metrics.cell_height / 4 }, - 1 => .{ self.metrics.cell_height / 4, 2 * self.metrics.cell_height / 4 + 1, 3 * self.metrics.cell_height / 4 }, - 2 => .{ self.metrics.cell_height / 4 + 1, 2 * self.metrics.cell_height / 4, 3 * self.metrics.cell_height / 4 + 1 }, - 3 => .{ self.metrics.cell_height / 4 + 1, 2 * self.metrics.cell_height / 4 + 1, 3 * self.metrics.cell_height / 4 }, - else => unreachable, +/// Use these values as such: +/// yQuads[0] bottom edge of first quarter. +/// yQuads[1] top edge of second quarter. +/// yQuads[2] bottom edge of second quarter. +/// yQuads[3] top edge of third quarter. +/// yQuads[4] bottom edge of third quarter +/// yQuads[5] top edge of fourth quarter. +fn yQuads(self: Box) [6]u32 { + const float_height: f64 = @floatFromInt(self.metrics.cell_height); + const quarter_height: u32 = @intFromFloat(@round(0.25 * float_height)); + const half_height: u32 = @intFromFloat(@round(0.50 * float_height)); + const three_quarters_height: u32 = @intFromFloat(@round(0.75 * float_height)); + return .{ + quarter_height, + self.metrics.cell_height - three_quarters_height, + half_height, + self.metrics.cell_height - half_height, + three_quarters_height, + self.metrics.cell_height - quarter_height, }; } @@ -2591,8 +2721,12 @@ fn draw_smooth_mosaic( ) !void { const y_thirds = self.yThirds(); const top: f64 = 0.0; - const upper: f64 = @floatFromInt(y_thirds[0]); - const lower: f64 = @floatFromInt(y_thirds[1]); + // We average the edge positions for the y_thirds boundaries here + // rather than having to deal with varying alignments depending on + // the surrounding pieces. The most this will be off by is half of + // a pixel, so hopefully it's not noticeable. + const upper: f64 = 0.5 * (@as(f64, @floatFromInt(y_thirds[0])) + @as(f64, @floatFromInt(y_thirds[1]))); + const lower: f64 = 0.5 * (@as(f64, @floatFromInt(y_thirds[2])) + @as(f64, @floatFromInt(y_thirds[3]))); const bottom: f64 = @floatFromInt(self.metrics.cell_height); const left: f64 = 0.0; const center: f64 = @round(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2); @@ -3177,6 +3311,15 @@ fn testRenderAll(self: Box, alloc: Allocator, atlas: *font.Atlas) !void { else => {}, } } + + // Geometric Shapes: filled and outlined corners + for ([_]u21{ '◢', '◣', '◤', '◥', '◸', '◹', '◺', '◿' }) |char| { + _ = try self.renderGlyph( + alloc, + atlas, + char, + ); + } } test "render all sprites" { diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index f15423ada..af0c0af6a 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -190,6 +190,11 @@ const Kind = enum { // ▀ ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ ▉ ▊ ▋ ▌ ▍ ▎ ▏ ▐ ░ ▒ ▓ ▔ ▕ ▖ ▗ ▘ ▙ ▚ ▛ ▜ ▝ ▞ ▟ 0x2580...0x259F, + // "Geometric Shapes" block + 0x25e2...0x25e5, // ◢◣◤◥ + 0x25f8...0x25fa, // ◸◹◺ + 0x25ff, // ◿ + // "Braille" block 0x2800...0x28FF, diff --git a/src/font/sprite/canvas.zig b/src/font/sprite/canvas.zig index ed00aef12..a5ca7b290 100644 --- a/src/font/sprite/canvas.zig +++ b/src/font/sprite/canvas.zig @@ -150,7 +150,7 @@ pub const Canvas = struct { /// Acquires a z2d drawing context, caller MUST deinit context. pub fn getContext(self: *Canvas) z2d.Context { - return z2d.Context.init(self.alloc, &self.sfc); + return .init(self.alloc, &self.sfc); } /// Draw and fill a single pixel diff --git a/src/font/sprite/cursor.zig b/src/font/sprite/cursor.zig index 62195316e..d63db624a 100644 --- a/src/font/sprite/cursor.zig +++ b/src/font/sprite/cursor.zig @@ -50,7 +50,11 @@ pub fn renderGlyph( const region = try canvas.writeAtlas(alloc, atlas); return font.Glyph{ - .width = width, + // HACK: Set the width for the bar cursor to just the thickness, + // this is just for the benefit of the custom shader cursor + // uniform code. -- In the future code will be introduced to + // auto-crop the canvas so that this isn't needed. + .width = if (sprite == .cursor_bar) thickness else width, .height = height, .offset_x = 0, .offset_y = @intCast(height), diff --git a/src/font/sprite/testdata/Box.ppm b/src/font/sprite/testdata/Box.ppm index 0feb3ebe4..6082475af 100644 Binary files a/src/font/sprite/testdata/Box.ppm and b/src/font/sprite/testdata/Box.ppm differ diff --git a/src/global.zig b/src/global.zig index 375c10538..668d2faec 100644 --- a/src/global.zig +++ b/src/global.zig @@ -9,6 +9,7 @@ const harfbuzz = @import("harfbuzz"); const oni = @import("oniguruma"); const crash = @import("crash/main.zig"); const renderer = @import("renderer.zig"); +const apprt = @import("apprt.zig"); /// We export the xev backend we want to use so that the rest of /// Ghostty can import this once and have access to the proper @@ -35,7 +36,7 @@ pub const GlobalState = struct { /// The app resources directory, equivalent to zig-out/share when we build /// from source. This is null if we can't detect it. - resources_dir: ?[]const u8, + resources_dir: internal_os.ResourcesDir, /// Where logging should go pub const Logging = union(enum) { @@ -62,7 +63,7 @@ pub const GlobalState = struct { .action = null, .logging = .{ .stderr = {} }, .rlimits = .{}, - .resources_dir = null, + .resources_dir = .{}, }; errdefer self.deinit(); @@ -139,7 +140,7 @@ pub const GlobalState = struct { std.log.info("libxev default backend={s}", .{@tagName(xev.backend)}); // As early as possible, initialize our resource limits. - self.rlimits = ResourceLimits.init(); + self.rlimits = .init(); // Initialize our crash reporting. crash.init(self.alloc) catch |err| { @@ -170,11 +171,11 @@ pub const GlobalState = struct { // Find our resources directory once for the app so every launch // hereafter can use this cached value. - self.resources_dir = try internal_os.resourcesDir(self.alloc); - errdefer if (self.resources_dir) |dir| self.alloc.free(dir); + self.resources_dir = try apprt.runtime.resourcesDir(self.alloc); + errdefer self.resources_dir.deinit(self.alloc); // Setup i18n - if (self.resources_dir) |v| internal_os.i18n.init(v) catch |err| { + if (self.resources_dir.app()) |v| internal_os.i18n.init(v) catch |err| { std.log.warn("failed to init i18n, translations will not be available err={}", .{err}); }; } @@ -182,7 +183,7 @@ pub const GlobalState = struct { /// Cleans up the global state. This doesn't _need_ to be called but /// doing so in dev modes will check for memory leaks. pub fn deinit(self: *GlobalState) void { - if (self.resources_dir) |dir| self.alloc.free(dir); + self.resources_dir.deinit(self.alloc); // Flush our crash logs crash.deinit(); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 59adc7149..7cdb8047c 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -63,15 +63,17 @@ pub const Parser = struct { const flags, const start_idx = try parseFlags(raw_input); const input = raw_input[start_idx..]; - // Find the first = which splits are mapping into the trigger + // Find the last = which splits are mapping into the trigger // and action, respectively. - const eql_idx = std.mem.indexOf(u8, input, "=") orelse return Error.InvalidFormat; + // We use the last = because the keybind itself could contain + // raw equal signs (for the = codepoint) + const eql_idx = std.mem.lastIndexOf(u8, input, "=") orelse return Error.InvalidFormat; // Sequence iterator goes up to the equal, action is after. We can // parse the action now. return .{ .trigger_it = .{ .input = input[0..eql_idx] }, - .action = try Action.parse(input[eql_idx + 1 ..]), + .action = try .parse(input[eql_idx + 1 ..]), .flags = flags, }; } @@ -158,7 +160,7 @@ const SequenceIterator = struct { const rem = self.input[self.i..]; const idx = std.mem.indexOf(u8, rem, ">") orelse rem.len; defer self.i += idx + 1; - return try Trigger.parse(rem[0..idx]); + return try .parse(rem[0..idx]); } /// Returns true if there are no more triggers to parse. @@ -222,107 +224,195 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool { /// The set of actions that a keybinding can take. pub const Action = union(enum) { - /// Ignore this key combination, don't send it to the child process, just - /// black hole it. + /// Ignore this key combination. + /// + /// Ghostty will not process this combination nor forward it to the child + /// process within the terminal, but it may still be processed by the OS or + /// other applications. ignore, - /// This action is used to flag that the binding should be removed from - /// the set. This should never exist in an active set and `set.put` has an - /// assertion to verify this. + /// Unbind a previously bound key binding. + /// + /// This cannot unbind bindings that were not bound by Ghostty or the user + /// (e.g. bindings set by the OS or some other application). unbind, - /// Send a CSI sequence. The value should be the CSI sequence without the - /// CSI header (`ESC [` or `\x1b[`). + /// Send a CSI sequence. + /// + /// The value should be the CSI sequence without the CSI header (`ESC [` or + /// `\x1b[`). + /// + /// For example, `csi:0m` can be sent to reset all styles of the current text. csi: []const u8, /// Send an `ESC` sequence. esc: []const u8, - /// Send the given text. Uses Zig string literal syntax. This is currently - /// not validated. If the text is invalid (i.e. contains an invalid escape - /// sequence), the error will currently only show up in logs. + /// Send the specified text. + /// + /// Uses Zig string literal syntax. This is currently not validated. + /// If the text is invalid (i.e. contains an invalid escape sequence), + /// the error will currently only show up in logs. text: []const u8, /// Send data to the pty depending on whether cursor key mode is enabled /// (`application`) or disabled (`normal`). cursor_key: CursorKey, - /// Reset the terminal. This can fix a lot of issues when a running - /// program puts the terminal into a broken state. This is equivalent to - /// when you type "reset" and press enter. + /// Reset the terminal. + /// + /// This can fix a lot of issues when a running program puts the terminal + /// into a broken state, equivalent to running the `reset` command. /// /// If you do this while in a TUI program such as vim, this may break /// the program. If you do this while in a shell, you may have to press /// enter after to get a new prompt. reset, - /// Copy and paste. + /// Copy the selected text to the clipboard. copy_to_clipboard, + + /// Paste the contents of the default clipboard. paste_from_clipboard, + + /// Paste the contents of the selection clipboard. paste_from_selection, - /// Copy the URL under the cursor to the clipboard. If there is no - /// URL under the cursor, this does nothing. + /// If there is a URL under the cursor, copy it to the default clipboard. copy_url_to_clipboard, - /// Increase/decrease the font size by a certain amount. + /// Increase the font size by the specified amount in points (pt). + /// + /// For example, `increase_font_size:1.5` will increase the font size + /// by 1.5 points. increase_font_size: f32, + + /// Decrease the font size by the specified amount in points (pt). + /// + /// For example, `decrease_font_size:1.5` will decrease the font size + /// by 1.5 points. decrease_font_size: f32, /// Reset the font size to the original configured size. reset_font_size, - /// Clear the screen. This also clears all scrollback. + /// Clear the screen and all scrollback. clear_screen, /// Select all text on the screen. select_all, - /// Scroll the screen varying amounts. + /// Scroll to the top of the screen. scroll_to_top, + + /// Scroll to the bottom of the screen. scroll_to_bottom, + + /// Scroll to the selected text. scroll_to_selection, + + /// Scroll the screen up by one page. scroll_page_up, + + /// Scroll the screen down by one page. scroll_page_down, + + /// Scroll the screen by the specified fraction of a page. + /// + /// Positive values scroll downwards, and negative values scroll upwards. + /// + /// For example, `scroll_page_fractional:0.5` would scroll the screen + /// downwards by half a page, while `scroll_page_fractional:-1.5` would + /// scroll it upwards by one and a half pages. scroll_page_fractional: f32, + + /// Scroll the screen by the specified amount of lines. + /// + /// Positive values scroll downwards, and negative values scroll upwards. + /// + /// For example, `scroll_page_lines:3` would scroll the screen downwards + /// by 3 lines, while `scroll_page_lines:-10` would scroll it upwards by 10 + /// lines. scroll_page_lines: i16, - /// Adjust the current selection in a given direction. Does nothing if no - /// selection exists. + /// Adjust the current selection in the given direction or position, + /// relative to the cursor. /// - /// Arguments: - /// - left, right, up, down, page_up, page_down, home, end, - /// beginning_of_line, end_of_line + /// WARNING: This does not create a new selection, and does nothing when + /// there currently isn't one. + /// + /// Valid arguments are: + /// + /// - `left`, `right` + /// + /// Adjust the selection one cell to the left or right respectively. + /// + /// - `up`, `down` + /// + /// Adjust the selection one line upwards or downwards respectively. + /// + /// - `page_up`, `page_down` + /// + /// Adjust the selection one page upwards or downwards respectively. + /// + /// - `home`, `end` + /// + /// Adjust the selection to the top-left or the bottom-right corner + /// of the screen respectively. + /// + /// - `beginning_of_line`, `end_of_line` + /// + /// Adjust the selection to the beginning or the end of the line + /// respectively. /// - /// Example: Extend selection to the right - /// keybind = shift+right=adjust_selection:right adjust_selection: AdjustSelection, - /// Jump the viewport forward or back by prompt. Positive number is the - /// number of prompts to jump forward, negative is backwards. + /// Jump the viewport forward or back by the given number of prompts. + /// + /// Requires shell integration. + /// + /// Positive values scroll downwards, and negative values scroll upwards. jump_to_prompt: i16, - /// Write the entire scrollback into a temporary file. The action - /// determines what to do with the filepath. Valid values are: + /// Write the entire scrollback into a temporary file with the specified + /// action. The action determines what to do with the filepath. + /// + /// Valid actions are: + /// + /// - `copy` + /// + /// Copy the file path into the clipboard. + /// + /// - `paste` + /// + /// Paste the file path into the terminal. + /// + /// - `open` + /// + /// Open the file in the default OS editor for text files. /// - /// - "paste": Paste the file path into the terminal. - /// - "open": Open the file in the default OS editor for text files. /// The default OS editor is determined by using `open` on macOS /// and `xdg-open` on Linux. /// write_scrollback_file: WriteScreenAction, - /// Same as write_scrollback_file but writes the full screen contents. - /// See write_scrollback_file for available values. + /// Write the contents of the screen into a temporary file with the + /// specified action. + /// + /// See `write_scrollback_file` for possible actions. write_screen_file: WriteScreenAction, - /// Same as write_scrollback_file but writes the selected text. - /// If there is no selected text this does nothing (it doesn't - /// even create an empty file). See write_scrollback_file for - /// available values. + /// Write the currently selected text into a temporary file with the + /// specified action. + /// + /// See `write_scrollback_file` for possible actions. + /// + /// Does nothing when no text is selected. write_selection_file: WriteScreenAction, - /// Open a new window. If the application isn't currently focused, + /// Open a new window. + /// + /// If the application isn't currently focused, /// this will bring it to the front. new_window, @@ -335,187 +425,275 @@ pub const Action = union(enum) { /// Go to the next tab. next_tab, - /// Go to the last tab (the one with the highest index) + /// Go to the last tab. last_tab, - /// Go to the tab with the specific number, 1-indexed. If the tab number - /// is higher than the number of tabs, this will go to the last tab. + /// Go to the tab with the specific index, starting from 1. + /// + /// If the tab number is higher than the number of tabs, + /// this will go to the last tab. goto_tab: usize, /// Moves a tab by a relative offset. - /// Adjusts the tab position based on `offset`. For example `move_tab:-1` for left, `move_tab:1` for right. - /// If the new position is out of bounds, it wraps around cyclically within the tab range. + /// + /// Positive values move the tab forwards, and negative values move it + /// backwards. If the new position is out of bounds, it is wrapped around + /// cyclically within the tab list. + /// + /// For example, `move_tab:1` moves the tab one position forwards, and if + /// it was already the last tab in the list, it wraps around and becomes + /// the first tab in the list. Likewise, `move_tab:-1` moves the tab one + /// position backwards, and if it was the first tab, then it will become + /// the last tab. move_tab: isize, /// Toggle the tab overview. - /// This only works with libadwaita version 1.4.0 or newer. + /// + /// This is only supported on Linux and when the system's libadwaita + /// version is 1.4 or newer. The current libadwaita version can be + /// found by running `ghostty +version`. toggle_tab_overview, - /// Change the title of the current focused surface via a prompt. + /// Change the title of the current focused surface via a pop-up prompt. + /// + /// This requires libadwaita 1.5 or newer on Linux. The current libadwaita + /// version can be found by running `ghostty +version`. prompt_surface_title, - /// Create a new split in the given direction. + /// Create a new split in the specified direction. /// - /// Arguments: - /// - right, down, left, up, auto (splits along the larger direction) + /// Valid arguments: + /// + /// - `right`, `down`, `left`, `up` + /// + /// Creates a new split in the corresponding direction. + /// + /// - `auto` + /// + /// Creates a new split along the larger direction. + /// For example, if the parent split is currently wider than it is tall, + /// then a left-right split would be created, and vice versa. /// - /// Example: Create split on the right - /// keybind = cmd+shift+d=new_split:right new_split: SplitDirection, - /// Focus on a split in a given direction. For example `goto_split:up`. - /// Valid values are left, right, up, down, previous and next. + /// Focus on a split either in the specified direction (`right`, `down`, + /// `left` and `up`), or in the adjacent split in the order of creation + /// (`previous` and `next`). goto_split: SplitFocusDirection, - /// zoom/unzoom the current split. + /// Zoom in or out of the current split. + /// + /// When a split is zoomed into, it will take up the entire space in + /// the current tab, hiding other splits. The tab or tab bar would also + /// reflect this by displaying an icon indicating the zoomed state. toggle_split_zoom, - /// Resize the current split in a given direction. - /// - /// Arguments: - /// - up, down, left, right - /// - the number of pixels to resize the split by - /// - /// Example: Move divider up 10 pixels - /// keybind = cmd+shift+up=resize_split:up,10 + /// Resize the current split in the specified direction and amount in + /// pixels. The two arguments should be joined with a comma (`,`), + /// like in `resize_split:up,10`. resize_split: SplitResizeParameter, - /// Equalize all splits in the current window + /// Equalize the size of all splits in the current window. equalize_splits, /// Reset the window to the default size. The "default size" is the /// size that a new window would be created with. This has no effect /// if the window is fullscreen. + /// + /// Only implemented on macOS. reset_window_size, - /// Control the terminal inspector visibility. + /// Control the visibility of the terminal inspector. /// - /// Arguments: - /// - toggle, show, hide - /// - /// Example: Toggle inspector visibility - /// keybind = cmd+i=inspector:toggle + /// Valid arguments: `toggle`, `show`, `hide`. inspector: InspectorMode, - /// Open the configuration file in the default OS editor. If your default OS - /// editor isn't configured then this will fail. Currently, any failures to - /// open the configuration will show up only in the logs. + /// Show the GTK inspector. + /// + /// Has no effect on macOS. + show_gtk_inspector, + + /// Open the configuration file in the default OS editor. + /// + /// If your default OS editor isn't configured then this will fail. + /// Currently, any failures to open the configuration will show up only in + /// the logs. open_config, - /// Reload the configuration. The exact meaning depends on the app runtime - /// in use but this usually involves re-reading the configuration file - /// and applying any changes. Note that not all changes can be applied at - /// runtime. + /// Reload the configuration. + /// + /// The exact meaning depends on the app runtime in use, but this usually + /// involves re-reading the configuration file and applying any changes + /// Note that not all changes can be applied at runtime. reload_config, /// Close the current "surface", whether that is a window, tab, split, etc. - /// This only closes ONE surface. This will trigger close confirmation as - /// configured. + /// + /// This might trigger a close confirmation popup, depending on the value + /// of the `confirm-close-surface` configuration setting. close_surface, - /// Close the current tab, regardless of how many splits there may be. - /// This will trigger close confirmation as configured. + /// Close the current tab and all splits therein. + /// + /// This might trigger a close confirmation popup, depending on the value + /// of the `confirm-close-surface` configuration setting. close_tab, - /// Close the window, regardless of how many tabs or splits there may be. - /// This will trigger close confirmation as configured. + /// Close the current window and all tabs and splits therein. + /// + /// This might trigger a close confirmation popup, depending on the value + /// of the `confirm-close-surface` configuration setting. close_window, - /// Close all windows. This will trigger close confirmation as configured. - /// This only works for macOS currently. + /// Close all windows. + /// + /// WARNING: This action has been deprecated and has no effect on either + /// Linux or macOS. Users are instead encouraged to use `all:close_window` + /// instead. close_all_windows, - /// Toggle maximized window state. This only works on Linux. + /// Maximize or unmaximize the current window. + /// + /// This has no effect on macOS as it does not have the concept of + /// maximized windows. toggle_maximize, - /// Toggle fullscreen mode of window. + /// Fullscreen or unfullscreen the current window. toggle_fullscreen, - /// Toggle window decorations on and off. This only works on Linux. + /// Toggle window decorations (titlebar, buttons, etc.) for the current window. + /// + /// Only implemented on Linux. toggle_window_decorations, - /// Toggle whether the terminal window is always on top of other - /// windows even when it is not focused. Terminal windows always start - /// as normal (not always on top) windows. + /// Toggle whether the terminal window should always float on top of other + /// windows even when unfocused. /// - /// This only works on macOS. + /// Terminal windows always start as normal (not float-on-top) windows. + /// + /// Only implemented on macOS. toggle_window_float_on_top, - /// Toggle secure input mode on or off. This is used to prevent apps - /// that monitor input from seeing what you type. This is useful for - /// entering passwords or other sensitive information. + /// Toggle secure input mode. /// - /// This applies to the entire application, not just the focused - /// terminal. You must toggle it off to disable it, or quit Ghostty. + /// This is used to prevent apps from monitoring your keyboard input + /// when entering passwords or other sensitive information. /// - /// This only works on macOS, since this is a system API on macOS. + /// This applies to the entire application, not just the focused terminal. + /// You must manually untoggle it or quit Ghostty entirely to disable it. + /// + /// Only implemented on macOS, as this uses a built-in system API. toggle_secure_input, - /// Toggle the command palette. The command palette is a UI element - /// that lets you see what actions you can perform, their associated - /// keybindings (if any), a search bar to filter the actions, and - /// the ability to then execute the action. + /// Toggle the command palette. + /// + /// The command palette is a popup that lets you see what actions + /// you can perform, their associated keybindings (if any), a search bar + /// to filter the actions, and the ability to then execute the action. + /// + /// This requires libadwaita 1.5 or newer on Linux. The current libadwaita + /// version can be found by running `ghostty +version`. toggle_command_palette, - /// Toggle the "quick" terminal. The quick terminal is a terminal that - /// appears on demand from a keybinding, often sliding in from a screen - /// edge such as the top. This is useful for quick access to a terminal - /// without having to open a new window or tab. + /// Toggle the quick terminal. /// - /// When the quick terminal loses focus, it disappears. The terminal state - /// is preserved between appearances, so you can always press the keybinding - /// to bring it back up. + /// The quick terminal, also known as the "Quake-style" or drop-down + /// terminal, is a terminal window that appears on demand from a keybinding, + /// often sliding in from a screen edge such as the top. This is useful for + /// quick access to a terminal without having to open a new window or tab. /// - /// To enable the quick terminal globally so that Ghostty doesn't - /// have to be focused, prefix your keybind with `global`. Example: + /// The terminal state is preserved between appearances, so showing the + /// quick terminal after it was already hidden would display the same + /// window instead of creating a new one. + /// + /// As quick terminals are often useful when other windows are currently + /// focused, they are best used with *global* keybinds. For example, one + /// can define the following key bind to toggle the quick terminal from + /// anywhere within the system by pressing `` Cmd+` ``: /// /// ```ini - /// keybind = global:cmd+grave_accent=toggle_quick_terminal + /// keybind = global:cmd+backquote=toggle_quick_terminal /// ``` /// /// The quick terminal has some limitations: /// - /// - It is a singleton; only one instance can exist at a time. - /// - It does not support tabs, but it does support splits. - /// - It will not be restored when the application is restarted - /// (for systems that support window restoration). - /// - It supports fullscreen, but fullscreen will always be a non-native - /// fullscreen (macos-non-native-fullscreen = true). This only applies - /// to the quick terminal window. This is a requirement due to how - /// the quick terminal is rendered. + /// - Only one quick terminal instance can exist at a time. + /// + /// - Unlike normal terminal windows, the quick terminal will not be + /// restored when the application is restarted on systems that support + /// window restoration like macOS. + /// + /// - On Linux, the quick terminal is only supported on Wayland and not + /// X11, and only on Wayland compositors that support the `wlr-layer-shell-v1` + /// protocol. In practice, this means that only GNOME users would not be + /// able to use this feature. + /// + /// - On Linux, slide-in animations are only supported on KDE, and when + /// the "Sliding Popups" KWin plugin is enabled. + /// + /// If you do not have this plugin enabled, open System Settings > Apps + /// & Windows > Window Management > Desktop Effects, and enable the + /// plugin in the plugin list. Ghostty would then need to be restarted + /// fully for this to take effect. + /// + /// - Quick terminal tabs are only supported on Linux and not on macOS. + /// This is because tabs on macOS require a title bar. + /// + /// - On macOS, a fullscreened quick terminal will always be in non-native + /// fullscreen mode. This is a requirement due to how the quick terminal + /// is rendered. /// /// See the various configurations for the quick terminal in the /// configuration file to customize its behavior. - /// - /// Supported on macOS and some desktop environments on Linux, namely - /// those that support the `wlr-layer-shell` Wayland protocol - /// (i.e. most desktop environments and window managers except GNOME). - /// - /// Slide-in animations on Linux are only supported on KDE when the - /// "Sliding Popups" KWin plugin is enabled. If you do not have this - /// plugin enabled, open System Settings > Apps & Windows > Window - /// Management > Desktop Effects, and enable the plugin in the plugin list. - /// Ghostty would then need to be restarted for this to take effect. toggle_quick_terminal, - /// Show/hide all windows. If all windows become shown, we also ensure + /// Show or hide all windows. If all windows become shown, we also ensure /// Ghostty becomes focused. When hiding all windows, focus is yielded /// to the next application as determined by the OS. /// /// Note: When the focused surface is fullscreen, this method does nothing. /// - /// This currently only works on macOS. + /// Only implemented on macOS. toggle_visibility, /// Check for updates. /// - /// This currently only works on macOS. + /// Only implemented on macOS. check_for_updates, - /// Quit ghostty. + /// Undo the last undoable action for the focused surface or terminal, + /// if possible. This can undo actions such as closing tabs or + /// windows. + /// + /// Not every action in Ghostty can be undone or redone. The list + /// of actions support undo/redo is currently limited to: + /// + /// - New window, close window + /// - New tab, close tab + /// - New split, close split + /// + /// All actions are only undoable/redoable for a limited time. + /// For example, restoring a closed split can only be done for + /// some number of seconds since the split was closed. The exact + /// amount is configured with `TODO`. + /// + /// The undo/redo actions being limited ensures that there is + /// bounded memory usage over time, closed surfaces don't continue running + /// in the background indefinitely, and the keybinds become available + /// for terminal applications to use. + /// + /// Only implemented on macOS. + undo, + + /// Redo the last undoable action for the focused surface or terminal, + /// if possible. See "undo" for more details on what can and cannot + /// be undone or redone. + redo, + + /// Quit Ghostty. quit, - /// Crash ghostty in the desired thread for the focused surface. + /// Crash Ghostty in the desired thread for the focused surface. /// /// WARNING: This is a hard crash (panic) and data can be lost. /// @@ -525,9 +703,17 @@ pub const Action = union(enum) { /// /// The value determines the crash location: /// - /// - "main" - crash on the main (GUI) thread. - /// - "io" - crash on the IO thread for the focused surface. - /// - "render" - crash on the render thread for the focused surface. + /// - `main` + /// + /// Crash on the main (GUI) thread. + /// + /// - `io` + /// + /// Crash on the IO thread for the focused surface. + /// + /// - `render` + /// + /// Crash on the render thread for the focused surface. /// crash: CrashThread, @@ -631,6 +817,7 @@ pub const Action = union(enum) { }; pub const WriteScreenAction = enum { + copy, paste, open, }; @@ -795,10 +982,13 @@ pub const Action = union(enum) { .toggle_quick_terminal, .toggle_visibility, .check_for_updates, + .show_gtk_inspector, => .app, // These are app but can be special-cased in a surface context. .new_window, + .undo, + .redo, => .app, // Obviously surface actions. @@ -2039,6 +2229,32 @@ test "parse: plus sign" { try testing.expectError(Error.InvalidFormat, parseSingle("++=ignore")); } +test "parse: equals sign" { + const testing = std.testing; + + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .unicode = '=' } }, + .action = .ignore, + }, + try parseSingle("==ignore"), + ); + + // Modifier + try testing.expectEqual( + Binding{ + .trigger = .{ + .key = .{ .unicode = '=' }, + .mods = .{ .ctrl = true }, + }, + .action = .ignore, + }, + try parseSingle("ctrl+==ignore"), + ); + + try testing.expectError(Error.InvalidFormat, parseSingle("=ignore")); +} + // For Ghostty 1.2+ we changed our key names to match the W3C and removed // `physical:`. This tests the backwards compatibility with the old format. // Note that our backwards compatibility isn't 100% perfect since triggers diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig index 41634f2f1..b5f18b5a2 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/KeyEncoder.zig @@ -164,7 +164,7 @@ fn kitty( var seq: KittySequence = .{ .key = entry.code, .final = entry.final, - .mods = KittyMods.fromInput( + .mods = .fromInput( self.event.action, self.event.key, all_mods, diff --git a/src/input/command.zig b/src/input/command.zig index 8ef4a5f0e..693d5c8d4 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -18,7 +18,7 @@ const Action = @import("Binding.zig").Action; pub const Command = struct { action: Action, title: [:0]const u8, - description: [:0]const u8, + description: [:0]const u8 = "", /// ghostty_command_s pub const C = extern struct { @@ -28,6 +28,21 @@ pub const Command = struct { description: [*:0]const u8, }; + pub fn clone(self: *const Command, alloc: Allocator) Allocator.Error!Command { + return .{ + .action = try self.action.clone(alloc), + .title = try alloc.dupeZ(u8, self.title), + .description = try alloc.dupeZ(u8, self.description), + }; + } + + pub fn equal(self: Command, other: Command) bool { + if (self.action.hash() != other.action.hash()) return false; + if (!std.mem.eql(u8, self.title, other.title)) return false; + if (!std.mem.eql(u8, self.description, other.description)) return false; + return true; + } + /// Convert this command to a C struct. pub fn comptimeCval(self: Command) C { assert(@inComptime()); @@ -119,7 +134,7 @@ fn actionCommands(action: Action.Key) []const Command { .paste_from_clipboard => comptime &.{.{ .action = .paste_from_clipboard, .title = "Paste from Clipboard", - .description = "Paste the contents of the clipboard.", + .description = "Paste the contents of the main clipboard.", }}, .paste_from_selection => comptime &.{.{ @@ -189,6 +204,11 @@ fn actionCommands(action: Action.Key) []const Command { }}, .write_screen_file => comptime &.{ + .{ + .action = .{ .write_screen_file = .copy }, + .title = "Copy Screen to Temporary File and Copy Path", + .description = "Copy the screen contents to a temporary file and copy the path to the clipboard.", + }, .{ .action = .{ .write_screen_file = .paste }, .title = "Copy Screen to Temporary File and Paste Path", @@ -202,6 +222,11 @@ fn actionCommands(action: Action.Key) []const Command { }, .write_selection_file => comptime &.{ + .{ + .action = .{ .write_selection_file = .copy }, + .title = "Copy Selection to Temporary File and Copy Path", + .description = "Copy the selection contents to a temporary file and copy the path to the clipboard.", + }, .{ .action = .{ .write_selection_file = .paste }, .title = "Copy Selection to Temporary File and Paste Path", @@ -274,6 +299,39 @@ fn actionCommands(action: Action.Key) []const Command { }, }, + .goto_split => comptime &.{ + .{ + .action = .{ .goto_split = .previous }, + .title = "Focus Split: Previous", + .description = "Focus the previous split, if any.", + }, + .{ + .action = .{ .goto_split = .next }, + .title = "Focus Split: Next", + .description = "Focus the next split, if any.", + }, + .{ + .action = .{ .goto_split = .left }, + .title = "Focus Split: Left", + .description = "Focus the split to the left, if it exists.", + }, + .{ + .action = .{ .goto_split = .right }, + .title = "Focus Split: Right", + .description = "Focus the split to the right, if it exists.", + }, + .{ + .action = .{ .goto_split = .up }, + .title = "Focus Split: Up", + .description = "Focus the split above, if it exists.", + }, + .{ + .action = .{ .goto_split = .down }, + .title = "Focus Split: Down", + .description = "Focus the split below, if it exists.", + }, + }, + .toggle_split_zoom => comptime &.{.{ .action = .toggle_split_zoom, .title = "Toggle Split Zoom", @@ -298,6 +356,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle the inspector.", }}, + .show_gtk_inspector => comptime &.{.{ + .action = .show_gtk_inspector, + .title = "Show the GTK Inspector", + .description = "Show the GTK inspector.", + }}, + .open_config => comptime &.{.{ .action = .open_config, .title = "Open Config", @@ -370,6 +434,18 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Check for updates to the application.", }}, + .undo => comptime &.{.{ + .action = .undo, + .title = "Undo", + .description = "Undo the last action.", + }}, + + .redo => comptime &.{.{ + .action = .redo, + .title = "Redo", + .description = "Redo the last undone action.", + }}, + .quit => comptime &.{.{ .action = .quit, .title = "Quit", @@ -390,7 +466,6 @@ fn actionCommands(action: Action.Key) []const Command { .jump_to_prompt, .write_scrollback_file, .goto_tab, - .goto_split, .resize_split, .crash, => comptime &.{}, diff --git a/src/input/key.zig b/src/input/key.zig index 9dad37d78..28aa3ccf4 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -454,6 +454,11 @@ pub const Key = enum(c_int) { audio_volume_up, wake_up, + // "Legacy, Non-standard, and Special Keys" § 3.7 + copy, + cut, + paste, + /// Converts an ASCII character to a key, if possible. This returns /// null if the character is unknown. /// @@ -797,6 +802,9 @@ pub const Key = enum(c_int) { .audio_volume_up, .wake_up, .help, + .copy, + .cut, + .paste, => null, .unidentified, diff --git a/src/input/keycodes.zig b/src/input/keycodes.zig index b4004088e..2fa0665ea 100644 --- a/src/input/keycodes.zig +++ b/src/input/keycodes.zig @@ -11,7 +11,7 @@ pub const entries: []const Entry = entries: { const native_idx = switch (builtin.os.tag) { .ios, .macos => 4, // mac .windows => 3, // win - .linux => 2, // xkb + .freebsd, .linux => 2, // xkb else => @compileError("unsupported platform"), }; @@ -130,6 +130,9 @@ const code_to_key = code_to_key: { .{ "PageUp", .page_up }, .{ "Delete", .delete }, .{ "End", .end }, + .{ "Copy", .copy }, + .{ "Cut", .cut }, + .{ "Paste", .paste }, .{ "PageDown", .page_down }, .{ "ArrowRight", .arrow_right }, .{ "ArrowLeft", .arrow_left }, diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 6aa6628ab..5ab9d3cd4 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -308,7 +308,7 @@ pub const VTHandler = struct { current_seq: usize = 1, /// Exclude certain actions by tag. - filter_exclude: ActionTagSet = ActionTagSet.initMany(&.{.print}), + filter_exclude: ActionTagSet = .initMany(&.{.print}), filter_text: *cimgui.c.ImGuiTextFilter, const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag); diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 985c6c9bd..567eec5f9 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -98,11 +98,12 @@ pub fn main() !MainReturn { } // Create our app state - var app = try App.create(alloc); + const app: *App = try App.create(alloc); defer app.destroy(); // Create our runtime app - var app_runtime = try apprt.App.init(app, .{}); + var app_runtime: apprt.App = undefined; + try app_runtime.init(app, .{}); defer app_runtime.terminate(); // Since - by definition - there are no surfaces when first started, the diff --git a/src/os/args.zig b/src/os/args.zig index 9f7401c94..a531a418b 100644 --- a/src/os/args.zig +++ b/src/os/args.zig @@ -12,7 +12,7 @@ const macos = @import("macos"); /// but handles macOS using NSProcessInfo instead of libc argc/argv. pub fn iterator(allocator: Allocator) ArgIterator.InitError!ArgIterator { //if (true) return try std.process.argsWithAllocator(allocator); - return ArgIterator.initWithAllocator(allocator); + return .initWithAllocator(allocator); } /// Duck-typed to std.process.ArgIterator diff --git a/src/os/cf_release_thread.zig b/src/os/cf_release_thread.zig index dbf8e6592..445dc4864 100644 --- a/src/os/cf_release_thread.zig +++ b/src/os/cf_release_thread.zig @@ -8,6 +8,7 @@ const std = @import("std"); const builtin = @import("builtin"); const macos = @import("macos"); +const internal_os = @import("../os/main.zig"); const xev = @import("../global.zig").xev; const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue; @@ -119,6 +120,13 @@ pub fn threadMain(self: *Thread) void { fn threadMain_(self: *Thread) !void { defer log.debug("cf release thread exited", .{}); + // Right now, on Darwin, `std.Thread.setName` can only name the current + // thread, and we have no way to get the current thread from within it, + // so instead we use this code to name the thread instead. + if (builtin.os.tag.isDarwin()) { + internal_os.macos.pthread_setname_np(&"cf_release".*); + } + // Start the async handlers. We start these first so that they're // registered even if anything below fails so we can drain the mailbox. self.wakeup.wait(&self.loop, &self.wakeup_c, Thread, self, wakeupCallback); diff --git a/src/os/cgroup.zig b/src/os/cgroup.zig index 5645e337a..4f13921c5 100644 --- a/src/os/cgroup.zig +++ b/src/os/cgroup.zig @@ -56,6 +56,25 @@ pub fn create( } } +/// Remove a cgroup. This will only succeed if the cgroup is empty +/// (has no processes). The cgroup path should be relative to the +/// cgroup root (e.g. "/user.slice/surfaces/abc123.scope"). +pub fn remove(cgroup: []const u8) !void { + assert(cgroup.len > 0); + assert(cgroup[0] == '/'); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try std.fmt.bufPrint(&buf, "/sys/fs/cgroup{s}", .{cgroup}); + std.fs.cwd().deleteDir(path) catch |err| switch (err) { + // If it doesn't exist, that's fine - maybe it was already cleaned up + error.FileNotFound => {}, + + // Any other error we failed to delete it so we want to notify + // the user. + else => return err, + }; +} + /// Move the given PID into the given cgroup. pub fn moveInto( cgroup: []const u8, diff --git a/src/os/dbus.zig b/src/os/dbus.zig new file mode 100644 index 000000000..99824db71 --- /dev/null +++ b/src/os/dbus.zig @@ -0,0 +1,21 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +/// Returns true if the program was launched by D-Bus activation. +/// +/// On Linux GTK, this returns true if the program was launched using D-Bus +/// activation. It will return false if Ghostty was launched any other way. +/// +/// For other platforms and app runtimes, this returns false. +pub fn launchedByDbusActivation() bool { + return switch (builtin.os.tag) { + // On Linux, D-Bus activation sets `DBUS_STARTER_ADDRESS` and + // `DBUS_STARTER_BUS_TYPE`. If these environment variables are present + // (no matter the value) we were launched by D-Bus activation. + .linux => std.posix.getenv("DBUS_STARTER_ADDRESS") != null and + std.posix.getenv("DBUS_STARTER_BUS_TYPE") != null, + + // No other system supports D-Bus so always return false. + else => false, + }; +} diff --git a/src/os/desktop.zig b/src/os/desktop.zig index c73f150e0..3bc843e5c 100644 --- a/src/os/desktop.zig +++ b/src/os/desktop.zig @@ -30,24 +30,24 @@ pub fn launchedFromDesktop() bool { break :macos c.getppid() == 1; }, - // On Linux, GTK sets GIO_LAUNCHED_DESKTOP_FILE and + // On Linux and BSD, GTK sets GIO_LAUNCHED_DESKTOP_FILE and // GIO_LAUNCHED_DESKTOP_FILE_PID. We only check the latter to see if // we match the PID and assume that if we do, we were launched from // the desktop file. Pid comparing catches the scenario where // another terminal was launched from a desktop file and then launches // Ghostty and Ghostty inherits the env. - .linux => linux: { + .linux, .freebsd => ul: { const gio_pid_str = posix.getenv("GIO_LAUNCHED_DESKTOP_FILE_PID") orelse - break :linux false; + break :ul false; const pid = c.getpid(); const gio_pid = std.fmt.parseInt( @TypeOf(pid), gio_pid_str, 10, - ) catch break :linux false; + ) catch break :ul false; - break :linux gio_pid == pid; + break :ul gio_pid == pid; }, // TODO: This should have some logic to detect this. Perhaps std.builtin.subsystem @@ -71,14 +71,14 @@ pub const DesktopEnvironment = enum { }; /// Detect what desktop environment we are running under. This is mainly used -/// on Linux to enable or disable certain features but there may be more uses in +/// on Linux and BSD to enable or disable certain features but there may be more uses in /// the future. pub fn desktopEnvironment() DesktopEnvironment { return switch (comptime builtin.os.tag) { .macos => .macos, .windows => .windows, - .linux => de: { - if (@inComptime()) @compileError("Checking for the desktop environment on Linux must be done at runtime."); + .linux, .freebsd => de: { + if (@inComptime()) @compileError("Checking for the desktop environment on Linux/BSD must be done at runtime."); // Use $XDG_SESSION_DESKTOP to determine what DE we are using on Linux // https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html#desktop= @@ -110,7 +110,7 @@ test "desktop environment" { switch (builtin.os.tag) { .macos => try testing.expectEqual(.macos, desktopEnvironment()), .windows => try testing.expectEqual(.windows, desktopEnvironment()), - .linux => { + .linux, .freebsd => { const getenv = std.posix.getenv; const setenv = @import("env.zig").setenv; const unsetenv = @import("env.zig").unsetenv; diff --git a/src/os/flatpak.zig b/src/os/flatpak.zig index 7b92a8ba9..7bd84bc27 100644 --- a/src/os/flatpak.zig +++ b/src/os/flatpak.zig @@ -112,6 +112,8 @@ pub const FlatpakHostCommand = struct { pub fn spawn(self: *FlatpakHostCommand, alloc: Allocator) !u32 { const thread = try std.Thread.spawn(.{}, threadMain, .{ self, alloc }); thread.setName("flatpak-host-command") catch {}; + // We don't track this thread, it will terminate on its own on command exit + thread.detach(); // Wait for the process to start or error. self.state_mutex.lock(); @@ -232,9 +234,10 @@ pub const FlatpakHostCommand = struct { }; // Get our bus connection. - var g_err: [*c]c.GError = null; + var g_err: ?*c.GError = null; + defer if (g_err) |ptr| c.g_error_free(ptr); const bus = c.g_bus_get_sync(c.G_BUS_TYPE_SESSION, null, &g_err) orelse { - log.warn("signal error getting bus: {s}", .{g_err.*.message}); + log.warn("signal error getting bus: {s}", .{g_err.?.*.message}); return Error.FlatpakSetupFail; }; defer c.g_object_unref(bus); @@ -258,7 +261,7 @@ pub const FlatpakHostCommand = struct { &g_err, ); if (g_err != null) { - log.warn("signal send error: {s}", .{g_err.*.message}); + log.warn("signal send error: {s}", .{g_err.?.*.message}); return; } defer c.g_variant_unref(reply); @@ -278,9 +281,10 @@ pub const FlatpakHostCommand = struct { // Get our bus connection. This has to remain active until we exit // the thread otherwise our signals won't be called. - var g_err: [*c]c.GError = null; + var g_err: ?*c.GError = null; + defer if (g_err) |ptr| c.g_error_free(ptr); const bus = c.g_bus_get_sync(c.G_BUS_TYPE_SESSION, null, &g_err) orelse { - log.warn("spawn error getting bus: {s}", .{g_err.*.message}); + log.warn("spawn error getting bus: {s}", .{g_err.?.*.message}); self.updateState(.{ .err = {} }); return; }; @@ -308,7 +312,8 @@ pub const FlatpakHostCommand = struct { bus: *c.GDBusConnection, loop: *c.GMainLoop, ) !void { - var err: [*c]c.GError = null; + var err: ?*c.GError = null; + defer if (err) |ptr| c.g_error_free(ptr); var arena_allocator = std.heap.ArenaAllocator.init(alloc); defer arena_allocator.deinit(); const arena = arena_allocator.allocator(); @@ -317,15 +322,15 @@ pub const FlatpakHostCommand = struct { const fd_list = c.g_unix_fd_list_new(); defer c.g_object_unref(fd_list); if (c.g_unix_fd_list_append(fd_list, self.stdin, &err) < 0) { - log.warn("error adding fd: {s}", .{err.*.message}); + log.warn("error adding fd: {s}", .{err.?.*.message}); return Error.FlatpakSetupFail; } if (c.g_unix_fd_list_append(fd_list, self.stdout, &err) < 0) { - log.warn("error adding fd: {s}", .{err.*.message}); + log.warn("error adding fd: {s}", .{err.?.*.message}); return Error.FlatpakSetupFail; } if (c.g_unix_fd_list_append(fd_list, self.stderr, &err) < 0) { - log.warn("error adding fd: {s}", .{err.*.message}); + log.warn("error adding fd: {s}", .{err.?.*.message}); return Error.FlatpakSetupFail; } @@ -405,7 +410,7 @@ pub const FlatpakHostCommand = struct { null, &err, ) orelse { - log.warn("Flatpak.HostCommand failed: {s}", .{err.*.message}); + log.warn("Flatpak.HostCommand failed: {s}", .{err.?.*.message}); return Error.FlatpakRPCFail; }; defer c.g_variant_unref(reply); diff --git a/src/os/homedir.zig b/src/os/homedir.zig index b5629fd65..f3d6e4498 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -14,7 +14,7 @@ const Error = error{ /// is generally an expensive process so the value should be cached. pub inline fn home(buf: []u8) !?[]const u8 { return switch (builtin.os.tag) { - inline .linux, .macos => try homeUnix(buf), + inline .linux, .freebsd, .macos => try homeUnix(buf), .windows => try homeWindows(buf), // iOS doesn't have a user-writable home directory @@ -122,7 +122,7 @@ pub const ExpandError = error{ /// than `buf.len`. pub fn expandHome(path: []const u8, buf: []u8) ExpandError![]const u8 { return switch (builtin.os.tag) { - .linux, .macos => try expandHomeUnix(path, buf), + .linux, .freebsd, .macos => try expandHomeUnix(path, buf), .ios => return path, else => @compileError("unimplemented"), }; diff --git a/src/os/hostname.zig b/src/os/hostname.zig index 22f29ceff..a75ca1cbb 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const posix = std.posix; pub const HostnameParsingError = error{ @@ -6,6 +7,96 @@ pub const HostnameParsingError = error{ NoSpaceLeft, }; +pub const UrlParsingError = std.Uri.ParseError || error{ + HostnameIsNotMacAddress, + NoSchemeProvided, +}; + +const mac_address_length = 17; + +fn isUriPathSeparator(c: u8) bool { + return switch (c) { + '?', '#' => true, + else => false, + }; +} + +fn isValidMacAddress(mac_address: []const u8) bool { + // A valid mac address has 6 two-character components with 5 colons, e.g. 12:34:56:ab:cd:ef. + if (mac_address.len != 17) { + return false; + } + + for (mac_address, 0..) |c, i| { + if ((i + 1) % 3 == 0) { + if (c != ':') { + return false; + } + } else if (!std.mem.containsAtLeastScalar(u8, "0123456789ABCDEFabcdef", 1, c)) { + return false; + } + } + + return true; +} + +/// Parses the provided url to a `std.Uri` struct. This is very specific to getting hostname and +/// path information for Ghostty's PWD reporting functionality. Takes into account that on macOS +/// the url passed to this function might have a mac address as its hostname and parses it +/// correctly. +pub fn parseUrl(url: []const u8) UrlParsingError!std.Uri { + return std.Uri.parse(url) catch |e| { + // The mac-address-as-hostname issue is specific to macOS so we just return an error if we + // hit it on other platforms. + if (comptime builtin.os.tag != .macos) return e; + + // It's possible this is a mac address on macOS where the last 2 characters in the + // address are non-digits, e.g. 'ff', and thus an invalid port. + // + // Example: file://12:34:56:78:90:12/path/to/file + if (e != error.InvalidPort) return e; + + const url_without_scheme_start = std.mem.indexOf(u8, url, "://") orelse { + return error.NoSchemeProvided; + }; + const scheme = url[0..url_without_scheme_start]; + const url_without_scheme = url[url_without_scheme_start + 3 ..]; + + // The first '/' after the scheme marks the end of the hostname. If the first '/' + // following the end of the scheme is not at the right position this is not a + // valid mac address. + if (url_without_scheme.len != mac_address_length and + std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != mac_address_length) + { + return error.HostnameIsNotMacAddress; + } + + // At this point we may have a mac address as the hostname. + const mac_address = url_without_scheme[0..mac_address_length]; + + if (!isValidMacAddress(mac_address)) { + return error.HostnameIsNotMacAddress; + } + + var uri_path_end_idx: usize = mac_address_length; + while (uri_path_end_idx < url_without_scheme.len and + !isUriPathSeparator(url_without_scheme[uri_path_end_idx])) + { + uri_path_end_idx += 1; + } + + // Same compliance factor as std.Uri.parse(), i.e. not at all compliant with the URI + // spec. + return .{ + .scheme = scheme, + .host = .{ .percent_encoded = mac_address }, + .path = .{ + .percent_encoded = url_without_scheme[mac_address_length..uri_path_end_idx], + }, + }; + }; +} + /// Print the hostname from a file URI into a buffer. pub fn bufPrintHostnameFromFileUri( buf: []u8, @@ -70,6 +161,101 @@ pub fn isLocalHostname(hostname: []const u8) LocalHostnameValidationError!bool { return std.mem.eql(u8, hostname, ourHostname); } +test parseUrl { + // 1. Typical hostnames. + + var uri = try parseUrl("file://personal.computer/home/test/"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("personal.computer", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + uri = try parseUrl("kitty-shell-cwd://personal.computer/home/test/"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("personal.computer", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + // 2. Hostnames that are mac addresses. + + // Numerical mac addresses. + + uri = try parseUrl("file://12:34:56:78:90:12/home/test/"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == 12); + + uri = try parseUrl("kitty-shell-cwd://12:34:56:78:90:12/home/test/"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == 12); + + // Alphabetical mac addresses. + + uri = try parseUrl("file://ab:cd:ef:ab:cd:ef/home/test/"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + uri = try parseUrl("kitty-shell-cwd://ab:cd:ef:ab:cd:ef/home/test/"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + // 3. Hostnames that are mac addresses with no path. + + // Numerical mac addresses. + + uri = try parseUrl("file://12:34:56:78:90:12"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("", uri.path.percent_encoded); + try std.testing.expect(uri.port == 12); + + uri = try parseUrl("kitty-shell-cwd://12:34:56:78:90:12"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("", uri.path.percent_encoded); + try std.testing.expect(uri.port == 12); + + // Alphabetical mac addresses. + + uri = try parseUrl("file://ab:cd:ef:ab:cd:ef"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + uri = try parseUrl("kitty-shell-cwd://ab:cd:ef:ab:cd:ef"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); +} + +test "parseUrl succeeds even if path component is missing" { + const uri = try parseUrl("file://12:34:56:78:90:ab"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90:ab", uri.host.?.percent_encoded); + try std.testing.expect(uri.path.isEmpty()); + try std.testing.expect(uri.port == null); +} + test "bufPrintHostnameFromFileUri succeeds with ascii hostname" { const uri = try std.Uri.parse("file://localhost/"); @@ -86,6 +272,14 @@ test "bufPrintHostnameFromFileUri succeeds with hostname as mac address" { try std.testing.expectEqualStrings("12:34:56:78:90:12", actual); } +test "bufPrintHostnameFromFileUri succeeds with hostname as mac address with the last component as ascii" { + const uri = try parseUrl("file://12:34:56:78:90:ab"); + + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const actual = try bufPrintHostnameFromFileUri(&buf, uri); + try std.testing.expectEqualStrings("12:34:56:78:90:ab", actual); +} + test "bufPrintHostnameFromFileUri succeeds with hostname as a mac address and the last section is < 10" { const uri = try std.Uri.parse("file://12:34:56:78:90:05"); diff --git a/src/os/i18n.zig b/src/os/i18n.zig index fd1d44ab0..1ba8a676c 100644 --- a/src/os/i18n.zig +++ b/src/os/i18n.zig @@ -69,23 +69,27 @@ pub const InitError = error{ /// want to set the domain for the entire application since this is also /// used by libghostty. pub fn init(resources_dir: []const u8) InitError!void { - // i18n is unsupported on Windows - if (builtin.os.tag == .windows) return; + switch (builtin.os.tag) { + // i18n is unsupported on Windows + .windows => return, - // Our resources dir is always nested below the share dir that - // is standard for translations. - const share_dir = std.fs.path.dirname(resources_dir) orelse - return error.InvalidResourcesDir; + else => { + // Our resources dir is always nested below the share dir that + // is standard for translations. + const share_dir = std.fs.path.dirname(resources_dir) orelse + return error.InvalidResourcesDir; - // Build our locale path - var buf: [std.fs.max_path_bytes]u8 = undefined; - const path = std.fmt.bufPrintZ(&buf, "{s}/locale", .{share_dir}) catch - return error.OutOfMemory; + // Build our locale path + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = std.fmt.bufPrintZ(&buf, "{s}/locale", .{share_dir}) catch + return error.OutOfMemory; - // Bind our bundle ID to the given locale path - log.debug("binding domain={s} path={s}", .{ build_config.bundle_id, path }); - _ = bindtextdomain(build_config.bundle_id, path.ptr) orelse - return error.OutOfMemory; + // Bind our bundle ID to the given locale path + log.debug("binding domain={s} path={s}", .{ build_config.bundle_id, path }); + _ = bindtextdomain(build_config.bundle_id, path.ptr) orelse + return error.OutOfMemory; + }, + } } /// Set the global gettext domain to our bundle ID, allowing unqualified diff --git a/src/os/locale.zig b/src/os/locale.zig index 17e4d163c..b391d690f 100644 --- a/src/os/locale.zig +++ b/src/os/locale.zig @@ -108,11 +108,8 @@ fn setLangFromCocoa() void { } // Get our preferred languages and set that to the LANGUAGE - // env var in case our language differs from our locale. We only - // do this when the app is launched from the desktop because then - // we're in an app bundle and we are expected to read from our - // Bundle's preferred languages. - if (internal_os.launchedFromDesktop()) language: { + // env var in case our language differs from our locale. + language: { var buf: [1024]u8 = undefined; const pref_ = preferredLanguageFromCocoa( &buf, diff --git a/src/os/macos.zig b/src/os/macos.zig index ca7c81a47..100d0fe44 100644 --- a/src/os/macos.zig +++ b/src/os/macos.zig @@ -88,6 +88,10 @@ extern "c" fn pthread_set_qos_class_self_np( relative_priority: c_int, ) c_int; +pub extern "c" fn pthread_setname_np( + name: [*:0]const u8, +) void; + pub const NSOperatingSystemVersion = extern struct { major: i64, minor: i64, diff --git a/src/os/main.zig b/src/os/main.zig index 36833f427..906e3d150 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -2,6 +2,7 @@ //! system. These aren't restricted to syscalls or low-level operations, but //! also OS-specific features and conventions. +const dbus = @import("dbus.zig"); const desktop = @import("desktop.zig"); const env = @import("env.zig"); const file = @import("file.zig"); @@ -12,6 +13,7 @@ const mouse = @import("mouse.zig"); const openpkg = @import("open.zig"); const pipepkg = @import("pipe.zig"); const resourcesdir = @import("resourcesdir.zig"); +const systemd = @import("systemd.zig"); // Namespaces pub const args = @import("args.zig"); @@ -27,6 +29,7 @@ pub const shell = @import("shell.zig"); // Functions and types pub const CFReleaseThread = @import("cf_release_thread.zig"); pub const TempDir = @import("TempDir.zig"); +pub const GetEnvResult = env.GetEnvResult; pub const getEnvMap = env.getEnvMap; pub const appendEnv = env.appendEnv; pub const appendEnvAlways = env.appendEnvAlways; @@ -35,6 +38,8 @@ pub const getenv = env.getenv; pub const setenv = env.setenv; pub const unsetenv = env.unsetenv; pub const launchedFromDesktop = desktop.launchedFromDesktop; +pub const launchedByDbusActivation = dbus.launchedByDbusActivation; +pub const launchedBySystemd = systemd.launchedBySystemd; pub const desktopEnvironment = desktop.desktopEnvironment; pub const rlimit = file.rlimit; pub const fixMaxFiles = file.fixMaxFiles; @@ -51,6 +56,7 @@ pub const open = openpkg.open; pub const OpenType = openpkg.Type; pub const pipe = pipepkg.pipe; pub const resourcesDir = resourcesdir.resourcesDir; +pub const ResourcesDir = resourcesdir.ResourcesDir; pub const ShellEscapeWriter = shell.ShellEscapeWriter; test { diff --git a/src/os/open.zig b/src/os/open.zig index f7eadd06e..ce62a7e0b 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -2,6 +2,8 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; +const log = std.log.scoped(.@"os-open"); + /// The type of the data at the URL to open. This is used as a hint /// to potentially open the URL in a different way. pub const Type = enum { @@ -12,68 +14,73 @@ pub const Type = enum { /// Open a URL in the default handling application. /// /// Any output on stderr is logged as a warning in the application logs. -/// Output on stdout is ignored. +/// Output on stdout is ignored. The allocator is used to buffer the +/// log output and may allocate from another thread. pub fn open( alloc: Allocator, typ: Type, url: []const u8, ) !void { - const cmd: OpenCommand = switch (builtin.os.tag) { - .linux => .{ .child = std.process.Child.init( + var exe: std.process.Child = switch (builtin.os.tag) { + .linux, .freebsd => .init( &.{ "xdg-open", url }, alloc, - ) }, + ), - .windows => .{ .child = std.process.Child.init( + .windows => .init( &.{ "rundll32", "url.dll,FileProtocolHandler", url }, alloc, - ) }, + ), - .macos => .{ - .child = std.process.Child.init( - switch (typ) { - .text => &.{ "open", "-t", url }, - .unknown => &.{ "open", url }, - }, - alloc, - ), - .wait = true, - }, + .macos => .init( + switch (typ) { + .text => &.{ "open", "-t", url }, + .unknown => &.{ "open", url }, + }, + alloc, + ), .ios => return error.Unimplemented, else => @compileError("unsupported OS"), }; - var exe = cmd.child; - if (cmd.wait) { - // Pipe stdout/stderr so we can collect output from the command - exe.stdout_behavior = .Pipe; - exe.stderr_behavior = .Pipe; - } + // Pipe stdout/stderr so we can collect output from the command. + // This must be set before spawning the process. + exe.stdout_behavior = .Pipe; + exe.stderr_behavior = .Pipe; + // Spawn the process on our same thread so we can detect failure + // quickly. try exe.spawn(); - if (cmd.wait) { - // 50 KiB is the default value used by std.process.Child.run - const output_max_size = 50 * 1024; - - var stdout: std.ArrayListUnmanaged(u8) = .{}; - var stderr: std.ArrayListUnmanaged(u8) = .{}; - defer { - stdout.deinit(alloc); - stderr.deinit(alloc); - } - - try exe.collectOutput(alloc, &stdout, &stderr, output_max_size); - _ = try exe.wait(); - - // If we have any stderr output we log it. This makes it easier for - // users to debug why some open commands may not work as expected. - if (stderr.items.len > 0) std.log.err("open stderr={s}", .{stderr.items}); - } + // Create a thread that handles collecting output and reaping + // the process. This is done in a separate thread because SOME + // open implementations block and some do not. It's easier to just + // spawn a thread to handle this so that we never block. + const thread = try std.Thread.spawn(.{}, openThread, .{ alloc, exe }); + thread.detach(); } -const OpenCommand = struct { - child: std.process.Child, - wait: bool = false, -}; +fn openThread(alloc: Allocator, exe_: std.process.Child) !void { + // 50 KiB is the default value used by std.process.Child.run and should + // be enough to get the output we care about. + const output_max_size = 50 * 1024; + + var stdout: std.ArrayListUnmanaged(u8) = .{}; + var stderr: std.ArrayListUnmanaged(u8) = .{}; + defer { + stdout.deinit(alloc); + stderr.deinit(alloc); + } + + // Copy the exe so it is non-const. This is necessary because wait() + // requires a mutable reference and we can't have one as a thread + // param. + var exe = exe_; + try exe.collectOutput(alloc, &stdout, &stderr, output_max_size); + _ = try exe.wait(); + + // If we have any stderr output we log it. This makes it easier for + // users to debug why some open commands may not work as expected. + if (stderr.items.len > 0) log.warn("wait stderr={s}", .{stderr.items}); +} diff --git a/src/os/resourcesdir.zig b/src/os/resourcesdir.zig index 6f69b91d3..278de44fc 100644 --- a/src/os/resourcesdir.zig +++ b/src/os/resourcesdir.zig @@ -2,13 +2,42 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; +pub const ResourcesDir = struct { + /// Avoid accessing these directly, use the app() and host() methods instead. + app_path: ?[]const u8 = null, + host_path: ?[]const u8 = null, + + /// Free resources held. Requires the same allocator as when resourcesDir() + /// is called. + pub fn deinit(self: *ResourcesDir, alloc: Allocator) void { + if (self.app_path) |p| alloc.free(p); + if (self.host_path) |p| alloc.free(p); + } + + /// Get the directory to the bundled resources directory accessible + /// by the application. + pub fn app(self: *ResourcesDir) ?[]const u8 { + return self.app_path; + } + + /// Get the directory to the bundled resources directory accessible + /// by the host environment (i.e. for sandboxed applications). The + /// returned directory might not be accessible from the application + /// itself. + /// + /// In non-sandboxed environment, this should be the same as app(). + pub fn host(self: *ResourcesDir) ?[]const u8 { + return self.host_path orelse self.app_path; + } +}; + /// Gets the directory to the bundled resources directory, if it /// exists (not all platforms or packages have it). The output is /// owned by the caller. /// /// This is highly Ghostty-specific and can likely be generalized at /// some point but we can cross that bridge if we ever need to. -pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { +pub fn resourcesDir(alloc: Allocator) !ResourcesDir { // Use the GHOSTTY_RESOURCES_DIR environment variable in release builds. // // In debug builds we try using terminfo detection first instead, since @@ -20,7 +49,7 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { // freed, do not try to use internal_os.getenv or posix getenv. if (comptime builtin.mode != .Debug) { if (std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR")) |dir| { - if (dir.len > 0) return dir; + if (dir.len > 0) return .{ .app_path = dir }; } else |err| switch (err) { error.EnvironmentVariableNotFound => {}, else => return err, @@ -32,12 +61,13 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { const sentinels = switch (comptime builtin.target.os.tag) { .windows => .{"terminfo/ghostty.terminfo"}, .macos => .{"terminfo/78/xterm-ghostty"}, + .freebsd => .{ "site-terminfo/g/ghostty", "site-terminfo/x/xterm-ghostty" }, else => .{ "terminfo/g/ghostty", "terminfo/x/xterm-ghostty" }, }; // Get the path to our running binary var exe_buf: [std.fs.max_path_bytes]u8 = undefined; - var exe: []const u8 = std.fs.selfExePath(&exe_buf) catch return null; + var exe: []const u8 = std.fs.selfExePath(&exe_buf) catch return .{}; // We have an exe path! Climb the tree looking for the terminfo // bundle as we expect it. @@ -49,17 +79,22 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { if (comptime builtin.target.os.tag.isDarwin()) { inline for (sentinels) |sentinel| { if (try maybeDir(&dir_buf, dir, "Contents/Resources", sentinel)) |v| { - return try std.fs.path.join(alloc, &.{ v, "ghostty" }); + return .{ .app_path = try std.fs.path.join(alloc, &.{ v, "ghostty" }) }; } } } - // On all platforms, we look for a /usr/share style path. This + // On all platforms (except BSD), we look for a /usr/share style path. This // is valid even on Mac since there is nothing that requires // Ghostty to be in an app bundle. inline for (sentinels) |sentinel| { - if (try maybeDir(&dir_buf, dir, "share", sentinel)) |v| { - return try std.fs.path.join(alloc, &.{ v, "ghostty" }); + if (try maybeDir( + &dir_buf, + dir, + if (builtin.target.os.tag == .freebsd) "local/share" else "share", + sentinel, + )) |v| { + return .{ .app_path = try std.fs.path.join(alloc, &.{ v, "ghostty" }) }; } } } @@ -68,14 +103,14 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { // fallback and use the provided resources dir. if (comptime builtin.mode == .Debug) { if (std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR")) |dir| { - if (dir.len > 0) return dir; + if (dir.len > 0) return .{ .app_path = dir }; } else |err| switch (err) { error.EnvironmentVariableNotFound => {}, else => return err, } } - return null; + return .{}; } /// Little helper to check if the "base/sub/suffix" directory exists and diff --git a/src/os/systemd.zig b/src/os/systemd.zig new file mode 100644 index 000000000..9b67296d6 --- /dev/null +++ b/src/os/systemd.zig @@ -0,0 +1,65 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const log = std.log.scoped(.systemd); + +/// Returns true if the program was launched as a systemd service. +/// +/// On Linux, this returns true if the program was launched as a systemd +/// service. It will return false if Ghostty was launched any other way. +/// +/// For other platforms and app runtimes, this returns false. +pub fn launchedBySystemd() bool { + return switch (builtin.os.tag) { + .linux => linux: { + // On Linux, systemd sets the `INVOCATION_ID` (v232+) and the + // `JOURNAL_STREAM` (v231+) environment variables. If these + // environment variables are not present we were not launched by + // systemd. + if (std.posix.getenv("INVOCATION_ID") == null) break :linux false; + if (std.posix.getenv("JOURNAL_STREAM") == null) break :linux false; + + // If `INVOCATION_ID` and `JOURNAL_STREAM` are present, check to make sure + // that our parent process is actually `systemd`, not some other terminal + // emulator that doesn't clean up those environment variables. + const ppid = std.os.linux.getppid(); + if (ppid == 1) break :linux true; + + // If the parent PID is not 1 we need to check to see if we were launched by + // a user systemd daemon. Do that by checking the `/proc//comm` + // to see if it ends with `systemd`. + var comm_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const comm_path = std.fmt.bufPrint(&comm_path_buf, "/proc/{d}/comm", .{ppid}) catch { + log.err("unable to format comm path for pid {d}", .{ppid}); + break :linux false; + }; + const comm_file = std.fs.openFileAbsolute(comm_path, .{ .mode = .read_only }) catch { + log.err("unable to open '{s}' for reading", .{comm_path}); + break :linux false; + }; + defer comm_file.close(); + + // The maximum length of the command name is defined by + // `TASK_COMM_LEN` in the Linux kernel. This is usually 16 + // bytes at the time of writing (Jun 2025) so its set to that. + // Also, since we only care to compare to "systemd", anything + // longer can be assumed to not be systemd. + const TASK_COMM_LEN = 16; + var comm_data_buf: [TASK_COMM_LEN]u8 = undefined; + const comm_size = comm_file.readAll(&comm_data_buf) catch { + log.err("problems reading from '{s}'", .{comm_path}); + break :linux false; + }; + const comm_data = comm_data_buf[0..comm_size]; + + break :linux std.mem.eql( + u8, + std.mem.trimRight(u8, comm_data, "\n"), + "systemd", + ); + }, + + // No other system supports systemd so always return false. + else => false, + }; +} diff --git a/src/pty.zig b/src/pty.zig index a36de9adc..02906b778 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -99,6 +99,10 @@ const PosixPty = struct { @cInclude("sys/ioctl.h"); // ioctl and constants @cInclude("util.h"); // openpty() }), + .freebsd => @cImport({ + @cInclude("termios.h"); // ioctl and constants + @cInclude("libutil.h"); // openpty() + }), else => @cImport({ @cInclude("sys/ioctl.h"); // ioctl and constants @cInclude("pty.h"); diff --git a/src/renderer.zig b/src/renderer.zig index 61d9a4e53..e3ed070b6 100644 --- a/src/renderer.zig +++ b/src/renderer.zig @@ -16,6 +16,7 @@ const cursor = @import("renderer/cursor.zig"); const message = @import("renderer/message.zig"); const size = @import("renderer/size.zig"); pub const shadertoy = @import("renderer/shadertoy.zig"); +pub const GenericRenderer = @import("renderer/generic.zig").Renderer; pub const Metal = @import("renderer/Metal.zig"); pub const OpenGL = @import("renderer/OpenGL.zig"); pub const WebGL = @import("renderer/WebGL.zig"); @@ -56,8 +57,8 @@ pub const Impl = enum { /// The implementation to use for the renderer. This is comptime chosen /// so that every build has exactly one renderer implementation. pub const Renderer = switch (build_config.renderer) { - .metal => Metal, - .opengl => OpenGL, + .metal => GenericRenderer(Metal), + .opengl => GenericRenderer(OpenGL), .webgl => WebGL, }; diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 99dbc838e..3899bb8c5 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1,547 +1,85 @@ -//! Renderer implementation for Metal. -//! -//! Open questions: -//! +//! Graphics API wrapper for Metal. pub const Metal = @This(); const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const glfw = @import("glfw"); const objc = @import("objc"); const macos = @import("macos"); -const imgui = @import("imgui"); -const glslang = @import("glslang"); -const xev = @import("../global.zig").xev; -const apprt = @import("../apprt.zig"); -const configpkg = @import("../config.zig"); -const font = @import("../font/main.zig"); -const os = @import("../os/main.zig"); -const terminal = @import("../terminal/main.zig"); -const renderer = @import("../renderer.zig"); -const math = @import("../math.zig"); -const Surface = @import("../Surface.zig"); -const link = @import("link.zig"); const graphics = macos.graphics; -const fgMode = @import("cell.zig").fgMode; -const isCovering = @import("cell.zig").isCovering; +const apprt = @import("../apprt.zig"); +const font = @import("../font/main.zig"); +const configpkg = @import("../config.zig"); +const rendererpkg = @import("../renderer.zig"); +const Renderer = rendererpkg.GenericRenderer(Metal); const shadertoy = @import("shadertoy.zig"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; -const CFReleaseThread = os.CFReleaseThread; -const Terminal = terminal.Terminal; -const Health = renderer.Health; const mtl = @import("metal/api.zig"); -const mtl_buffer = @import("metal/buffer.zig"); -const mtl_cell = @import("metal/cell.zig"); -const mtl_image = @import("metal/image.zig"); -const mtl_sampler = @import("metal/sampler.zig"); -const mtl_shaders = @import("metal/shaders.zig"); -const Image = mtl_image.Image; -const ImageMap = mtl_image.ImageMap; -const Shaders = mtl_shaders.Shaders; +const IOSurfaceLayer = @import("metal/IOSurfaceLayer.zig"); -const ImageBuffer = mtl_buffer.Buffer(mtl_shaders.Image); -const InstanceBuffer = mtl_buffer.Buffer(u16); +pub const GraphicsAPI = Metal; +pub const Target = @import("metal/Target.zig"); +pub const Frame = @import("metal/Frame.zig"); +pub const RenderPass = @import("metal/RenderPass.zig"); +pub const Pipeline = @import("metal/Pipeline.zig"); +const bufferpkg = @import("metal/buffer.zig"); +pub const Buffer = bufferpkg.Buffer; +pub const Texture = @import("metal/Texture.zig"); +pub const shaders = @import("metal/shaders.zig"); -const ImagePlacementList = std.ArrayListUnmanaged(mtl_image.Placement); +pub const custom_shader_target: shadertoy.Target = .msl; +// The fragCoord for Metal shaders is +Y = down. +pub const custom_shader_y_is_down = true; -const DisplayLink = switch (builtin.os.tag) { - .macos => *macos.video.DisplayLink, - else => void, -}; +/// Triple buffering. +pub const swap_chain_count = 3; + +const log = std.log.scoped(.metal); // Get native API access on certain platforms so we can do more customization. const glfwNative = glfw.Native(.{ .cocoa = builtin.os.tag == .macos, }); -const log = std.log.scoped(.metal); +layer: IOSurfaceLayer, -/// Allocator that can be used -alloc: std.mem.Allocator, +/// MTLDevice +device: objc.Object, +/// MTLCommandQueue +queue: objc.Object, -/// The configuration we need derived from the main config. -config: DerivedConfig, +/// Alpha blending mode +blending: configpkg.Config.AlphaBlending, -/// The mailbox for communicating with the window. -surface_mailbox: apprt.surface.Mailbox, - -/// Current font metrics defining our grid. -grid_metrics: font.Metrics, - -/// The size of everything. -size: renderer.Size, - -/// True if the window is focused -focused: bool, - -/// The foreground color set by an OSC 10 sequence. If unset then -/// default_foreground_color is used. -foreground_color: ?terminal.color.RGB, - -/// Foreground color set in the user's config file. -default_foreground_color: terminal.color.RGB, - -/// The background color set by an OSC 11 sequence. If unset then -/// default_background_color is used. -background_color: ?terminal.color.RGB, - -/// Background color set in the user's config file. -default_background_color: terminal.color.RGB, - -/// The cursor color set by an OSC 12 sequence. If unset then -/// default_cursor_color is used. -cursor_color: ?terminal.color.RGB, - -/// Default cursor color when no color is set explicitly by an OSC 12 command. -/// This is cursor color as set in the user's config, if any. If no cursor color -/// is set in the user's config, then the cursor color is determined by the -/// current foreground color. -default_cursor_color: ?terminal.color.RGB, - -/// When `cursor_color` is null, swap the foreground and background colors of -/// the cell under the cursor for the cursor color. Otherwise, use the default -/// foreground color as the cursor color. -cursor_invert: bool, - -/// The current set of cells to render. This is rebuilt on every frame -/// but we keep this around so that we don't reallocate. Each set of -/// cells goes into a separate shader. -cells: mtl_cell.Contents, - -/// The last viewport that we based our rebuild off of. If this changes, -/// then we do a full rebuild of the cells. The pointer values in this pin -/// are NOT SAFE to read because they may be modified, freed, etc from the -/// termio thread. We treat the pointers as integers for comparison only. -cells_viewport: ?terminal.Pin = null, - -/// Set to true after rebuildCells is called. This can be used -/// to determine if any possible changes have been made to the -/// cells for the draw call. -cells_rebuilt: bool = false, - -/// The current GPU uniform values. -uniforms: mtl_shaders.Uniforms, - -/// The font structures. -font_grid: *font.SharedGrid, -font_shaper: font.Shaper, -font_shaper_cache: font.ShaperCache, - -/// The images that we may render. -images: ImageMap = .{}, -image_placements: ImagePlacementList = .{}, -image_bg_end: u32 = 0, -image_text_end: u32 = 0, -image_virtual: bool = false, - -/// Metal state -shaders: Shaders, // Compiled shaders - -/// Metal objects -layer: objc.Object, // CAMetalLayer - -/// The CVDisplayLink used to drive the rendering loop in sync -/// with the display. This is void on platforms that don't support -/// a display link. -display_link: ?DisplayLink = null, - -/// The `CGColorSpace` that represents our current terminal color space -terminal_colorspace: *graphics.ColorSpace, - -/// Custom shader state. This is only set if we have custom shaders. -custom_shader_state: ?CustomShaderState = null, - -/// Health of the last frame. Note that when we do double/triple buffering -/// this will have to be part of the frame state. -health: std.atomic.Value(Health) = .{ .raw = .healthy }, - -/// Our GPU state -gpu_state: GPUState, - -/// State we need for the GPU that is shared between all frames. -pub const GPUState = struct { - // The count of buffers we use for double/triple buffering. If - // this is one then we don't do any double+ buffering at all. This - // is comptime because there isn't a good reason to change this at - // runtime and there is a lot of complexity to support it. For comptime, - // this is useful for debugging. - const BufferCount = 3; - - /// The frame data, the current frame index, and the semaphore protecting - /// the frame data. This is used to implement double/triple/etc. buffering. - frames: [BufferCount]FrameState, - frame_index: std.math.IntFittingRange(0, BufferCount) = 0, - frame_sema: std.Thread.Semaphore = .{ .permits = BufferCount }, - - device: objc.Object, // MTLDevice - queue: objc.Object, // MTLCommandQueue - - /// This buffer is written exactly once so we can use it globally. - instance: InstanceBuffer, // MTLBuffer - - /// The default storage mode to use for resources created with our device. - /// - /// This is based on whether the device is a discrete GPU or not, since - /// discrete GPUs do not have unified memory and therefore do not support - /// the "shared" storage mode, instead we have to use the "managed" mode. - default_storage_mode: mtl.MTLResourceOptions.StorageMode, - - pub fn init() !GPUState { - const device = try chooseDevice(); - const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{}); - errdefer queue.release(); - - // We determine whether our device is a discrete GPU based on these: - // - We're on macOS (iOS, iPadOS, etc. are guaranteed to be integrated). - // - We're not on aarch64 (Apple Silicon, therefore integrated). - // - The device reports that it does not have unified memory. - const is_discrete = - builtin.target.os.tag == .macos and - builtin.target.cpu.arch != .aarch64 and - !device.getProperty(bool, "hasUnifiedMemory"); - - const default_storage_mode: mtl.MTLResourceOptions.StorageMode = - if (is_discrete) .managed else .shared; - - var instance = try InstanceBuffer.initFill(device, &.{ - 0, 1, 3, // Top-left triangle - 1, 2, 3, // Bottom-right triangle - }, .{ .storage_mode = default_storage_mode }); - errdefer instance.deinit(); - - var result: GPUState = .{ - .device = device, - .queue = queue, - .instance = instance, - .frames = undefined, - .default_storage_mode = default_storage_mode, - }; - - // Initialize all of our frame state. - for (&result.frames) |*frame| { - frame.* = try FrameState.init(result.device, default_storage_mode); - } - - return result; - } - - fn chooseDevice() error{NoMetalDevice}!objc.Object { - var chosen_device: ?objc.Object = null; - - switch (comptime builtin.os.tag) { - .macos => { - const devices = objc.Object.fromId(mtl.MTLCopyAllDevices()); - defer devices.release(); - - var iter = devices.iterate(); - while (iter.next()) |device| { - // We want a GPU that’s connected to a display. - if (device.getProperty(bool, "isHeadless")) continue; - chosen_device = device; - // If the user has an eGPU plugged in, they probably want - // to use it. Otherwise, integrated GPUs are better for - // battery life and thermals. - if (device.getProperty(bool, "isRemovable") or - device.getProperty(bool, "isLowPower")) break; - } - }, - .ios => { - chosen_device = objc.Object.fromId(mtl.MTLCreateSystemDefaultDevice()); - }, - else => @compileError("unsupported target for Metal"), - } - - const device = chosen_device orelse return error.NoMetalDevice; - return device.retain(); - } - - pub fn deinit(self: *GPUState) void { - // Wait for all of our inflight draws to complete so that - // we can cleanly deinit our GPU state. - for (0..BufferCount) |_| self.frame_sema.wait(); - for (&self.frames) |*frame| frame.deinit(); - self.instance.deinit(); - self.queue.release(); - self.device.release(); - } - - /// Get the next frame state to draw to. This will wait on the - /// semaphore to ensure that the frame is available. This must - /// always be paired with a call to releaseFrame. - pub fn nextFrame(self: *GPUState) *FrameState { - self.frame_sema.wait(); - errdefer self.frame_sema.post(); - self.frame_index = (self.frame_index + 1) % BufferCount; - return &self.frames[self.frame_index]; - } - - /// This should be called when the frame has completed drawing. - pub fn releaseFrame(self: *GPUState) void { - self.frame_sema.post(); - } -}; - -/// State we need duplicated for every frame. Any state that could be -/// in a data race between the GPU and CPU while a frame is being -/// drawn should be in this struct. +/// The default storage mode to use for resources created with our device. /// -/// While a draw is in-process, we "lock" the state (via a semaphore) -/// and prevent the CPU from updating the state until Metal reports -/// that the frame is complete. -/// -/// This is used to implement double/triple buffering. -pub const FrameState = struct { - uniforms: UniformBuffer, - cells: CellTextBuffer, - cells_bg: CellBgBuffer, +/// This is based on whether the device is a discrete GPU or not, since +/// discrete GPUs do not have unified memory and therefore do not support +/// the "shared" storage mode, instead we have to use the "managed" mode. +default_storage_mode: mtl.MTLResourceOptions.StorageMode, - grayscale: objc.Object, // MTLTexture - grayscale_modified: usize = 0, - color: objc.Object, // MTLTexture - color_modified: usize = 0, +/// We start an AutoreleasePool before `drawFrame` and end it afterwards. +autorelease_pool: ?*objc.AutoreleasePool = null, - /// A buffer containing the uniform data. - const UniformBuffer = mtl_buffer.Buffer(mtl_shaders.Uniforms); - const CellBgBuffer = mtl_buffer.Buffer(mtl_shaders.CellBg); - const CellTextBuffer = mtl_buffer.Buffer(mtl_shaders.CellText); - - pub fn init( - device: objc.Object, - /// Storage mode for buffers and textures. - storage_mode: mtl.MTLResourceOptions.StorageMode, - ) !FrameState { - // Uniform buffer contains exactly 1 uniform struct. The - // uniform data will be undefined so this must be set before - // a frame is drawn. - var uniforms = try UniformBuffer.init( - device, - 1, - .{ - // Indicate that the CPU writes to this resource but never reads it. - .cpu_cache_mode = .write_combined, - .storage_mode = storage_mode, - }, - ); - errdefer uniforms.deinit(); - - // Create the buffers for our vertex data. The preallocation size - // is likely too small but our first frame update will resize it. - var cells = try CellTextBuffer.init( - device, - 10 * 10, - .{ - // Indicate that the CPU writes to this resource but never reads it. - .cpu_cache_mode = .write_combined, - .storage_mode = storage_mode, - }, - ); - errdefer cells.deinit(); - var cells_bg = try CellBgBuffer.init( - device, - 10 * 10, - .{ - // Indicate that the CPU writes to this resource but never reads it. - .cpu_cache_mode = .write_combined, - .storage_mode = storage_mode, - }, - ); - - errdefer cells_bg.deinit(); - - // Initialize our textures for our font atlas. - const grayscale = try initAtlasTexture(device, &.{ - .data = undefined, - .size = 8, - .format = .grayscale, - }, storage_mode); - errdefer grayscale.release(); - const color = try initAtlasTexture(device, &.{ - .data = undefined, - .size = 8, - .format = .rgba, - }, storage_mode); - errdefer color.release(); - - return .{ - .uniforms = uniforms, - .cells = cells, - .cells_bg = cells_bg, - .grayscale = grayscale, - .color = color, - }; - } - - pub fn deinit(self: *FrameState) void { - self.uniforms.deinit(); - self.cells.deinit(); - self.cells_bg.deinit(); - self.grayscale.release(); - self.color.release(); - } -}; - -pub const CustomShaderState = struct { - /// When we have a custom shader state, we maintain a front - /// and back texture which we use as a swap chain to render - /// between when multiple custom shaders are defined. - front_texture: objc.Object, // MTLTexture - back_texture: objc.Object, // MTLTexture - - sampler: mtl_sampler.Sampler, - uniforms: mtl_shaders.PostUniforms, - - /// The first time a frame was drawn. - /// This is used to update the time uniform. - first_frame_time: std.time.Instant, - - /// The last time a frame was drawn. - /// This is used to update the time uniform. - last_frame_time: std.time.Instant, - - /// Swap the front and back textures. - pub fn swap(self: *CustomShaderState) void { - std.mem.swap(objc.Object, &self.front_texture, &self.back_texture); - } - - pub fn deinit(self: *CustomShaderState) void { - self.front_texture.release(); - self.back_texture.release(); - self.sampler.deinit(); - } -}; - -/// The configuration for this renderer that is derived from the main -/// configuration. This must be exported so that we don't need to -/// pass around Config pointers which makes memory management a pain. -pub const DerivedConfig = struct { - arena: ArenaAllocator, - - font_thicken: bool, - font_thicken_strength: u8, - font_features: std.ArrayListUnmanaged([:0]const u8), - font_styles: font.CodepointResolver.StyleStatus, - cursor_color: ?terminal.color.RGB, - cursor_invert: bool, - cursor_opacity: f64, - cursor_text: ?terminal.color.RGB, - background: terminal.color.RGB, - background_opacity: f64, - foreground: terminal.color.RGB, - selection_background: ?terminal.color.RGB, - selection_foreground: ?terminal.color.RGB, - invert_selection_fg_bg: bool, - bold_is_bright: bool, - min_contrast: f32, - padding_color: configpkg.WindowPaddingColor, - custom_shaders: configpkg.RepeatablePath, - links: link.Set, - vsync: bool, - colorspace: configpkg.Config.WindowColorspace, - blending: configpkg.Config.AlphaBlending, - - pub fn init( - alloc_gpa: Allocator, - config: *const configpkg.Config, - ) !DerivedConfig { - var arena = ArenaAllocator.init(alloc_gpa); - errdefer arena.deinit(); - const alloc = arena.allocator(); - - // Copy our shaders - const custom_shaders = try config.@"custom-shader".clone(alloc); - - // Copy our font features - const font_features = try config.@"font-feature".clone(alloc); - - // Get our font styles - var font_styles = font.CodepointResolver.StyleStatus.initFill(true); - font_styles.set(.bold, config.@"font-style-bold" != .false); - font_styles.set(.italic, config.@"font-style-italic" != .false); - font_styles.set(.bold_italic, config.@"font-style-bold-italic" != .false); - - // Our link configs - const links = try link.Set.fromConfig( - alloc, - config.link.links.items, - ); - - const cursor_invert = config.@"cursor-invert-fg-bg"; - - return .{ - .background_opacity = @max(0, @min(1, config.@"background-opacity")), - .font_thicken = config.@"font-thicken", - .font_thicken_strength = config.@"font-thicken-strength", - .font_features = font_features.list, - .font_styles = font_styles, - - .cursor_color = if (!cursor_invert and config.@"cursor-color" != null) - config.@"cursor-color".?.toTerminalRGB() - else - null, - - .cursor_invert = cursor_invert, - - .cursor_text = if (config.@"cursor-text") |txt| - txt.toTerminalRGB() - else - null, - - .cursor_opacity = @max(0, @min(1, config.@"cursor-opacity")), - - .background = config.background.toTerminalRGB(), - .foreground = config.foreground.toTerminalRGB(), - .invert_selection_fg_bg = config.@"selection-invert-fg-bg", - .bold_is_bright = config.@"bold-is-bright", - .min_contrast = @floatCast(config.@"minimum-contrast"), - .padding_color = config.@"window-padding-color", - - .selection_background = if (config.@"selection-background") |bg| - bg.toTerminalRGB() - else - null, - - .selection_foreground = if (config.@"selection-foreground") |bg| - bg.toTerminalRGB() - else - null, - - .custom_shaders = custom_shaders, - .links = links, - .vsync = config.@"window-vsync", - .colorspace = config.@"window-colorspace", - .blending = config.@"alpha-blending", - .arena = arena, - }; - } - - pub fn deinit(self: *DerivedConfig) void { - const alloc = self.arena.allocator(); - self.links.deinit(alloc); - self.arena.deinit(); - } -}; - -/// Returns the hints that we want for this -pub fn glfwWindowHints(config: *const configpkg.Config) glfw.Window.Hints { - return .{ - .client_api = .no_api, - .transparent_framebuffer = config.@"background-opacity" < 1, +pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal { + comptime switch (builtin.os.tag) { + .macos, .ios => {}, + else => @compileError("unsupported platform for Metal"), }; -} -/// This is called early right after window creation to setup our -/// window surface as necessary. -pub fn surfaceInit(surface: *apprt.Surface) !void { - _ = surface; + _ = alloc; - // We don't do anything else here because we want to set everything - // else up during actual initialization. -} + // Choose our MTLDevice and create a MTLCommandQueue for that device. + const device = try chooseDevice(); + errdefer device.release(); + const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{}); + errdefer queue.release(); + + const default_storage_mode: mtl.MTLResourceOptions.StorageMode = + if (device.getProperty(bool, "hasUnifiedMemory")) .shared else .managed; -pub fn init(alloc: Allocator, options: renderer.Options) !Metal { const ViewInfo = struct { view: objc.Object, scaleFactor: f64, @@ -553,7 +91,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { // Everything in glfw is window-oriented so we grab the backing // window, then derive everything from that. const nswindow = objc.Object.fromId(glfwNative.getCocoaWindow( - options.rt_surface.window, + opts.rt_surface.window, ).?); const contentView = objc.Object.fromId( @@ -571,8 +109,8 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { }, apprt.embedded => .{ - .scaleFactor = @floatCast(options.rt_surface.content_scale.x), - .view = switch (options.rt_surface.platform) { + .scaleFactor = @floatCast(opts.rt_surface.content_scale.x), + .view = switch (opts.rt_surface.platform) { .macos => |v| v.nsview, .ios => |v| v.uiview, }, @@ -581,2768 +119,293 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { else => @compileError("unsupported apprt for metal"), }; - // Initialize our metal stuff - var gpu_state = try GPUState.init(); - errdefer gpu_state.deinit(); + // Create an IOSurfaceLayer which we can assign to the view to make + // it in to a "layer-hosting view", so that we can manually control + // the layer contents. + var layer = try IOSurfaceLayer.init(); + errdefer layer.release(); - // Get our CAMetalLayer - const layer: objc.Object = switch (builtin.os.tag) { - .macos => layer: { - const CAMetalLayer = objc.getClass("CAMetalLayer").?; - break :layer CAMetalLayer.msgSend(objc.Object, objc.sel("layer"), .{}); + // Add our layer to the view. + // + // On macOS we do this by making the view "layer-hosting" + // by assigning it to the view's `layer` property BEFORE + // setting `wantsLayer` to `true`. + // + // On iOS, views are always layer-backed, and `layer` + // is readonly, so instead we add it as a sublayer. + switch (comptime builtin.os.tag) { + .macos => { + info.view.setProperty("layer", layer.layer.value); + info.view.setProperty("wantsLayer", true); }, - // iOS is always layer-backed so we don't need to do anything here. - .ios => info.view.getProperty(objc.Object, "layer"), + .ios => { + info.view.msgSend(void, objc.sel("addSublayer"), .{layer.layer.value}); + }, else => @compileError("unsupported target for Metal"), - }; - layer.setProperty("device", gpu_state.device.value); - layer.setProperty("opaque", options.config.background_opacity >= 1); - layer.setProperty("displaySyncEnabled", options.config.vsync); - - // Set our layer's pixel format appropriately. - layer.setProperty( - "pixelFormat", - // Using an `*_srgb` pixel format makes Metal gamma encode - // the pixels written to it *after* blending, which means - // we get linear alpha blending rather than gamma-incorrect - // blending. - if (options.config.blending.isLinear()) - @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) - else - @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), - ); - - // Set our layer's color space to Display P3. - // This allows us to have "Apple-style" alpha blending, - // since it seems to be the case that Apple apps like - // Terminal and TextEdit render text in the display's - // color space using converted colors, which reduces, - // but does not fully eliminate blending artifacts. - const colorspace = try graphics.ColorSpace.createNamed(.displayP3); - defer colorspace.release(); - layer.setProperty("colorspace", colorspace); - - // Create a colorspace the represents our terminal colors - // this will allow us to create e.g. `CGColor`s for things - // like the current background color. - const terminal_colorspace = try graphics.ColorSpace.createNamed( - switch (options.config.colorspace) { - .@"display-p3" => .displayP3, - .srgb => .sRGB, - }, - ); - errdefer terminal_colorspace.release(); - - // Make our view layer-backed with our Metal layer. On iOS views are - // always layer backed so we don't need to do this. But on iOS the - // caller MUST be sure to set the layerClass to CAMetalLayer. - if (comptime builtin.os.tag == .macos) { - info.view.setProperty("layer", layer.value); - info.view.setProperty("wantsLayer", true); - - // The layer gravity is set to top-left so that when we resize - // the view, the contents aren't stretched before a redraw. - layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft); } - // Ensure that our metal layer has a content scale set to match the - // scale factor of the window. This avoids magnification issues leading - // to blurry rendering. - layer.setProperty("contentsScale", info.scaleFactor); + // Ensure that if our layer is oversized it + // does not overflow the bounds of the view. + info.view.setProperty("clipsToBounds", true); - // Create the font shaper. We initially create a shaper that can support - // a width of 160 which is a common width for modern screens to help - // avoid allocations later. - var font_shaper = try font.Shaper.init(alloc, .{ - .features = options.config.font_features.items, - }); - errdefer font_shaper.deinit(); + // Ensure that our layer has a content scale set to + // match the scale factor of the window. This avoids + // magnification issues leading to blurry rendering. + layer.layer.setProperty("contentsScale", info.scaleFactor); - // Initialize all the data that requires a critical font section. - const font_critical: struct { - metrics: font.Metrics, - } = font_critical: { - const grid = options.font_grid; - grid.lock.lockShared(); - defer grid.lock.unlockShared(); - break :font_critical .{ - .metrics = grid.metrics, - }; - }; + // This makes it so that our display callback will actually be called. + layer.layer.setProperty("needsDisplayOnBoundsChange", true); - const display_link: ?DisplayLink = switch (builtin.os.tag) { - .macos => if (options.config.vsync) - try macos.video.DisplayLink.createWithActiveCGDisplays() - else - null, - else => null, - }; - errdefer if (display_link) |v| v.release(); - - var result: Metal = .{ - .alloc = alloc, - .config = options.config, - .surface_mailbox = options.surface_mailbox, - .grid_metrics = font_critical.metrics, - .size = options.size, - .focused = true, - .foreground_color = null, - .default_foreground_color = options.config.foreground, - .background_color = null, - .default_background_color = options.config.background, - .cursor_color = null, - .default_cursor_color = options.config.cursor_color, - .cursor_invert = options.config.cursor_invert, - - // Render state - .cells = .{}, - .uniforms = .{ - .projection_matrix = undefined, - .cell_size = undefined, - .grid_size = undefined, - .grid_padding = undefined, - .padding_extend = .{}, - .min_contrast = options.config.min_contrast, - .cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16) }, - .cursor_color = undefined, - .bg_color = .{ - options.config.background.r, - options.config.background.g, - options.config.background.b, - @intFromFloat(@round(options.config.background_opacity * 255.0)), - }, - .cursor_wide = false, - .use_display_p3 = options.config.colorspace == .@"display-p3", - .use_linear_blending = options.config.blending.isLinear(), - .use_linear_correction = options.config.blending == .@"linear-corrected", - }, - - // Fonts - .font_grid = options.font_grid, - .font_shaper = font_shaper, - .font_shaper_cache = font.ShaperCache.init(), - - // Shaders (initialized below) - .shaders = undefined, - - // Metal stuff + return .{ .layer = layer, - .display_link = display_link, - .terminal_colorspace = terminal_colorspace, - .custom_shader_state = null, - .gpu_state = gpu_state, + .device = device, + .queue = queue, + .blending = opts.config.blending, + .default_storage_mode = default_storage_mode, }; - - try result.initShaders(); - - // Do an initialize screen size setup to ensure our undefined values - // above are initialized. - try result.setScreenSize(result.size); - - return result; } pub fn deinit(self: *Metal) void { - self.gpu_state.deinit(); - - if (DisplayLink != void) { - if (self.display_link) |display_link| { - display_link.stop() catch {}; - display_link.release(); - } - } - - self.terminal_colorspace.release(); - - self.cells.deinit(self.alloc); - - self.font_shaper.deinit(); - self.font_shaper_cache.deinit(self.alloc); - - self.config.deinit(); - - { - var it = self.images.iterator(); - while (it.next()) |kv| kv.value_ptr.image.deinit(self.alloc); - self.images.deinit(self.alloc); - } - self.image_placements.deinit(self.alloc); - - self.deinitShaders(); - - self.* = undefined; + self.queue.release(); + self.device.release(); + self.layer.release(); } -fn deinitShaders(self: *Metal) void { - if (self.custom_shader_state) |*state| state.deinit(); - - self.shaders.deinit(self.alloc); +pub fn loopEnter(self: *Metal) void { + const renderer: *align(1) Renderer = @fieldParentPtr("api", self); + self.layer.setDisplayCallback( + @ptrCast(&displayCallback), + @ptrCast(renderer), + ); } -fn initShaders(self: *Metal) !void { - var arena = ArenaAllocator.init(self.alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); - - // Load our custom shaders - const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles( - arena_alloc, - self.config.custom_shaders, - .msl, - ) catch |err| err: { - log.warn("error loading custom shaders err={}", .{err}); - break :err &.{}; +fn displayCallback(renderer: *Renderer) align(8) void { + renderer.drawFrame(true) catch |err| { + log.warn("Error drawing frame in display callback, err={}", .{err}); }; +} - var custom_shader_state: ?CustomShaderState = state: { - if (custom_shaders.len == 0) break :state null; +/// Actions taken before doing anything in `drawFrame`. +/// +/// Right now we use this to start an AutoreleasePool. +pub fn drawFrameStart(self: *Metal) void { + assert(self.autorelease_pool == null); + self.autorelease_pool = .init(); +} - // Build our sampler for our texture - var sampler = try mtl_sampler.Sampler.init(self.gpu_state.device); - errdefer sampler.deinit(); +/// Actions taken after `drawFrame` is done. +/// +/// Right now we use this to end our AutoreleasePool. +pub fn drawFrameEnd(self: *Metal) void { + assert(self.autorelease_pool != null); + self.autorelease_pool.?.deinit(); + self.autorelease_pool = null; +} - break :state .{ - // Resolution and screen textures will be fixed up by first - // call to setScreenSize. Draw calls will bail out early if - // the screen size hasn't been set yet, so it won't error. - .front_texture = undefined, - .back_texture = undefined, - .sampler = sampler, - .uniforms = .{ - .resolution = .{ 0, 0, 1 }, - .time = 1, - .time_delta = 1, - .frame_rate = 1, - .frame = 1, - .channel_time = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4, - .channel_resolution = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4, - .mouse = .{ 0, 0, 0, 0 }, - .date = .{ 0, 0, 0, 0 }, - .sample_rate = 1, - }, - - .first_frame_time = try std.time.Instant.now(), - .last_frame_time = try std.time.Instant.now(), - }; - }; - errdefer if (custom_shader_state) |*state| state.deinit(); - - var shaders = try Shaders.init( - self.alloc, - self.gpu_state.device, +pub fn initShaders( + self: *const Metal, + alloc: Allocator, + custom_shaders: []const [:0]const u8, +) !shaders.Shaders { + return try shaders.Shaders.init( + alloc, + self.device, custom_shaders, // Using an `*_srgb` pixel format makes Metal gamma encode // the pixels written to it *after* blending, which means // we get linear alpha blending rather than gamma-incorrect // blending. - if (self.config.blending.isLinear()) + if (self.blending.isLinear()) mtl.MTLPixelFormat.bgra8unorm_srgb else mtl.MTLPixelFormat.bgra8unorm, ); - errdefer shaders.deinit(self.alloc); - - self.shaders = shaders; - self.custom_shader_state = custom_shader_state; } -/// This is called just prior to spinning up the renderer thread for -/// final main thread setup requirements. -pub fn finalizeSurfaceInit(self: *Metal, surface: *apprt.Surface) !void { - _ = self; - _ = surface; - - // Metal doesn't have to do anything here. OpenGL has to do things - // like release the context but Metal doesn't have anything like that. -} - -/// Callback called by renderer.Thread when it begins. -pub fn threadEnter(self: *const Metal, surface: *apprt.Surface) !void { - _ = self; - _ = surface; - - // Metal requires no per-thread state. -} - -/// Callback called by renderer.Thread when it exits. -pub fn threadExit(self: *const Metal) void { - _ = self; - - // Metal requires no per-thread state. -} - -/// Called by renderer.Thread when it starts the main loop. -pub fn loopEnter(self: *Metal, thr: *renderer.Thread) !void { - // If we don't support a display link we have no work to do. - if (comptime DisplayLink == void) return; - - // This is when we know our "self" pointer is stable so we can - // setup the display link. To setup the display link we set our - // callback and we can start it immediately. - const display_link = self.display_link orelse return; - try display_link.setOutputCallback( - xev.Async, - &displayLinkCallback, - &thr.draw_now, - ); - display_link.start() catch {}; -} - -/// Called by renderer.Thread when it exits the main loop. -pub fn loopExit(self: *Metal) void { - // If we don't support a display link we have no work to do. - if (comptime DisplayLink == void) return; - - // Stop our display link. If this fails its okay it just means - // that we either never started it or the view its attached to - // is gone which is fine. - const display_link = self.display_link orelse return; - display_link.stop() catch {}; -} - -fn displayLinkCallback( - _: *macos.video.DisplayLink, - ud: ?*xev.Async, -) void { - const draw_now = ud orelse return; - draw_now.notify() catch |err| { - log.err("error notifying draw_now err={}", .{err}); +/// Get the current size of the runtime surface. +pub fn surfaceSize(self: *const Metal) !struct { width: u32, height: u32 } { + const bounds = self.layer.layer.getProperty(graphics.Rect, "bounds"); + const scale = self.layer.layer.getProperty(f64, "contentsScale"); + return .{ + .width = @intFromFloat(bounds.size.width * scale), + .height = @intFromFloat(bounds.size.height * scale), }; } -/// Mark the full screen as dirty so that we redraw everything. -pub fn markDirty(self: *Metal) void { - // This is how we force a full rebuild with metal. - self.cells_viewport = null; -} - -/// Called when we get an updated display ID for our display link. -pub fn setMacOSDisplayID(self: *Metal, id: u32) !void { - if (comptime DisplayLink == void) return; - const display_link = self.display_link orelse return; - log.info("updating display link display id={}", .{id}); - display_link.setCurrentCGDisplay(id) catch |err| { - log.warn("error setting display link display id err={}", .{err}); - }; -} - -/// True if our renderer has animations so that a higher frequency -/// timer is used. -pub fn hasAnimations(self: *const Metal) bool { - return self.custom_shader_state != null; -} - -/// True if our renderer is using vsync. If true, the renderer or apprt -/// is responsible for triggering draw_now calls to the render thread. That -/// is the only way to trigger a drawFrame. -pub fn hasVsync(self: *const Metal) bool { - if (comptime DisplayLink == void) return false; - const display_link = self.display_link orelse return false; - return display_link.isRunning(); -} - -/// Callback when the focus changes for the terminal this is rendering. -/// -/// Must be called on the render thread. -pub fn setFocus(self: *Metal, focus: bool) !void { - self.focused = focus; - - // If we're not focused, then we want to stop the display link - // because it is a waste of resources and we can move to pure - // change-driven updates. - if (comptime DisplayLink != void) link: { - const display_link = self.display_link orelse break :link; - if (focus) { - display_link.start() catch {}; - } else { - display_link.stop() catch {}; - } - } -} - -/// Callback when the window is visible or occluded. -/// -/// Must be called on the render thread. -pub fn setVisible(self: *Metal, visible: bool) void { - // If we're not visible, then we want to stop the display link - // because it is a waste of resources and we can move to pure - // change-driven updates. - if (comptime DisplayLink != void) link: { - const display_link = self.display_link orelse break :link; - if (visible and self.focused) { - display_link.start() catch {}; - } else { - display_link.stop() catch {}; - } - } -} - -/// Set the new font grid. -/// -/// Must be called on the render thread. -pub fn setFontGrid(self: *Metal, grid: *font.SharedGrid) void { - // Update our grid - self.font_grid = grid; - - // Update all our textures so that they sync on the next frame. - // We can modify this without a lock because the GPU does not - // touch this data. - for (&self.gpu_state.frames) |*frame| { - frame.grayscale_modified = 0; - frame.color_modified = 0; - } - - // Get our metrics from the grid. This doesn't require a lock because - // the metrics are never recalculated. - const metrics = grid.metrics; - self.grid_metrics = metrics; - - // Reset our shaper cache. If our font changed (not just the size) then - // the data in the shaper cache may be invalid and cannot be used, so we - // always clear the cache just in case. - const font_shaper_cache = font.ShaperCache.init(); - self.font_shaper_cache.deinit(self.alloc); - self.font_shaper_cache = font_shaper_cache; - - // Run a screen size update since this handles a lot of our uniforms - // that are grid size dependent and changing the font grid can change - // the grid size. - // - // If the screen size isn't set, it will be eventually so that'll call - // the setScreenSize automatically. - self.setScreenSize(self.size) catch |err| { - // The setFontGrid function can't fail but resizing our cell - // buffer definitely can fail. If it does, our renderer is probably - // screwed but let's just log it and continue until we can figure - // out a better way to handle this. - log.err("error resizing cells buffer err={}", .{err}); - }; - - // Reset our viewport to force a rebuild, since `setScreenSize` only - // does this when the number of cells changes, which isn't guaranteed. - self.cells_viewport = null; -} - -/// Update the frame data. -pub fn updateFrame( - self: *Metal, - surface: *apprt.Surface, - state: *renderer.State, - cursor_blink_visible: bool, -) !void { - _ = surface; - - // Data we extract out of the critical area. - const Critical = struct { - bg: terminal.color.RGB, - screen: terminal.Screen, - screen_type: terminal.ScreenType, - mouse: renderer.State.Mouse, - preedit: ?renderer.State.Preedit, - cursor_style: ?renderer.CursorStyle, - color_palette: terminal.color.Palette, - viewport_pin: terminal.Pin, - - /// If true, rebuild the full screen. - full_rebuild: bool, - }; - - // Update all our data as tightly as possible within the mutex. - var critical: Critical = critical: { - // const start = try std.time.Instant.now(); - // const start_micro = std.time.microTimestamp(); - // defer { - // const end = std.time.Instant.now() catch unreachable; - // // "[updateFrame critical time] \t" - // std.log.err("[updateFrame critical time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us}); - // } - - state.mutex.lock(); - defer state.mutex.unlock(); - - // If we're in a synchronized output state, we pause all rendering. - if (state.terminal.modes.get(.synchronized_output)) { - log.debug("synchronized output started, skipping render", .{}); - return; - } - - // Swap bg/fg if the terminal is reversed - const bg = self.background_color orelse self.default_background_color; - const fg = self.foreground_color orelse self.default_foreground_color; - defer { - if (self.background_color) |*c| { - c.* = bg; - } else { - self.default_background_color = bg; - } - - if (self.foreground_color) |*c| { - c.* = fg; - } else { - self.default_foreground_color = fg; - } - } - - if (state.terminal.modes.get(.reverse_colors)) { - if (self.background_color) |*c| { - c.* = fg; - } else { - self.default_background_color = fg; - } - - if (self.foreground_color) |*c| { - c.* = bg; - } else { - self.default_foreground_color = bg; - } - } - - // Get the viewport pin so that we can compare it to the current. - const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?; - - // We used to share terminal state, but we've since learned through - // analysis that it is faster to copy the terminal state than to - // hold the lock while rebuilding GPU cells. - var screen_copy = try state.terminal.screen.clone( - self.alloc, - .{ .viewport = .{} }, - null, - ); - errdefer screen_copy.deinit(); - - // Whether to draw our cursor or not. - const cursor_style = if (state.terminal.flags.password_input) - .lock +/// Initialize a new render target which can be presented by this API. +pub fn initTarget(self: *const Metal, width: usize, height: usize) !Target { + return Target.init(.{ + .device = self.device, + // Using an `*_srgb` pixel format makes Metal gamma encode the pixels + // written to it *after* blending, which means we get linear alpha + // blending rather than gamma-incorrect blending. + .pixel_format = if (self.blending.isLinear()) + .bgra8unorm_srgb else - renderer.cursorStyle( - state, - self.focused, - cursor_blink_visible, - ); - - // Get our preedit state - const preedit: ?renderer.State.Preedit = preedit: { - if (cursor_style == null) break :preedit null; - const p = state.preedit orelse break :preedit null; - break :preedit try p.clone(self.alloc); - }; - errdefer if (preedit) |p| p.deinit(self.alloc); - - // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path. - // We only do this if the Kitty image state is dirty meaning only if - // it changes. - // - // If we have any virtual references, we must also rebuild our - // kitty state on every frame because any cell change can move - // an image. - if (state.terminal.screen.kitty_images.dirty or - self.image_virtual) - { - try self.prepKittyGraphics(state.terminal); - } - - // If we have any terminal dirty flags set then we need to rebuild - // the entire screen. This can be optimized in the future. - const full_rebuild: bool = rebuild: { - { - const Int = @typeInfo(terminal.Terminal.Dirty).@"struct".backing_integer.?; - const v: Int = @bitCast(state.terminal.flags.dirty); - if (v > 0) break :rebuild true; - } - { - const Int = @typeInfo(terminal.Screen.Dirty).@"struct".backing_integer.?; - const v: Int = @bitCast(state.terminal.screen.dirty); - if (v > 0) break :rebuild true; - } - - // If our viewport changed then we need to rebuild the entire - // screen because it means we scrolled. If we have no previous - // viewport then we must rebuild. - const prev_viewport = self.cells_viewport orelse break :rebuild true; - if (!prev_viewport.eql(viewport_pin)) break :rebuild true; - - break :rebuild false; - }; - - // Reset the dirty flags in the terminal and screen. We assume - // that our rebuild will be successful since so we optimize for - // success and reset while we hold the lock. This is much easier - // than coordinating row by row or as changes are persisted. - state.terminal.flags.dirty = .{}; - state.terminal.screen.dirty = .{}; - { - var it = state.terminal.screen.pages.pageIterator( - .right_down, - .{ .screen = .{} }, - null, - ); - while (it.next()) |chunk| { - var dirty_set = chunk.node.data.dirtyBitSet(); - dirty_set.unsetAll(); - } - } - - break :critical .{ - .bg = self.background_color orelse self.default_background_color, - .screen = screen_copy, - .screen_type = state.terminal.active_screen, - .mouse = state.mouse, - .preedit = preedit, - .cursor_style = cursor_style, - .color_palette = state.terminal.color_palette.colors, - .viewport_pin = viewport_pin, - .full_rebuild = full_rebuild, - }; - }; - defer { - critical.screen.deinit(); - if (critical.preedit) |p| p.deinit(self.alloc); - } - - // Build our GPU cells - try self.rebuildCells( - critical.full_rebuild, - &critical.screen, - critical.screen_type, - critical.mouse, - critical.preedit, - critical.cursor_style, - &critical.color_palette, - ); - - // Notify our shaper we're done for the frame. For some shapers like - // CoreText this triggers off-thread cleanup logic. - self.font_shaper.endFrame(); - - // Update our viewport pin - self.cells_viewport = critical.viewport_pin; - - // Update our background color - self.uniforms.bg_color = .{ - critical.bg.r, - critical.bg.g, - critical.bg.b, - @intFromFloat(@round(self.config.background_opacity * 255.0)), - }; - - // Update the background color on our layer - // - // TODO: Is this expensive? Should we be checking if our - // bg color has changed first before doing this work? - { - const color = graphics.c.CGColorCreate( - @ptrCast(self.terminal_colorspace), - &[4]f64{ - @as(f64, @floatFromInt(critical.bg.r)) / 255.0, - @as(f64, @floatFromInt(critical.bg.g)) / 255.0, - @as(f64, @floatFromInt(critical.bg.b)) / 255.0, - self.config.background_opacity, - }, - ); - defer graphics.c.CGColorRelease(color); - - // We use a CATransaction so that Core Animation knows that we - // updated the background color property. Otherwise it behaves - // weird, not updating the color until we resize. - const CATransaction = objc.getClass("CATransaction").?; - CATransaction.msgSend(void, "begin", .{}); - defer CATransaction.msgSend(void, "commit", .{}); - - self.layer.setProperty("backgroundColor", color); - } - - // Go through our images and see if we need to setup any textures. - { - var image_it = self.images.iterator(); - while (image_it.next()) |kv| { - switch (kv.value_ptr.image) { - .ready => {}, - - .pending_gray, - .pending_gray_alpha, - .pending_rgb, - .pending_rgba, - .replace_gray, - .replace_gray_alpha, - .replace_rgb, - .replace_rgba, - => try kv.value_ptr.image.upload( - self.alloc, - self.gpu_state.device, - self.gpu_state.default_storage_mode, - ), - - .unload_pending, - .unload_replace, - .unload_ready, - => { - kv.value_ptr.image.deinit(self.alloc); - self.images.removeByPtr(kv.key_ptr); - }, - } - } - } -} - -/// Draw the frame to the screen. -pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void { - _ = surface; - - // If we have no cells rebuilt we can usually skip drawing since there - // is no changed data. However, if we have active animations we still - // need to draw so that we can update the time uniform and render the - // changes. - if (!self.cells_rebuilt and !self.hasAnimations()) return; - self.cells_rebuilt = false; - - // Wait for a frame to be available. - const frame = self.gpu_state.nextFrame(); - errdefer self.gpu_state.releaseFrame(); - // log.debug("drawing frame index={}", .{self.gpu_state.frame_index}); - - // Setup our frame data - try frame.uniforms.sync(self.gpu_state.device, &.{self.uniforms}); - try frame.cells_bg.sync(self.gpu_state.device, self.cells.bg_cells); - const fg_count = try frame.cells.syncFromArrayLists(self.gpu_state.device, self.cells.fg_rows.lists); - - // If we have custom shaders, update the animation time. - if (self.custom_shader_state) |*state| { - const now = std.time.Instant.now() catch state.first_frame_time; - const since_ns: f32 = @floatFromInt(now.since(state.first_frame_time)); - const delta_ns: f32 = @floatFromInt(now.since(state.last_frame_time)); - state.uniforms.time = since_ns / std.time.ns_per_s; - state.uniforms.time_delta = delta_ns / std.time.ns_per_s; - state.last_frame_time = now; - } - - // @autoreleasepool {} - const pool = objc.AutoreleasePool.init(); - defer pool.deinit(); - - // Get our drawable (CAMetalDrawable) - const drawable = self.layer.msgSend(objc.Object, objc.sel("nextDrawable"), .{}); - - // Get our screen texture. If we don't have a dedicated screen texture - // then we just use the drawable texture. - const screen_texture = if (self.custom_shader_state) |state| - state.back_texture - else tex: { - const texture = drawable.msgSend(objc.c.id, objc.sel("texture"), .{}); - break :tex objc.Object.fromId(texture); - }; - - // If our font atlas changed, sync the texture data - texture: { - const modified = self.font_grid.atlas_grayscale.modified.load(.monotonic); - if (modified <= frame.grayscale_modified) break :texture; - self.font_grid.lock.lockShared(); - defer self.font_grid.lock.unlockShared(); - frame.grayscale_modified = self.font_grid.atlas_grayscale.modified.load(.monotonic); - try syncAtlasTexture( - self.gpu_state.device, - &self.font_grid.atlas_grayscale, - &frame.grayscale, - self.gpu_state.default_storage_mode, - ); - } - texture: { - const modified = self.font_grid.atlas_color.modified.load(.monotonic); - if (modified <= frame.color_modified) break :texture; - self.font_grid.lock.lockShared(); - defer self.font_grid.lock.unlockShared(); - frame.color_modified = self.font_grid.atlas_color.modified.load(.monotonic); - try syncAtlasTexture( - self.gpu_state.device, - &self.font_grid.atlas_color, - &frame.color, - self.gpu_state.default_storage_mode, - ); - } - - // Command buffer (MTLCommandBuffer) - const buffer = self.gpu_state.queue.msgSend(objc.Object, objc.sel("commandBuffer"), .{}); - - { - // MTLRenderPassDescriptor - const desc = desc: { - const MTLRenderPassDescriptor = objc.getClass("MTLRenderPassDescriptor").?; - const desc = MTLRenderPassDescriptor.msgSend( - objc.Object, - objc.sel("renderPassDescriptor"), - .{}, - ); - - // Set our color attachment to be our drawable surface. - const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments")); - { - const attachment = attachments.msgSend( - objc.Object, - objc.sel("objectAtIndexedSubscript:"), - .{@as(c_ulong, 0)}, - ); - - attachment.setProperty("loadAction", @intFromEnum(mtl.MTLLoadAction.clear)); - attachment.setProperty("storeAction", @intFromEnum(mtl.MTLStoreAction.store)); - attachment.setProperty("texture", screen_texture.value); - attachment.setProperty("clearColor", mtl.MTLClearColor{ - .red = 0.0, - .green = 0.0, - .blue = 0.0, - .alpha = 0.0, - }); - } - - break :desc desc; - }; - - // MTLRenderCommandEncoder - const encoder = buffer.msgSend( - objc.Object, - objc.sel("renderCommandEncoderWithDescriptor:"), - .{desc.value}, - ); - defer encoder.msgSend(void, objc.sel("endEncoding"), .{}); - - // Draw background images first - try self.drawImagePlacements(encoder, frame, self.image_placements.items[0..self.image_bg_end]); - - // Then draw background cells - try self.drawCellBgs(encoder, frame); - - // Then draw images under text - try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_bg_end..self.image_text_end]); - - // Then draw fg cells - try self.drawCellFgs(encoder, frame, fg_count); - - // Then draw remaining images - try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_text_end..]); - } - - // If we have custom shaders, then we render them. - if (self.custom_shader_state) |*state| { - // MTLRenderPassDescriptor - const desc = desc: { - const MTLRenderPassDescriptor = objc.getClass("MTLRenderPassDescriptor").?; - const desc = MTLRenderPassDescriptor.msgSend( - objc.Object, - objc.sel("renderPassDescriptor"), - .{}, - ); - - break :desc desc; - }; - - // Prepare our color attachment (output). - const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments")); - const attachment = attachments.msgSend( - objc.Object, - objc.sel("objectAtIndexedSubscript:"), - .{@as(c_ulong, 0)}, - ); - attachment.setProperty("loadAction", @intFromEnum(mtl.MTLLoadAction.clear)); - attachment.setProperty("storeAction", @intFromEnum(mtl.MTLStoreAction.store)); - attachment.setProperty("clearColor", mtl.MTLClearColor{ - .red = 0, - .green = 0, - .blue = 0, - .alpha = 1, - }); - - const post_len = self.shaders.post_pipelines.len; - - for (self.shaders.post_pipelines[0 .. post_len - 1]) |pipeline| { - // Set our color attachment to be our front texture. - attachment.setProperty("texture", state.front_texture.value); - - // MTLRenderCommandEncoder - const encoder = buffer.msgSend( - objc.Object, - objc.sel("renderCommandEncoderWithDescriptor:"), - .{desc.value}, - ); - defer encoder.msgSend(void, objc.sel("endEncoding"), .{}); - - // Draw shader - try self.drawPostShader(encoder, pipeline, state); - // Swap the front and back textures. - state.swap(); - } - - // Draw the final shader directly to the drawable. - { - // Set our color attachment to be our drawable. - // - // Texture is a property of CAMetalDrawable but if you run - // Ghostty in XCode in debug mode it returns a CaptureMTLDrawable - // which ironically doesn't implement CAMetalDrawable as a - // property so we just send a message. - const texture = drawable.msgSend(objc.c.id, objc.sel("texture"), .{}); - attachment.setProperty("texture", texture); - - // MTLRenderCommandEncoder - const encoder = buffer.msgSend( - objc.Object, - objc.sel("renderCommandEncoderWithDescriptor:"), - .{desc.value}, - ); - defer encoder.msgSend(void, objc.sel("endEncoding"), .{}); - - try self.drawPostShader( - encoder, - self.shaders.post_pipelines[post_len - 1], - state, - ); - } - } - - buffer.msgSend(void, objc.sel("presentDrawable:"), .{drawable.value}); - - // Create our block to register for completion updates. This is used - // so we can detect failures. The block is deallocated by the objC - // runtime on success. - const block = try CompletionBlock.init(.{ .self = self }, &bufferCompleted); - errdefer block.deinit(); - buffer.msgSend(void, objc.sel("addCompletedHandler:"), .{block.context}); - - buffer.msgSend(void, objc.sel("commit"), .{}); -} - -/// This is the block type used for the addCompletedHandler call.back. -const CompletionBlock = objc.Block(struct { self: *Metal }, .{ - objc.c.id, // MTLCommandBuffer -}, void); - -/// This is the callback called by the CompletionBlock invocation for -/// addCompletedHandler. -/// -/// Note: this is USUALLY called on a separate thread because the renderer -/// thread and the Apple event loop threads are usually different. Therefore, -/// we need to be mindful of thread safety here. -fn bufferCompleted( - block: *const CompletionBlock.Context, - buffer_id: objc.c.id, -) callconv(.c) void { - const self = block.self; - const buffer = objc.Object.fromId(buffer_id); - - // Get our command buffer status. If it is anything other than error - // then we don't care and just return right away. We're looking for - // errors so that we can log them. - const status = buffer.getProperty(mtl.MTLCommandBufferStatus, "status"); - const health: Health = switch (status) { - .@"error" => .unhealthy, - else => .healthy, - }; - - // If our health value hasn't changed, then we do nothing. We don't - // do a cmpxchg here because strict atomicity isn't important. - if (self.health.load(.seq_cst) != health) { - self.health.store(health, .seq_cst); - - // Our health value changed, so we notify the surface so that it - // can do something about it. - _ = self.surface_mailbox.push(.{ - .renderer_health = health, - }, .{ .forever = {} }); - } - - // Always release our semaphore - self.gpu_state.releaseFrame(); -} - -fn drawPostShader( - self: *Metal, - encoder: objc.Object, - pipeline: objc.Object, - state: *const CustomShaderState, -) !void { - _ = self; - - // Use our custom shader pipeline - encoder.msgSend( - void, - objc.sel("setRenderPipelineState:"), - .{pipeline.value}, - ); - - // Set our sampler - encoder.msgSend( - void, - objc.sel("setFragmentSamplerState:atIndex:"), - .{ state.sampler.sampler.value, @as(c_ulong, 0) }, - ); - - // Set our uniforms - encoder.msgSend( - void, - objc.sel("setFragmentBytes:length:atIndex:"), - .{ - @as(*const anyopaque, @ptrCast(&state.uniforms)), - @as(c_ulong, @sizeOf(@TypeOf(state.uniforms))), - @as(c_ulong, 0), - }, - ); - - // Screen texture - encoder.msgSend( - void, - objc.sel("setFragmentTexture:atIndex:"), - .{ - state.back_texture.value, - @as(c_ulong, 0), - }, - ); - - // Draw! - encoder.msgSend( - void, - objc.sel("drawPrimitives:vertexStart:vertexCount:"), - .{ - @intFromEnum(mtl.MTLPrimitiveType.triangle), - @as(c_ulong, 0), - @as(c_ulong, 3), - }, - ); -} - -fn drawImagePlacements( - self: *Metal, - encoder: objc.Object, - frame: *const FrameState, - placements: []const mtl_image.Placement, -) !void { - if (placements.len == 0) return; - - // Use our image shader pipeline - encoder.msgSend( - void, - objc.sel("setRenderPipelineState:"), - .{self.shaders.image_pipeline.value}, - ); - - // Set our uniforms - encoder.msgSend( - void, - objc.sel("setVertexBuffer:offset:atIndex:"), - .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) }, - ); - encoder.msgSend( - void, - objc.sel("setFragmentBuffer:offset:atIndex:"), - .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) }, - ); - - for (placements) |placement| { - try self.drawImagePlacement(encoder, placement); - } -} - -fn drawImagePlacement( - self: *Metal, - encoder: objc.Object, - p: mtl_image.Placement, -) !void { - // Look up the image - const image = self.images.get(p.image_id) orelse { - log.warn("image not found for placement image_id={}", .{p.image_id}); - return; - }; - - // Get the texture - const texture = switch (image.image) { - .ready => |t| t, - else => { - log.warn("image not ready for placement image_id={}", .{p.image_id}); - return; - }, - }; - - // Create our vertex buffer, which is always exactly one item. - // future(mitchellh): we can group rendering multiple instances of a single image - const Buffer = mtl_buffer.Buffer(mtl_shaders.Image); - var buf = try Buffer.initFill(self.gpu_state.device, &.{.{ - .grid_pos = .{ - @as(f32, @floatFromInt(p.x)), - @as(f32, @floatFromInt(p.y)), - }, - - .cell_offset = .{ - @as(f32, @floatFromInt(p.cell_offset_x)), - @as(f32, @floatFromInt(p.cell_offset_y)), - }, - - .source_rect = .{ - @as(f32, @floatFromInt(p.source_x)), - @as(f32, @floatFromInt(p.source_y)), - @as(f32, @floatFromInt(p.source_width)), - @as(f32, @floatFromInt(p.source_height)), - }, - - .dest_size = .{ - @as(f32, @floatFromInt(p.width)), - @as(f32, @floatFromInt(p.height)), - }, - }}, .{ - // Indicate that the CPU writes to this resource but never reads it. - .cpu_cache_mode = .write_combined, - .storage_mode = self.gpu_state.default_storage_mode, - }); - defer buf.deinit(); - - // Set our buffer - encoder.msgSend( - void, - objc.sel("setVertexBuffer:offset:atIndex:"), - .{ buf.buffer.value, @as(c_ulong, 0), @as(c_ulong, 0) }, - ); - - // Set our texture - encoder.msgSend( - void, - objc.sel("setVertexTexture:atIndex:"), - .{ - texture.value, - @as(c_ulong, 0), - }, - ); - encoder.msgSend( - void, - objc.sel("setFragmentTexture:atIndex:"), - .{ - texture.value, - @as(c_ulong, 0), - }, - ); - - // Draw! - encoder.msgSend( - void, - objc.sel("drawIndexedPrimitives:indexCount:indexType:indexBuffer:indexBufferOffset:instanceCount:"), - .{ - @intFromEnum(mtl.MTLPrimitiveType.triangle), - @as(c_ulong, 6), - @intFromEnum(mtl.MTLIndexType.uint16), - self.gpu_state.instance.buffer.value, - @as(c_ulong, 0), - @as(c_ulong, 1), - }, - ); - - // log.debug("drawImagePlacement: {}", .{p}); -} - -/// Draw the cell backgrounds. -fn drawCellBgs( - self: *Metal, - encoder: objc.Object, - frame: *const FrameState, -) !void { - // Use our shader pipeline - encoder.msgSend( - void, - objc.sel("setRenderPipelineState:"), - .{self.shaders.cell_bg_pipeline.value}, - ); - - // Set our buffers - encoder.msgSend( - void, - objc.sel("setVertexBuffer:offset:atIndex:"), - .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) }, - ); - encoder.msgSend( - void, - objc.sel("setFragmentBuffer:offset:atIndex:"), - .{ frame.cells_bg.buffer.value, @as(c_ulong, 0), @as(c_ulong, 0) }, - ); - encoder.msgSend( - void, - objc.sel("setFragmentBuffer:offset:atIndex:"), - .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) }, - ); - - encoder.msgSend( - void, - objc.sel("drawPrimitives:vertexStart:vertexCount:"), - .{ - @intFromEnum(mtl.MTLPrimitiveType.triangle), - @as(c_ulong, 0), - @as(c_ulong, 3), - }, - ); -} - -/// Draw the cell foregrounds using the text shader. -fn drawCellFgs( - self: *Metal, - encoder: objc.Object, - frame: *const FrameState, - len: usize, -) !void { - // This triggers an assertion in the Metal API if we try to draw - // with an instance count of 0 so just bail. - if (len == 0) return; - - // Use our shader pipeline - encoder.msgSend( - void, - objc.sel("setRenderPipelineState:"), - .{self.shaders.cell_text_pipeline.value}, - ); - - // Set our buffers - encoder.msgSend( - void, - objc.sel("setVertexBuffer:offset:atIndex:"), - .{ frame.cells.buffer.value, @as(c_ulong, 0), @as(c_ulong, 0) }, - ); - encoder.msgSend( - void, - objc.sel("setVertexBuffer:offset:atIndex:"), - .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) }, - ); - encoder.msgSend( - void, - objc.sel("setVertexBuffer:offset:atIndex:"), - .{ frame.cells_bg.buffer.value, @as(c_ulong, 0), @as(c_ulong, 2) }, - ); - encoder.msgSend( - void, - objc.sel("setFragmentTexture:atIndex:"), - .{ frame.grayscale.value, @as(c_ulong, 0) }, - ); - encoder.msgSend( - void, - objc.sel("setFragmentTexture:atIndex:"), - .{ frame.color.value, @as(c_ulong, 1) }, - ); - encoder.msgSend( - void, - objc.sel("setFragmentBuffer:offset:atIndex:"), - .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 2) }, - ); - - encoder.msgSend( - void, - objc.sel("drawIndexedPrimitives:indexCount:indexType:indexBuffer:indexBufferOffset:instanceCount:"), - .{ - @intFromEnum(mtl.MTLPrimitiveType.triangle), - @as(c_ulong, 6), - @intFromEnum(mtl.MTLIndexType.uint16), - self.gpu_state.instance.buffer.value, - @as(c_ulong, 0), - @as(c_ulong, len), - }, - ); -} - -/// This goes through the Kitty graphic placements and accumulates the -/// placements we need to render on our viewport. It also ensures that -/// the visible images are loaded on the GPU. -fn prepKittyGraphics( - self: *Metal, - t: *terminal.Terminal, -) !void { - const storage = &t.screen.kitty_images; - defer storage.dirty = false; - - // We always clear our previous placements no matter what because - // we rebuild them from scratch. - self.image_placements.clearRetainingCapacity(); - self.image_virtual = false; - - // Go through our known images and if there are any that are no longer - // in use then mark them to be freed. - // - // This never conflicts with the below because a placement can't - // reference an image that doesn't exist. - { - var it = self.images.iterator(); - while (it.next()) |kv| { - if (storage.imageById(kv.key_ptr.*) == null) { - kv.value_ptr.image.markForUnload(); - } - } - } - - // The top-left and bottom-right corners of our viewport in screen - // points. This lets us determine offsets and containment of placements. - const top = t.screen.pages.getTopLeft(.viewport); - const bot = t.screen.pages.getBottomRight(.viewport).?; - const top_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; - const bot_y = t.screen.pages.pointFromPin(.screen, bot).?.screen.y; - - // Go through the placements and ensure the image is loaded on the GPU. - var it = storage.placements.iterator(); - while (it.next()) |kv| { - const p = kv.value_ptr; - - // Special logic based on location - switch (p.location) { - .pin => {}, - .virtual => { - // We need to mark virtual placements on our renderer so that - // we know to rebuild in more scenarios since cell changes can - // now trigger placement changes. - self.image_virtual = true; - - // We also continue out because virtual placements are - // only triggered by the unicode placeholder, not by the - // placement itself. - continue; - }, - } - - // Get the image for the placement - const image = storage.imageById(kv.key_ptr.image_id) orelse { - log.warn( - "missing image for placement, ignoring image_id={}", - .{kv.key_ptr.image_id}, - ); - continue; - }; - - try self.prepKittyPlacement(t, top_y, bot_y, &image, p); - } - - // If we have virtual placements then we need to scan for placeholders. - if (self.image_virtual) { - var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot); - while (v_it.next()) |virtual_p| try self.prepKittyVirtualPlacement( - t, - &virtual_p, - ); - } - - // Sort the placements by their Z value. - std.mem.sortUnstable( - mtl_image.Placement, - self.image_placements.items, - {}, - struct { - fn lessThan( - ctx: void, - lhs: mtl_image.Placement, - rhs: mtl_image.Placement, - ) bool { - _ = ctx; - return lhs.z < rhs.z or (lhs.z == rhs.z and lhs.image_id < rhs.image_id); - } - }.lessThan, - ); - - // Find our indices. The values are sorted by z so we can find the - // first placement out of bounds to find the limits. - var bg_end: ?u32 = null; - var text_end: ?u32 = null; - const bg_limit = std.math.minInt(i32) / 2; - for (self.image_placements.items, 0..) |p, i| { - if (bg_end == null and p.z >= bg_limit) { - bg_end = @intCast(i); - } - if (text_end == null and p.z >= 0) { - text_end = @intCast(i); - } - } - - self.image_bg_end = bg_end orelse 0; - self.image_text_end = text_end orelse self.image_bg_end; -} - -fn prepKittyVirtualPlacement( - self: *Metal, - t: *terminal.Terminal, - p: *const terminal.kitty.graphics.unicode.Placement, -) !void { - const storage = &t.screen.kitty_images; - const image = storage.imageById(p.image_id) orelse { - log.warn( - "missing image for virtual placement, ignoring image_id={}", - .{p.image_id}, - ); - return; - }; - - const rp = p.renderPlacement( - storage, - &image, - self.grid_metrics.cell_width, - self.grid_metrics.cell_height, - ) catch |err| { - log.warn("error rendering virtual placement err={}", .{err}); - return; - }; - - // If our placement is zero sized then we don't do anything. - if (rp.dest_width == 0 or rp.dest_height == 0) return; - - const viewport: terminal.point.Point = t.screen.pages.pointFromPin( - .viewport, - rp.top_left, - ) orelse { - // This is unreachable with virtual placements because we should - // only ever be looking at virtual placements that are in our - // viewport in the renderer and virtual placements only ever take - // up one row. - unreachable; - }; - - // Send our image to the GPU and store the placement for rendering. - try self.prepKittyImage(&image); - try self.image_placements.append(self.alloc, .{ - .image_id = image.id, - .x = @intCast(rp.top_left.x), - .y = @intCast(viewport.viewport.y), - .z = -1, - .width = rp.dest_width, - .height = rp.dest_height, - .cell_offset_x = rp.offset_x, - .cell_offset_y = rp.offset_y, - .source_x = rp.source_x, - .source_y = rp.source_y, - .source_width = rp.source_width, - .source_height = rp.source_height, + .bgra8unorm, + .storage_mode = self.default_storage_mode, + .width = width, + .height = height, }); } -fn prepKittyPlacement( - self: *Metal, - t: *terminal.Terminal, - top_y: u32, - bot_y: u32, - image: *const terminal.kitty.graphics.Image, - p: *const terminal.kitty.graphics.ImageStorage.Placement, -) !void { - // Get the rect for the placement. If this placement doesn't have - // a rect then its virtual or something so skip it. - const rect = p.rect(image.*, t) orelse return; - - // This is expensive but necessary. - const img_top_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; - const img_bot_y = t.screen.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; - - // If the selection isn't within our viewport then skip it. - if (img_top_y > bot_y) return; - if (img_bot_y < top_y) return; - - // We need to prep this image for upload if it isn't in the cache OR - // it is in the cache but the transmit time doesn't match meaning this - // image is different. - try self.prepKittyImage(image); - - // Calculate the dimensions of our image, taking in to - // account the rows / columns specified by the placement. - const dest_size = p.calculatedSize(image.*, t); - - // Calculate the source rectangle - const source_x = @min(image.width, p.source_x); - const source_y = @min(image.height, p.source_y); - const source_width = if (p.source_width > 0) - @min(image.width - source_x, p.source_width) - else - image.width; - const source_height = if (p.source_height > 0) - @min(image.height - source_y, p.source_height) - else - image.height; - - // Get the viewport-relative Y position of the placement. - const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y)); - - // Accumulate the placement - if (dest_size.width > 0 and dest_size.height > 0) { - try self.image_placements.append(self.alloc, .{ - .image_id = image.id, - .x = @intCast(rect.top_left.x), - .y = y_pos, - .z = p.z, - .width = dest_size.width, - .height = dest_size.height, - .cell_offset_x = p.x_offset, - .cell_offset_y = p.y_offset, - .source_x = source_x, - .source_y = source_y, - .source_width = source_width, - .source_height = source_height, - }); - } -} - -fn prepKittyImage( - self: *Metal, - image: *const terminal.kitty.graphics.Image, -) !void { - // If this image exists and its transmit time is the same we assume - // it is the identical image so we don't need to send it to the GPU. - const gop = try self.images.getOrPut(self.alloc, image.id); - if (gop.found_existing and - gop.value_ptr.transmit_time.order(image.transmit_time) == .eq) - { - return; - } - - // Copy the data into the pending state. - const data = try self.alloc.dupe(u8, image.data); - errdefer self.alloc.free(data); - - // Store it in the map - const pending: Image.Pending = .{ - .width = image.width, - .height = image.height, - .data = data.ptr, - }; - - const new_image: Image = switch (image.format) { - .gray => .{ .pending_gray = pending }, - .gray_alpha => .{ .pending_gray_alpha = pending }, - .rgb => .{ .pending_rgb = pending }, - .rgba => .{ .pending_rgba = pending }, - .png => unreachable, // should be decoded by now - }; - - if (!gop.found_existing) { - gop.value_ptr.* = .{ - .image = new_image, - .transmit_time = undefined, - }; +/// Present the provided target. +pub inline fn present(self: *Metal, target: Target, sync: bool) !void { + if (sync) { + self.layer.setSurfaceSync(target.surface); } else { - try gop.value_ptr.image.markForReplace( - self.alloc, - new_image, - ); - } - - gop.value_ptr.transmit_time = image.transmit_time; -} - -/// Update the configuration. -pub fn changeConfig(self: *Metal, config: *DerivedConfig) !void { - // We always redo the font shaper in case font features changed. We - // could check to see if there was an actual config change but this is - // easier and rare enough to not cause performance issues. - { - var font_shaper = try font.Shaper.init(self.alloc, .{ - .features = config.font_features.items, - }); - errdefer font_shaper.deinit(); - self.font_shaper.deinit(); - self.font_shaper = font_shaper; - } - - // We also need to reset the shaper cache so shaper info - // from the previous font isn't re-used for the new font. - const font_shaper_cache = font.ShaperCache.init(); - self.font_shaper_cache.deinit(self.alloc); - self.font_shaper_cache = font_shaper_cache; - - // Set our new minimum contrast - self.uniforms.min_contrast = config.min_contrast; - - // Set our new color space and blending - self.uniforms.use_display_p3 = config.colorspace == .@"display-p3"; - self.uniforms.use_linear_blending = config.blending.isLinear(); - self.uniforms.use_linear_correction = config.blending == .@"linear-corrected"; - - // Set our new colors - self.default_background_color = config.background; - self.default_foreground_color = config.foreground; - self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null; - self.cursor_invert = config.cursor_invert; - - // Update our layer's opaqueness and display sync in case they changed. - { - // We use a CATransaction so that Core Animation knows that we - // updated the opaque property. Otherwise it behaves weird, not - // properly going from opaque to transparent unless we resize. - const CATransaction = objc.getClass("CATransaction").?; - CATransaction.msgSend(void, "begin", .{}); - defer CATransaction.msgSend(void, "commit", .{}); - - self.layer.setProperty("opaque", config.background_opacity >= 1); - self.layer.setProperty("displaySyncEnabled", config.vsync); - } - - // Update our terminal colorspace if it changed - if (self.config.colorspace != config.colorspace) { - const terminal_colorspace = try graphics.ColorSpace.createNamed( - switch (config.colorspace) { - .@"display-p3" => .displayP3, - .srgb => .sRGB, - }, - ); - errdefer terminal_colorspace.release(); - self.terminal_colorspace.release(); - self.terminal_colorspace = terminal_colorspace; - } - - const old_blending = self.config.blending; - const old_custom_shaders = self.config.custom_shaders; - - self.config.deinit(); - self.config = config.*; - - // Reset our viewport to force a rebuild, in case of a font change. - self.cells_viewport = null; - - // We reinitialize our shaders if our - // blending or custom shaders changed. - if (old_blending != config.blending or - !old_custom_shaders.equal(config.custom_shaders)) - { - self.deinitShaders(); - try self.initShaders(); - // We call setScreenSize to reinitialize - // the textures used for custom shaders. - if (self.custom_shader_state != null) { - try self.setScreenSize(self.size); - } - // And we update our layer's pixel format appropriately. - self.layer.setProperty( - "pixelFormat", - if (config.blending.isLinear()) - @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) - else - @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), - ); + try self.layer.setSurface(target.surface); } } -/// Resize the screen. -pub fn setScreenSize( - self: *Metal, - size: renderer.Size, -) !void { - // Store our sizes - self.size = size; - const grid_size = size.grid(); - const terminal_size = size.terminal(); +/// Present the last presented target again. (noop for Metal) +pub inline fn presentLastTarget(self: *Metal) !void { + _ = self; +} - // Blank space around the grid. - const blank: renderer.Padding = size.screen.blankPadding( - size.padding, - grid_size, - size.cell, - ).add(size.padding); - - var padding_extend = self.uniforms.padding_extend; - switch (self.config.padding_color) { - .extend => { - // If padding extension is enabled, we extend left and right always - // because there is no downside to this. Up/down is dependent - // on some heuristics (see rebuildCells). - padding_extend.left = true; - padding_extend.right = true; +/// Returns the options to use when constructing buffers. +pub inline fn bufferOptions(self: Metal) bufferpkg.Options { + return .{ + .device = self.device, + .resource_options = .{ + // Indicate that the CPU writes to this resource but never reads it. + .cpu_cache_mode = .write_combined, + .storage_mode = self.default_storage_mode, }, - - .@"extend-always" => { - padding_extend.up = true; - padding_extend.down = true; - padding_extend.left = true; - padding_extend.right = true; - }, - - .background => { - // Otherwise, disable all padding extension. - padding_extend = .{}; - }, - } - - // Set the size of the drawable surface to the bounds - self.layer.setProperty("drawableSize", graphics.Size{ - .width = @floatFromInt(size.screen.width), - .height = @floatFromInt(size.screen.height), - }); - - // Setup our uniforms - const old = self.uniforms; - self.uniforms = .{ - .projection_matrix = math.ortho2d( - -1 * @as(f32, @floatFromInt(size.padding.left)), - @floatFromInt(terminal_size.width + size.padding.right), - @floatFromInt(terminal_size.height + size.padding.bottom), - -1 * @as(f32, @floatFromInt(size.padding.top)), - ), - .cell_size = .{ - @floatFromInt(self.grid_metrics.cell_width), - @floatFromInt(self.grid_metrics.cell_height), - }, - .grid_size = .{ - grid_size.columns, - grid_size.rows, - }, - .grid_padding = .{ - @floatFromInt(blank.top), - @floatFromInt(blank.right), - @floatFromInt(blank.bottom), - @floatFromInt(blank.left), - }, - .padding_extend = padding_extend, - .min_contrast = old.min_contrast, - .cursor_pos = old.cursor_pos, - .cursor_color = old.cursor_color, - .bg_color = old.bg_color, - .cursor_wide = old.cursor_wide, - .use_display_p3 = old.use_display_p3, - .use_linear_blending = old.use_linear_blending, - .use_linear_correction = old.use_linear_correction, }; +} - // Reset our cell contents if our grid size has changed. - if (!self.cells.size.equals(grid_size)) { - try self.cells.resize(self.alloc, grid_size); +pub const instanceBufferOptions = bufferOptions; +pub const uniformBufferOptions = bufferOptions; +pub const fgBufferOptions = bufferOptions; +pub const bgBufferOptions = bufferOptions; +pub const imageBufferOptions = bufferOptions; +pub const bgImageBufferOptions = bufferOptions; - // Reset our viewport to force a rebuild - self.cells_viewport = null; - } +/// Returns the options to use when constructing textures. +pub inline fn textureOptions(self: Metal) Texture.Options { + return .{ + .device = self.device, + // Using an `*_srgb` pixel format makes Metal gamma encode the pixels + // written to it *after* blending, which means we get linear alpha + // blending rather than gamma-incorrect blending. + .pixel_format = if (self.blending.isLinear()) + .bgra8unorm_srgb + else + .bgra8unorm, + .resource_options = .{ + // Indicate that the CPU writes to this resource but never reads it. + .cpu_cache_mode = .write_combined, + .storage_mode = self.default_storage_mode, + }, + }; +} - // If we have custom shaders then we update the state - if (self.custom_shader_state) |*state| { - // Only free our previous texture if this isn't our first - // time setting the custom shader state. - if (state.uniforms.resolution[0] > 0) { - state.front_texture.release(); - state.back_texture.release(); - } +/// Pixel format for image texture options. +pub const ImageTextureFormat = enum { + /// 1 byte per pixel grayscale. + gray, + /// 4 bytes per pixel RGBA. + rgba, + /// 4 bytes per pixel BGRA. + bgra, - state.uniforms.resolution = .{ - @floatFromInt(size.screen.width), - @floatFromInt(size.screen.height), - 1, - }; - - state.front_texture = texture: { - // This texture is the size of our drawable but supports being a - // render target AND reading so that the custom shaders can read from it. - const desc = init: { - const Class = objc.getClass("MTLTextureDescriptor").?; - const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); - const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); - break :init id_init; - }; - desc.setProperty( - "pixelFormat", - // Using an `*_srgb` pixel format makes Metal gamma encode - // the pixels written to it *after* blending, which means - // we get linear alpha blending rather than gamma-incorrect - // blending. - if (self.config.blending.isLinear()) - @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) - else - @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), - ); - desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width))); - desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height))); - desc.setProperty( - "usage", - @intFromEnum(mtl.MTLTextureUsage.render_target) | - @intFromEnum(mtl.MTLTextureUsage.shader_read) | - @intFromEnum(mtl.MTLTextureUsage.shader_write), - ); - - // If we fail to create the texture, then we just don't have a screen - // texture and our custom shaders won't run. - const id = self.gpu_state.device.msgSend( - ?*anyopaque, - objc.sel("newTextureWithDescriptor:"), - .{desc}, - ) orelse return error.MetalFailed; - - break :texture objc.Object.fromId(id); - }; - - state.back_texture = texture: { - // This texture is the size of our drawable but supports being a - // render target AND reading so that the custom shaders can read from it. - const desc = init: { - const Class = objc.getClass("MTLTextureDescriptor").?; - const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); - const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); - break :init id_init; - }; - desc.setProperty( - "pixelFormat", - // Using an `*_srgb` pixel format makes Metal gamma encode - // the pixels written to it *after* blending, which means - // we get linear alpha blending rather than gamma-incorrect - // blending. - if (self.config.blending.isLinear()) - @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) - else - @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), - ); - desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width))); - desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height))); - desc.setProperty( - "usage", - @intFromEnum(mtl.MTLTextureUsage.render_target) | - @intFromEnum(mtl.MTLTextureUsage.shader_read) | - @intFromEnum(mtl.MTLTextureUsage.shader_write), - ); - - // If we fail to create the texture, then we just don't have a screen - // texture and our custom shaders won't run. - const id = self.gpu_state.device.msgSend( - ?*anyopaque, - objc.sel("newTextureWithDescriptor:"), - .{desc}, - ) orelse return error.MetalFailed; - - break :texture objc.Object.fromId(id); + fn toPixelFormat( + self: ImageTextureFormat, + srgb: bool, + ) mtl.MTLPixelFormat { + return switch (self) { + .gray => if (srgb) .r8unorm_srgb else .r8unorm, + .rgba => if (srgb) .rgba8unorm_srgb else .rgba8unorm, + .bgra => if (srgb) .bgra8unorm_srgb else .bgra8unorm, }; } - - log.debug("screen size size={}", .{size}); -} - -/// Convert the terminal state to GPU cells stored in CPU memory. These -/// are then synced to the GPU in the next frame. This only updates CPU -/// memory and doesn't touch the GPU. -fn rebuildCells( - self: *Metal, - rebuild: bool, - screen: *terminal.Screen, - screen_type: terminal.ScreenType, - mouse: renderer.State.Mouse, - preedit: ?renderer.State.Preedit, - cursor_style_: ?renderer.CursorStyle, - color_palette: *const terminal.color.Palette, -) !void { - // const start = try std.time.Instant.now(); - // const start_micro = std.time.microTimestamp(); - // defer { - // const end = std.time.Instant.now() catch unreachable; - // // "[rebuildCells time] \t" - // std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us}); - // } - - _ = screen_type; // we might use this again later so not deleting it yet - - // Create an arena for all our temporary allocations while rebuilding - var arena = ArenaAllocator.init(self.alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); - - // Create our match set for the links. - var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( - arena_alloc, - screen, - mouse_pt, - mouse.mods, - ) else .{}; - - // Determine our x/y range for preedit. We don't want to render anything - // here because we will render the preedit separately. - const preedit_range: ?struct { - y: terminal.size.CellCountInt, - x: [2]terminal.size.CellCountInt, - cp_offset: usize, - } = if (preedit) |preedit_v| preedit: { - const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1); - break :preedit .{ - .y = screen.cursor.y, - .x = .{ range.start, range.end }, - .cp_offset = range.cp_offset, - }; - } else null; - - if (rebuild) { - // If we are doing a full rebuild, then we clear the entire cell buffer. - self.cells.reset(); - - // We also reset our padding extension depending on the screen type - switch (self.config.padding_color) { - .background => {}, - - // For extension, assume we are extending in all directions. - // For "extend" this may be disabled due to heuristics below. - .extend, .@"extend-always" => { - self.uniforms.padding_extend = .{ - .up = true, - .down = true, - .left = true, - .right = true, - }; - }, - } - } - - // We rebuild the cells row-by-row because we - // do font shaping and dirty tracking by row. - var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null); - // If our cell contents buffer is shorter than the screen viewport, - // we render the rows that fit, starting from the bottom. If instead - // the viewport is shorter than the cell contents buffer, we align - // the top of the viewport with the top of the contents buffer. - var y: terminal.size.CellCountInt = @min( - screen.pages.rows, - self.cells.size.rows, - ); - while (row_it.next()) |row| { - // The viewport may have more rows than our cell contents, - // so we need to break from the loop early if we hit y = 0. - if (y == 0) break; - - y -= 1; - - if (!rebuild) { - // Only rebuild if we are doing a full rebuild or this row is dirty. - if (!row.isDirty()) continue; - - // Clear the cells if the row is dirty - self.cells.clear(y); - } - - // True if we want to do font shaping around the cursor. We want to - // do font shaping as long as the cursor is enabled. - const shape_cursor = screen.viewportIsBottom() and - y == screen.cursor.y; - - // We need to get this row's selection if there is one for proper - // run splitting. - const row_selection = sel: { - const sel = screen.selection orelse break :sel null; - const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse - break :sel null; - break :sel sel.containedRow(screen, pin) orelse null; - }; - - // On primary screen, we still apply vertical padding extension - // under certain conditions we feel are safe. This helps make some - // scenarios look better while avoiding scenarios we know do NOT look - // good. - switch (self.config.padding_color) { - // These already have the correct values set above. - .background, .@"extend-always" => {}, - - // Apply heuristics for padding extension. - .extend => if (y == 0) { - self.uniforms.padding_extend.up = !row.neverExtendBg( - color_palette, - self.background_color orelse self.default_background_color, - ); - } else if (y == self.cells.size.rows - 1) { - self.uniforms.padding_extend.down = !row.neverExtendBg( - color_palette, - self.background_color orelse self.default_background_color, - ); - }, - } - - // Iterator of runs for shaping. - var run_iter = self.font_shaper.runIterator( - self.font_grid, - screen, - row, - row_selection, - if (shape_cursor) screen.cursor.x else null, - ); - var shaper_run: ?font.shape.TextRun = try run_iter.next(self.alloc); - var shaper_cells: ?[]const font.shape.Cell = null; - var shaper_cells_i: usize = 0; - - const row_cells_all = row.cells(.all); - - // If our viewport is wider than our cell contents buffer, - // we still only process cells up to the width of the buffer. - const row_cells = row_cells_all[0..@min(row_cells_all.len, self.cells.size.columns)]; - - for (row_cells, 0..) |*cell, x| { - // If this cell falls within our preedit range then we - // skip this because preedits are setup separately. - if (preedit_range) |range| preedit: { - // We're not on the preedit line, no actions necessary. - if (range.y != y) break :preedit; - // We're before the preedit range, no actions necessary. - if (x < range.x[0]) break :preedit; - // We're in the preedit range, skip this cell. - if (x <= range.x[1]) continue; - // After exiting the preedit range we need to catch - // the run position up because of the missed cells. - // In all other cases, no action is necessary. - if (x != range.x[1] + 1) break :preedit; - - // Step the run iterator until we find a run that ends - // after the current cell, which will be the soonest run - // that might contain glyphs for our cell. - while (shaper_run) |run| { - if (run.offset + run.cells > x) break; - shaper_run = try run_iter.next(self.alloc); - shaper_cells = null; - shaper_cells_i = 0; - } - - const run = shaper_run orelse break :preedit; - - // If we haven't shaped this run, do so now. - shaper_cells = shaper_cells orelse - // Try to read the cells from the shaping cache if we can. - self.font_shaper_cache.get(run) orelse - cache: { - // Otherwise we have to shape them. - const cells = try self.font_shaper.shape(run); - - // Try to cache them. If caching fails for any reason we - // continue because it is just a performance optimization, - // not a correctness issue. - self.font_shaper_cache.put( - self.alloc, - run, - cells, - ) catch |err| { - log.warn( - "error caching font shaping results err={}", - .{err}, - ); - }; - - // The cells we get from direct shaping are always owned - // by the shaper and valid until the next shaping call so - // we can safely use them. - break :cache cells; - }; - - // Advance our index until we reach or pass - // our current x position in the shaper cells. - while (shaper_cells.?[shaper_cells_i].x < x) { - shaper_cells_i += 1; - } - } - - const wide = cell.wide; - - const style = row.style(cell); - - const cell_pin: terminal.Pin = cell: { - var copy = row; - copy.x = @intCast(x); - break :cell copy; - }; - - // True if this cell is selected - const selected: bool = if (screen.selection) |sel| - sel.contains(screen, .{ - .node = row.node, - .y = row.y, - .x = @intCast( - // Spacer tails should show the selection - // state of the wide cell they belong to. - if (wide == .spacer_tail) - x -| 1 - else - x, - ), - }) - else - false; - - const bg_style = style.bg(cell, color_palette); - const fg_style = style.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color; - - // The final background color for the cell. - const bg = bg: { - if (selected) { - break :bg if (self.config.invert_selection_fg_bg) - if (style.flags.inverse) - // Cell is selected with invert selection fg/bg - // enabled, and the cell has the inverse style - // flag, so they cancel out and we get the normal - // bg color. - bg_style - else - // If it doesn't have the inverse style - // flag then we use the fg color instead. - fg_style - else - // If we don't have invert selection fg/bg set then we - // just use the selection background if set, otherwise - // the default fg color. - break :bg self.config.selection_background orelse self.foreground_color orelse self.default_foreground_color; - } - - // Not selected - break :bg if (style.flags.inverse != isCovering(cell.codepoint())) - // Two cases cause us to invert (use the fg color as the bg) - // - The "inverse" style flag. - // - A "covering" glyph; we use fg for bg in that case to - // help make sure that padding extension works correctly. - // If one of these is true (but not the other) - // then we use the fg style color for the bg. - fg_style - else - // Otherwise they cancel out. - bg_style; - }; - - const fg = fg: { - if (selected and !self.config.invert_selection_fg_bg) { - // If we don't have invert selection fg/bg set - // then we just use the selection foreground if - // set, otherwise the default bg color. - break :fg self.config.selection_foreground orelse self.background_color orelse self.default_background_color; - } - - // Whether we need to use the bg color as our fg color: - // - Cell is inverted and not selected - // - Cell is selected and not inverted - // Note: if selected then invert sel fg / bg must be - // false since we separately handle it if true above. - break :fg if (style.flags.inverse != selected) - bg_style orelse self.background_color orelse self.default_background_color - else - fg_style; - }; - - // Foreground alpha for this cell. - const alpha: u8 = if (style.flags.faint) 175 else 255; - - // Set the cell's background color. - { - const rgb = bg orelse self.background_color orelse self.default_background_color; - - // Determine our background alpha. If we have transparency configured - // then this is dynamic depending on some situations. This is all - // in an attempt to make transparency look the best for various - // situations. See inline comments. - const bg_alpha: u8 = bg_alpha: { - const default: u8 = 255; - - if (self.config.background_opacity >= 1) break :bg_alpha default; - - // Cells that are selected should be fully opaque. - if (selected) break :bg_alpha default; - - // Cells that are reversed should be fully opaque. - if (style.flags.inverse) break :bg_alpha default; - - // Cells that have an explicit bg color should be fully opaque. - if (bg_style != null) { - break :bg_alpha default; - } - - // Otherwise, we use the configured background opacity. - break :bg_alpha @intFromFloat(@round(self.config.background_opacity * 255.0)); - }; - - self.cells.bgCell(y, x).* = .{ - rgb.r, rgb.g, rgb.b, bg_alpha, - }; - } - - // If the invisible flag is set on this cell then we - // don't need to render any foreground elements, so - // we just skip all glyphs with this x coordinate. - // - // NOTE: This behavior matches xterm. Some other terminal - // emulators, e.g. Alacritty, still render text decorations - // and only make the text itself invisible. The decision - // has been made here to match xterm's behavior for this. - if (style.flags.invisible) { - continue; - } - - // Give links a single underline, unless they already have - // an underline, in which case use a double underline to - // distinguish them. - const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin)) - if (style.flags.underline == .single) - .double - else - .single - else - style.flags.underline; - - // We draw underlines first so that they layer underneath text. - // This improves readability when a colored underline is used - // which intersects parts of the text (descenders). - if (underline != .none) self.addUnderline( - @intCast(x), - @intCast(y), - underline, - style.underlineColor(color_palette) orelse fg, - alpha, - ) catch |err| { - log.warn( - "error adding underline to cell, will be invalid x={} y={}, err={}", - .{ x, y, err }, - ); - }; - - if (style.flags.overline) self.addOverline(@intCast(x), @intCast(y), fg, alpha) catch |err| { - log.warn( - "error adding overline to cell, will be invalid x={} y={}, err={}", - .{ x, y, err }, - ); - }; - - // If we're at or past the end of our shaper run then - // we need to get the next run from the run iterator. - if (shaper_cells != null and shaper_cells_i >= shaper_cells.?.len) { - shaper_run = try run_iter.next(self.alloc); - shaper_cells = null; - shaper_cells_i = 0; - } - - if (shaper_run) |run| glyphs: { - // If we haven't shaped this run yet, do so. - shaper_cells = shaper_cells orelse - // Try to read the cells from the shaping cache if we can. - self.font_shaper_cache.get(run) orelse - cache: { - // Otherwise we have to shape them. - const cells = try self.font_shaper.shape(run); - - // Try to cache them. If caching fails for any reason we - // continue because it is just a performance optimization, - // not a correctness issue. - self.font_shaper_cache.put( - self.alloc, - run, - cells, - ) catch |err| { - log.warn( - "error caching font shaping results err={}", - .{err}, - ); - }; - - // The cells we get from direct shaping are always owned - // by the shaper and valid until the next shaping call so - // we can safely use them. - break :cache cells; - }; - - const cells = shaper_cells orelse break :glyphs; - - // If there are no shaper cells for this run, ignore it. - // This can occur for runs of empty cells, and is fine. - if (cells.len == 0) break :glyphs; - - // If we encounter a shaper cell to the left of the current - // cell then we have some problems. This logic relies on x - // position monotonically increasing. - assert(cells[shaper_cells_i].x >= x); - - // NOTE: An assumption is made here that a single cell will never - // be present in more than one shaper run. If that assumption is - // violated, this logic breaks. - - while (shaper_cells_i < cells.len and cells[shaper_cells_i].x == x) : ({ - shaper_cells_i += 1; - }) { - self.addGlyph( - @intCast(x), - @intCast(y), - cell_pin, - cells[shaper_cells_i], - shaper_run.?, - fg, - alpha, - ) catch |err| { - log.warn( - "error adding glyph to cell, will be invalid x={} y={}, err={}", - .{ x, y, err }, - ); - }; - } - } - - // Finally, draw a strikethrough if necessary. - if (style.flags.strikethrough) self.addStrikethrough( - @intCast(x), - @intCast(y), - fg, - alpha, - ) catch |err| { - log.warn( - "error adding strikethrough to cell, will be invalid x={} y={}, err={}", - .{ x, y, err }, - ); - }; - } - } - - // Setup our cursor rendering information. - cursor: { - // By default, we don't handle cursor inversion on the shader. - self.cells.setCursor(null); - self.uniforms.cursor_pos = .{ - std.math.maxInt(u16), - std.math.maxInt(u16), - }; - - // If we have preedit text, we don't setup a cursor - if (preedit != null) break :cursor; - - // Prepare the cursor cell contents. - const style = cursor_style_ orelse break :cursor; - const cursor_color = self.cursor_color orelse self.default_cursor_color orelse color: { - if (self.cursor_invert) { - // Use the foreground color from the cell under the cursor, if any. - const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - break :color if (sty.flags.inverse) - // If the cell is reversed, use background color instead. - (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color) - else - (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color); - } else { - break :color self.foreground_color orelse self.default_foreground_color; - } - }; - - self.addCursor(screen, style, cursor_color); - - // If the cursor is visible then we set our uniforms. - if (style == .block and screen.viewportIsBottom()) { - const wide = screen.cursor.page_cell.wide; - - self.uniforms.cursor_pos = .{ - // If we are a spacer tail of a wide cell, our cursor needs - // to move back one cell. The saturate is to ensure we don't - // overflow but this shouldn't happen with well-formed input. - switch (wide) { - .narrow, .spacer_head, .wide => screen.cursor.x, - .spacer_tail => screen.cursor.x -| 1, - }, - screen.cursor.y, - }; - - self.uniforms.cursor_wide = switch (wide) { - .narrow, .spacer_head => false, - .wide, .spacer_tail => true, - }; - - const uniform_color = if (self.cursor_invert) blk: { - // Use the background color from the cell under the cursor, if any. - const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - break :blk if (sty.flags.inverse) - // If the cell is reversed, use foreground color instead. - (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color) - else - (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color); - } else if (self.config.cursor_text) |txt| - txt - else - self.background_color orelse self.default_background_color; - - self.uniforms.cursor_color = .{ - uniform_color.r, - uniform_color.g, - uniform_color.b, - 255, - }; - } - } - - // Setup our preedit text. - if (preedit) |preedit_v| { - const range = preedit_range.?; - var x = range.x[0]; - for (preedit_v.codepoints[range.cp_offset..]) |cp| { - self.addPreeditCell(cp, .{ .x = x, .y = range.y }) catch |err| { - log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{ - x, - range.y, - err, - }); - }; - - x += if (cp.wide) 2 else 1; - } - } - - // Update that our cells rebuilt - self.cells_rebuilt = true; - - // Log some things - // log.debug("rebuildCells complete cached_runs={}", .{ - // self.font_shaper_cache.count(), - // }); -} - -/// Add an underline decoration to the specified cell -fn addUnderline( - self: *Metal, - x: terminal.size.CellCountInt, - y: terminal.size.CellCountInt, - style: terminal.Attribute.Underline, - color: terminal.color.RGB, - alpha: u8, -) !void { - const sprite: font.Sprite = switch (style) { - .none => unreachable, - .single => .underline, - .double => .underline_double, - .dotted => .underline_dotted, - .dashed => .underline_dashed, - .curly => .underline_curly, - }; - - const render = try self.font_grid.renderGlyph( - self.alloc, - font.sprite_index, - @intFromEnum(sprite), - .{ - .cell_width = 1, - .grid_metrics = self.grid_metrics, - }, - ); - - try self.cells.add(self.alloc, .underline, .{ - .mode = .fg, - .grid_pos = .{ @intCast(x), @intCast(y) }, - .constraint_width = 1, - .color = .{ color.r, color.g, color.b, alpha }, - .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, - .glyph_size = .{ render.glyph.width, render.glyph.height }, - .bearings = .{ - @intCast(render.glyph.offset_x), - @intCast(render.glyph.offset_y), - }, - }); -} - -/// Add a overline decoration to the specified cell -fn addOverline( - self: *Metal, - x: terminal.size.CellCountInt, - y: terminal.size.CellCountInt, - color: terminal.color.RGB, - alpha: u8, -) !void { - const render = try self.font_grid.renderGlyph( - self.alloc, - font.sprite_index, - @intFromEnum(font.Sprite.overline), - .{ - .cell_width = 1, - .grid_metrics = self.grid_metrics, - }, - ); - - try self.cells.add(self.alloc, .overline, .{ - .mode = .fg, - .grid_pos = .{ @intCast(x), @intCast(y) }, - .constraint_width = 1, - .color = .{ color.r, color.g, color.b, alpha }, - .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, - .glyph_size = .{ render.glyph.width, render.glyph.height }, - .bearings = .{ - @intCast(render.glyph.offset_x), - @intCast(render.glyph.offset_y), - }, - }); -} - -/// Add a strikethrough decoration to the specified cell -fn addStrikethrough( - self: *Metal, - x: terminal.size.CellCountInt, - y: terminal.size.CellCountInt, - color: terminal.color.RGB, - alpha: u8, -) !void { - const render = try self.font_grid.renderGlyph( - self.alloc, - font.sprite_index, - @intFromEnum(font.Sprite.strikethrough), - .{ - .cell_width = 1, - .grid_metrics = self.grid_metrics, - }, - ); - - try self.cells.add(self.alloc, .strikethrough, .{ - .mode = .fg, - .grid_pos = .{ @intCast(x), @intCast(y) }, - .constraint_width = 1, - .color = .{ color.r, color.g, color.b, alpha }, - .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, - .glyph_size = .{ render.glyph.width, render.glyph.height }, - .bearings = .{ - @intCast(render.glyph.offset_x), - @intCast(render.glyph.offset_y), - }, - }); -} - -// Add a glyph to the specified cell. -fn addGlyph( - self: *Metal, - x: terminal.size.CellCountInt, - y: terminal.size.CellCountInt, - cell_pin: terminal.Pin, - shaper_cell: font.shape.Cell, - shaper_run: font.shape.TextRun, - color: terminal.color.RGB, - alpha: u8, -) !void { - const rac = cell_pin.rowAndCell(); - const cell = rac.cell; - - // Render - const render = try self.font_grid.renderGlyph( - self.alloc, - shaper_run.font_index, - shaper_cell.glyph_index, - .{ - .grid_metrics = self.grid_metrics, - .thicken = self.config.font_thicken, - .thicken_strength = self.config.font_thicken_strength, - }, - ); - - // If the glyph is 0 width or height, it will be invisible - // when drawn, so don't bother adding it to the buffer. - if (render.glyph.width == 0 or render.glyph.height == 0) { - return; - } - - const mode: mtl_shaders.CellText.Mode = switch (try fgMode( - render.presentation, - cell_pin, - )) { - .normal => .fg, - .color => .fg_color, - .constrained => .fg_constrained, - .powerline => .fg_powerline, - }; - - try self.cells.add(self.alloc, .text, .{ - .mode = mode, - .grid_pos = .{ @intCast(x), @intCast(y) }, - .constraint_width = cell.gridWidth(), - .color = .{ color.r, color.g, color.b, alpha }, - .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, - .glyph_size = .{ render.glyph.width, render.glyph.height }, - .bearings = .{ - @intCast(render.glyph.offset_x + shaper_cell.x_offset), - @intCast(render.glyph.offset_y + shaper_cell.y_offset), - }, - }); -} - -fn addCursor( - self: *Metal, - screen: *terminal.Screen, - cursor_style: renderer.CursorStyle, - cursor_color: terminal.color.RGB, -) void { - // Add the cursor. We render the cursor over the wide character if - // we're on the wide character tail. - const wide, const x = cell: { - // The cursor goes over the screen cursor position. - const cell = screen.cursor.page_cell; - if (cell.wide != .spacer_tail or screen.cursor.x == 0) - break :cell .{ cell.wide == .wide, screen.cursor.x }; - - // If we're part of a wide character, we move the cursor back to - // the actual character. - const prev_cell = screen.cursorCellLeft(1); - break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 }; - }; - - const alpha: u8 = if (!self.focused) 255 else alpha: { - const alpha = 255 * self.config.cursor_opacity; - break :alpha @intFromFloat(@ceil(alpha)); - }; - - const render = switch (cursor_style) { - .block, - .block_hollow, - .bar, - .underline, - => render: { - const sprite: font.Sprite = switch (cursor_style) { - .block => .cursor_rect, - .block_hollow => .cursor_hollow_rect, - .bar => .cursor_bar, - .underline => .underline, - .lock => unreachable, - }; - - break :render self.font_grid.renderGlyph( - self.alloc, - font.sprite_index, - @intFromEnum(sprite), - .{ - .cell_width = if (wide) 2 else 1, - .grid_metrics = self.grid_metrics, - }, - ) catch |err| { - log.warn("error rendering cursor glyph err={}", .{err}); - return; - }; - }, - - .lock => self.font_grid.renderCodepoint( - self.alloc, - 0xF023, // lock symbol - .regular, - .text, - .{ - .cell_width = if (wide) 2 else 1, - .grid_metrics = self.grid_metrics, - }, - ) catch |err| { - log.warn("error rendering cursor glyph err={}", .{err}); - return; - } orelse { - // This should never happen because we embed nerd - // fonts so we just log and return instead of fallback. - log.warn("failed to find lock symbol for cursor codepoint=0xF023", .{}); - return; +}; + +/// Returns the options to use when constructing textures for images. +pub inline fn imageTextureOptions( + self: Metal, + format: ImageTextureFormat, + srgb: bool, +) Texture.Options { + return .{ + .device = self.device, + .pixel_format = format.toPixelFormat(srgb), + .resource_options = .{ + // Indicate that the CPU writes to this resource but never reads it. + .cpu_cache_mode = .write_combined, + .storage_mode = self.default_storage_mode, }, }; - - self.cells.setCursor(.{ - .mode = .cursor, - .grid_pos = .{ x, screen.cursor.y }, - .color = .{ cursor_color.r, cursor_color.g, cursor_color.b, alpha }, - .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, - .glyph_size = .{ render.glyph.width, render.glyph.height }, - .bearings = .{ - @intCast(render.glyph.offset_x), - @intCast(render.glyph.offset_y), - }, - }); } -fn addPreeditCell( - self: *Metal, - cp: renderer.State.Preedit.Codepoint, - coord: terminal.Coordinate, -) !void { - // Preedit is rendered inverted - const bg = self.foreground_color orelse self.default_foreground_color; - const fg = self.background_color orelse self.default_background_color; - - // Render the glyph for our preedit text - const render_ = self.font_grid.renderCodepoint( - self.alloc, - @intCast(cp.codepoint), - .regular, - .text, - .{ .grid_metrics = self.grid_metrics }, - ) catch |err| { - log.warn("error rendering preedit glyph err={}", .{err}); - return; - }; - const render = render_ orelse { - log.warn("failed to find font for preedit codepoint={X}", .{cp.codepoint}); - return; - }; - - // Add our opaque background cell - self.cells.bgCell(coord.y, coord.x).* = .{ - bg.r, bg.g, bg.b, 255, - }; - if (cp.wide and coord.x < self.cells.size.columns - 1) { - self.cells.bgCell(coord.y, coord.x + 1).* = .{ - bg.r, bg.g, bg.b, 255, - }; - } - - // Add our text - try self.cells.add(self.alloc, .text, .{ - .mode = .fg, - .grid_pos = .{ @intCast(coord.x), @intCast(coord.y) }, - .color = .{ fg.r, fg.g, fg.b, 255 }, - .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, - .glyph_size = .{ render.glyph.width, render.glyph.height }, - .bearings = .{ - @intCast(render.glyph.offset_x), - @intCast(render.glyph.offset_y), - }, - }); -} - -/// Sync the atlas data to the given texture. This copies the bytes -/// associated with the atlas to the given texture. If the atlas no longer -/// fits into the texture, the texture will be resized. -fn syncAtlasTexture( - device: objc.Object, +/// Initializes a Texture suitable for the provided font atlas. +pub fn initAtlasTexture( + self: *const Metal, atlas: *const font.Atlas, - texture: *objc.Object, - /// Storage mode for the MTLTexture object - storage_mode: mtl.MTLResourceOptions.StorageMode, -) !void { - const width = texture.getProperty(c_ulong, "width"); - if (atlas.size > width) { - // Free our old texture - texture.*.release(); - - // Reallocate - texture.* = try initAtlasTexture(device, atlas, storage_mode); - } - - texture.msgSend( - void, - objc.sel("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"), - .{ - mtl.MTLRegion{ - .origin = .{ .x = 0, .y = 0, .z = 0 }, - .size = .{ - .width = @intCast(atlas.size), - .height = @intCast(atlas.size), - .depth = 1, - }, - }, - @as(c_ulong, 0), - @as(*const anyopaque, atlas.data.ptr), - @as(c_ulong, atlas.format.depth() * atlas.size), - }, - ); -} - -/// Initialize a MTLTexture object for the given atlas. -fn initAtlasTexture( - device: objc.Object, - atlas: *const font.Atlas, - /// Storage mode for the MTLTexture object - storage_mode: mtl.MTLResourceOptions.StorageMode, -) !objc.Object { - // Determine our pixel format +) Texture.Error!Texture { const pixel_format: mtl.MTLPixelFormat = switch (atlas.format) { .grayscale => .r8unorm, - .rgba => .bgra8unorm, + .bgra => .bgra8unorm_srgb, else => @panic("unsupported atlas format for Metal texture"), }; - // Create our descriptor - const desc = init: { - const Class = objc.getClass("MTLTextureDescriptor").?; - const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); - const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); - break :init id_init; - }; - - // Set our properties - desc.setProperty("pixelFormat", @intFromEnum(pixel_format)); - desc.setProperty("width", @as(c_ulong, @intCast(atlas.size))); - desc.setProperty("height", @as(c_ulong, @intCast(atlas.size))); - - desc.setProperty( - "resourceOptions", - mtl.MTLResourceOptions{ - // Indicate that the CPU writes to this resource but never reads it. - .cpu_cache_mode = .write_combined, - .storage_mode = storage_mode, + return try Texture.init( + .{ + .device = self.device, + .pixel_format = pixel_format, + .resource_options = .{ + // Indicate that the CPU writes to this resource but never reads it. + .cpu_cache_mode = .write_combined, + .storage_mode = self.default_storage_mode, + }, }, + atlas.size, + atlas.size, + null, ); - - // Initialize - const id = device.msgSend( - ?*anyopaque, - objc.sel("newTextureWithDescriptor:"), - .{desc}, - ) orelse return error.MetalFailed; - - return objc.Object.fromId(id); } -test { - _ = mtl_cell; +/// Begin a frame. +pub inline fn beginFrame( + self: *const Metal, + /// Once the frame has been completed, the `frameCompleted` method + /// on the renderer is called with the health status of the frame. + renderer: *Renderer, + /// The target is presented via the provided renderer's API when completed. + target: *Target, +) !Frame { + return try Frame.begin(.{ .queue = self.queue }, renderer, target); +} + +fn chooseDevice() error{NoMetalDevice}!objc.Object { + var chosen_device: ?objc.Object = null; + + switch (comptime builtin.os.tag) { + .macos => { + const devices = objc.Object.fromId(mtl.MTLCopyAllDevices()); + defer devices.release(); + + var iter = devices.iterate(); + while (iter.next()) |device| { + // We want a GPU that’s connected to a display. + if (device.getProperty(bool, "isHeadless")) continue; + chosen_device = device; + // If the user has an eGPU plugged in, they probably want + // to use it. Otherwise, integrated GPUs are better for + // battery life and thermals. + if (device.getProperty(bool, "isRemovable") or + device.getProperty(bool, "isLowPower")) break; + } + }, + .ios => { + chosen_device = objc.Object.fromId(mtl.MTLCreateSystemDefaultDevice()); + }, + else => @compileError("unsupported target for Metal"), + } + + const device = chosen_device orelse return error.NoMetalDevice; + return device.retain(); } diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index d0222a390..cf195361e 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -1,452 +1,175 @@ -//! Rendering implementation for OpenGL. +//! Graphics API wrapper for OpenGL. pub const OpenGL = @This(); const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const glfw = @import("glfw"); -const assert = std.debug.assert; -const testing = std.testing; -const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; -const link = @import("link.zig"); -const isCovering = @import("cell.zig").isCovering; -const fgMode = @import("cell.zig").fgMode; +const gl = @import("opengl"); const shadertoy = @import("shadertoy.zig"); const apprt = @import("../apprt.zig"); -const configpkg = @import("../config.zig"); const font = @import("../font/main.zig"); -const imgui = @import("imgui"); -const renderer = @import("../renderer.zig"); -const terminal = @import("../terminal/main.zig"); -const Terminal = terminal.Terminal; -const gl = @import("opengl"); -const math = @import("../math.zig"); -const Surface = @import("../Surface.zig"); +const configpkg = @import("../config.zig"); +const rendererpkg = @import("../renderer.zig"); +const Renderer = rendererpkg.GenericRenderer(OpenGL); -const CellProgram = @import("opengl/CellProgram.zig"); -const ImageProgram = @import("opengl/ImageProgram.zig"); -const gl_image = @import("opengl/image.zig"); -const custom = @import("opengl/custom.zig"); -const Image = gl_image.Image; -const ImageMap = gl_image.ImageMap; -const ImagePlacementList = std.ArrayListUnmanaged(gl_image.Placement); +pub const GraphicsAPI = OpenGL; +pub const Target = @import("opengl/Target.zig"); +pub const Frame = @import("opengl/Frame.zig"); +pub const RenderPass = @import("opengl/RenderPass.zig"); +pub const Pipeline = @import("opengl/Pipeline.zig"); +const bufferpkg = @import("opengl/buffer.zig"); +pub const Buffer = bufferpkg.Buffer; +pub const Texture = @import("opengl/Texture.zig"); +pub const shaders = @import("opengl/shaders.zig"); -const log = std.log.scoped(.grid); +pub const custom_shader_target: shadertoy.Target = .glsl; +// The fragCoord for OpenGL shaders is +Y = up. +pub const custom_shader_y_is_down = false; -/// The runtime can request a single-threaded draw by setting this boolean -/// to true. In this case, the renderer.draw() call is expected to be called -/// from the runtime. -pub const single_threaded_draw = if (@hasDecl(apprt.Surface, "opengl_single_threaded_draw")) - apprt.Surface.opengl_single_threaded_draw -else - false; -const DrawMutex = if (single_threaded_draw) std.Thread.Mutex else void; -const drawMutexZero: DrawMutex = if (DrawMutex == void) void{} else .{}; +/// Because OpenGL's frame completion is always +/// sync, we have no need for multi-buffering. +pub const swap_chain_count = 1; + +const log = std.log.scoped(.opengl); + +/// We require at least OpenGL 4.3 +pub const MIN_VERSION_MAJOR = 4; +pub const MIN_VERSION_MINOR = 3; alloc: std.mem.Allocator, -/// The configuration we need derived from the main config. -config: DerivedConfig, +/// Alpha blending mode +blending: configpkg.Config.AlphaBlending, -/// Current font metrics defining our grid. -grid_metrics: font.Metrics, +/// The most recently presented target, in case we need to present it again. +last_target: ?Target = null, -/// The size of everything. -size: renderer.Size, - -/// The current set of cells to render. Each set of cells goes into -/// a separate shader call. -cells_bg: std.ArrayListUnmanaged(CellProgram.Cell), -cells: std.ArrayListUnmanaged(CellProgram.Cell), - -/// The last viewport that we based our rebuild off of. If this changes, -/// then we do a full rebuild of the cells. The pointer values in this pin -/// are NOT SAFE to read because they may be modified, freed, etc from the -/// termio thread. We treat the pointers as integers for comparison only. -cells_viewport: ?terminal.Pin = null, - -/// The size of the cells list that was sent to the GPU. This is used -/// to detect when the cells array was reallocated/resized and handle that -/// accordingly. -gl_cells_size: usize = 0, - -/// The last length of the cells that was written to the GPU. This is used to -/// determine what data needs to be rewritten on the GPU. -gl_cells_written: usize = 0, - -/// Shader program for cell rendering. -gl_state: ?GLState = null, - -/// The font structures. -font_grid: *font.SharedGrid, -font_shaper: font.Shaper, -font_shaper_cache: font.ShaperCache, -texture_grayscale_modified: usize = 0, -texture_grayscale_resized: usize = 0, -texture_color_modified: usize = 0, -texture_color_resized: usize = 0, - -/// True if the window is focused -focused: bool, - -/// The foreground color set by an OSC 10 sequence. If unset then the default -/// value from the config file is used. -foreground_color: ?terminal.color.RGB, - -/// Foreground color set in the user's config file. -default_foreground_color: terminal.color.RGB, - -/// The background color set by an OSC 11 sequence. If unset then the default -/// value from the config file is used. -background_color: ?terminal.color.RGB, - -/// Background color set in the user's config file. -default_background_color: terminal.color.RGB, - -/// The cursor color set by an OSC 12 sequence. If unset then -/// default_cursor_color is used. -cursor_color: ?terminal.color.RGB, - -/// Default cursor color when no color is set explicitly by an OSC 12 command. -/// This is cursor color as set in the user's config, if any. If no cursor color -/// is set in the user's config, then the cursor color is determined by the -/// current foreground color. -default_cursor_color: ?terminal.color.RGB, - -/// When `cursor_color` is null, swap the foreground and background colors of -/// the cell under the cursor for the cursor color. Otherwise, use the default -/// foreground color as the cursor color. -cursor_invert: bool, - -/// The mailbox for communicating with the window. -surface_mailbox: apprt.surface.Mailbox, - -/// Deferred operations. This is used to apply changes to the OpenGL context. -/// Some runtimes (GTK) do not support multi-threading so to keep our logic -/// simple we apply all OpenGL context changes in the render() call. -deferred_screen_size: ?SetScreenSize = null, -deferred_font_size: ?SetFontSize = null, -deferred_config: ?SetConfig = null, - -/// If we're drawing with single threaded operations -draw_mutex: DrawMutex = drawMutexZero, - -/// Current background to draw. This may not match self.background if the -/// terminal is in reversed mode. -draw_background: terminal.color.RGB, - -/// Whether we're doing padding extension for vertical sides. -padding_extend_top: bool = true, -padding_extend_bottom: bool = true, - -/// The images that we may render. -images: ImageMap = .{}, -image_placements: ImagePlacementList = .{}, -image_bg_end: u32 = 0, -image_text_end: u32 = 0, -image_virtual: bool = false, - -/// Deferred OpenGL operation to update the screen size. -const SetScreenSize = struct { - size: renderer.Size, - - fn apply(self: SetScreenSize, r: *OpenGL) !void { - const gl_state: *GLState = if (r.gl_state) |*v| - v - else - return error.OpenGLUninitialized; - - // Apply our padding - const grid_size = self.size.grid(); - const terminal_size = self.size.terminal(); - - // Blank space around the grid. - const blank: renderer.Padding = switch (r.config.padding_color) { - // We can use zero padding because the background color is our - // clear color. - .background => .{}, - - .extend, .@"extend-always" => self.size.screen.blankPadding( - self.size.padding, - grid_size, - self.size.cell, - ).add(self.size.padding), - }; - - // Update our viewport for this context to be the entire window. - // OpenGL works in pixels, so we have to use the pixel size. - try gl.viewport( - 0, - 0, - @intCast(self.size.screen.width), - @intCast(self.size.screen.height), - ); - - // Update the projection uniform within our shader - inline for (.{ "cell_program", "image_program" }) |name| { - const program = @field(gl_state, name); - const bind = try program.program.use(); - defer bind.unbind(); - try program.program.setUniform( - "projection", - - // 2D orthographic projection with the full w/h - math.ortho2d( - -1 * @as(f32, @floatFromInt(self.size.padding.left)), - @floatFromInt(terminal_size.width + self.size.padding.right), - @floatFromInt(terminal_size.height + self.size.padding.bottom), - -1 * @as(f32, @floatFromInt(self.size.padding.top)), - ), - ); - } - - // Setup our grid padding - { - const program = gl_state.cell_program; - const bind = try program.program.use(); - defer bind.unbind(); - try program.program.setUniform( - "grid_padding", - @Vector(4, f32){ - @floatFromInt(blank.top), - @floatFromInt(blank.right), - @floatFromInt(blank.bottom), - @floatFromInt(blank.left), - }, - ); - try program.program.setUniform( - "grid_size", - @Vector(2, f32){ - @floatFromInt(grid_size.columns), - @floatFromInt(grid_size.rows), - }, - ); - } - - // Update our custom shader resolution - if (gl_state.custom) |*custom_state| { - try custom_state.setScreenSize(self.size); - } - } -}; - -const SetFontSize = struct { - metrics: font.Metrics, - - fn apply(self: SetFontSize, r: *const OpenGL) !void { - const gl_state = r.gl_state orelse return error.OpenGLUninitialized; - - inline for (.{ "cell_program", "image_program" }) |name| { - const program = @field(gl_state, name); - const bind = try program.program.use(); - defer bind.unbind(); - try program.program.setUniform( - "cell_size", - @Vector(2, f32){ - @floatFromInt(self.metrics.cell_width), - @floatFromInt(self.metrics.cell_height), - }, - ); - } - } -}; - -const SetConfig = struct { - fn apply(self: SetConfig, r: *const OpenGL) !void { - _ = self; - const gl_state = r.gl_state orelse return error.OpenGLUninitialized; - - const bind = try gl_state.cell_program.program.use(); - defer bind.unbind(); - try gl_state.cell_program.program.setUniform( - "min_contrast", - r.config.min_contrast, - ); - } -}; - -/// The configuration for this renderer that is derived from the main -/// configuration. This must be exported so that we don't need to -/// pass around Config pointers which makes memory management a pain. -pub const DerivedConfig = struct { - arena: ArenaAllocator, - - font_thicken: bool, - font_thicken_strength: u8, - font_features: std.ArrayListUnmanaged([:0]const u8), - font_styles: font.CodepointResolver.StyleStatus, - cursor_color: ?terminal.color.RGB, - cursor_invert: bool, - cursor_text: ?terminal.color.RGB, - cursor_opacity: f64, - background: terminal.color.RGB, - background_opacity: f64, - foreground: terminal.color.RGB, - selection_background: ?terminal.color.RGB, - selection_foreground: ?terminal.color.RGB, - invert_selection_fg_bg: bool, - bold_is_bright: bool, - min_contrast: f32, - padding_color: configpkg.WindowPaddingColor, - custom_shaders: configpkg.RepeatablePath, - links: link.Set, - - pub fn init( - alloc_gpa: Allocator, - config: *const configpkg.Config, - ) !DerivedConfig { - var arena = ArenaAllocator.init(alloc_gpa); - errdefer arena.deinit(); - const alloc = arena.allocator(); - - // Copy our shaders - const custom_shaders = try config.@"custom-shader".clone(alloc); - - // Copy our font features - const font_features = try config.@"font-feature".clone(alloc); - - // Get our font styles - var font_styles = font.CodepointResolver.StyleStatus.initFill(true); - font_styles.set(.bold, config.@"font-style-bold" != .false); - font_styles.set(.italic, config.@"font-style-italic" != .false); - font_styles.set(.bold_italic, config.@"font-style-bold-italic" != .false); - - // Our link configs - const links = try link.Set.fromConfig( - alloc, - config.link.links.items, - ); - - const cursor_invert = config.@"cursor-invert-fg-bg"; - - return .{ - .background_opacity = @max(0, @min(1, config.@"background-opacity")), - .font_thicken = config.@"font-thicken", - .font_thicken_strength = config.@"font-thicken-strength", - .font_features = font_features.list, - .font_styles = font_styles, - - .cursor_color = if (!cursor_invert and config.@"cursor-color" != null) - config.@"cursor-color".?.toTerminalRGB() - else - null, - - .cursor_invert = cursor_invert, - - .cursor_text = if (config.@"cursor-text") |txt| - txt.toTerminalRGB() - else - null, - - .cursor_opacity = @max(0, @min(1, config.@"cursor-opacity")), - - .background = config.background.toTerminalRGB(), - .foreground = config.foreground.toTerminalRGB(), - .invert_selection_fg_bg = config.@"selection-invert-fg-bg", - .bold_is_bright = config.@"bold-is-bright", - .min_contrast = @floatCast(config.@"minimum-contrast"), - .padding_color = config.@"window-padding-color", - - .selection_background = if (config.@"selection-background") |bg| - bg.toTerminalRGB() - else - null, - - .selection_foreground = if (config.@"selection-foreground") |bg| - bg.toTerminalRGB() - else - null, - - .custom_shaders = custom_shaders, - .links = links, - - .arena = arena, - }; - } - - pub fn deinit(self: *DerivedConfig) void { - const alloc = self.arena.allocator(); - self.links.deinit(alloc); - self.arena.deinit(); - } -}; - -pub fn init(alloc: Allocator, options: renderer.Options) !OpenGL { - // Create the initial font shaper - var shaper = try font.Shaper.init(alloc, .{ - .features = options.config.font_features.items, - }); - errdefer shaper.deinit(); - - // For the remainder of the setup we lock our font grid data because - // we're reading it. - const grid = options.font_grid; - grid.lock.lockShared(); - defer grid.lock.unlockShared(); - - var gl_state = try GLState.init(alloc, options.config, grid); - errdefer gl_state.deinit(); - - return OpenGL{ +/// NOTE: This is an error{}!OpenGL instead of just OpenGL for parity with +/// Metal, since it needs to be fallible so does this, even though it +/// can't actually fail. +pub fn init(alloc: Allocator, opts: rendererpkg.Options) error{}!OpenGL { + return .{ .alloc = alloc, - .config = options.config, - .cells_bg = .{}, - .cells = .{}, - .grid_metrics = grid.metrics, - .size = options.size, - .gl_state = gl_state, - .font_grid = grid, - .font_shaper = shaper, - .font_shaper_cache = font.ShaperCache.init(), - .draw_background = options.config.background, - .focused = true, - .foreground_color = null, - .default_foreground_color = options.config.foreground, - .background_color = null, - .default_background_color = options.config.background, - .cursor_color = null, - .default_cursor_color = options.config.cursor_color, - .cursor_invert = options.config.cursor_invert, - .surface_mailbox = options.surface_mailbox, - .deferred_font_size = .{ .metrics = grid.metrics }, - .deferred_config = .{}, + .blending = opts.config.blending, }; } pub fn deinit(self: *OpenGL) void { - self.font_shaper.deinit(); - self.font_shaper_cache.deinit(self.alloc); - - { - var it = self.images.iterator(); - while (it.next()) |kv| kv.value_ptr.image.deinit(self.alloc); - self.images.deinit(self.alloc); - } - self.image_placements.deinit(self.alloc); - - if (self.gl_state) |*v| v.deinit(self.alloc); - - self.cells.deinit(self.alloc); - self.cells_bg.deinit(self.alloc); - - self.config.deinit(); - self.* = undefined; } /// Returns the hints that we want for this pub fn glfwWindowHints(config: *const configpkg.Config) glfw.Window.Hints { + _ = config; return .{ - .context_version_major = 3, - .context_version_minor = 3, + .context_version_major = MIN_VERSION_MAJOR, + .context_version_minor = MIN_VERSION_MINOR, .opengl_profile = .opengl_core_profile, .opengl_forward_compat = true, - .cocoa_graphics_switching = builtin.os.tag == .macos, - .cocoa_retina_framebuffer = true, - .transparent_framebuffer = config.@"background-opacity" < 1, + .transparent_framebuffer = true, }; } +/// 32-bit windows cross-compilation breaks with `.c` for some reason, so... +const gl_debug_proc_callconv = + @typeInfo( + @typeInfo( + @typeInfo( + gl.c.GLDEBUGPROC, + ).optional.child, + ).pointer.child, + ).@"fn".calling_convention; + +fn glDebugMessageCallback( + src: gl.c.GLenum, + typ: gl.c.GLenum, + id: gl.c.GLuint, + severity: gl.c.GLenum, + len: gl.c.GLsizei, + msg: [*c]const gl.c.GLchar, + user_param: ?*const anyopaque, +) callconv(gl_debug_proc_callconv) void { + _ = user_param; + + const src_str: []const u8 = switch (src) { + gl.c.GL_DEBUG_SOURCE_API => "OpenGL API", + gl.c.GL_DEBUG_SOURCE_WINDOW_SYSTEM => "Window System", + gl.c.GL_DEBUG_SOURCE_SHADER_COMPILER => "Shader Compiler", + gl.c.GL_DEBUG_SOURCE_THIRD_PARTY => "Third Party", + gl.c.GL_DEBUG_SOURCE_APPLICATION => "User", + gl.c.GL_DEBUG_SOURCE_OTHER => "Other", + else => "Unknown", + }; + + const typ_str: []const u8 = switch (typ) { + gl.c.GL_DEBUG_TYPE_ERROR => "Error", + gl.c.GL_DEBUG_TYPE_DEPRECATED_BEHAVIOR => "Deprecated Behavior", + gl.c.GL_DEBUG_TYPE_UNDEFINED_BEHAVIOR => "Undefined Behavior", + gl.c.GL_DEBUG_TYPE_PORTABILITY => "Portability Issue", + gl.c.GL_DEBUG_TYPE_PERFORMANCE => "Performance Issue", + gl.c.GL_DEBUG_TYPE_MARKER => "Marker", + gl.c.GL_DEBUG_TYPE_PUSH_GROUP => "Group Push", + gl.c.GL_DEBUG_TYPE_POP_GROUP => "Group Pop", + gl.c.GL_DEBUG_TYPE_OTHER => "Other", + else => "Unknown", + }; + + const msg_str = msg[0..@intCast(len)]; + + (switch (severity) { + gl.c.GL_DEBUG_SEVERITY_HIGH => log.err( + "[{d}] ({s}: {s}) {s}", + .{ id, src_str, typ_str, msg_str }, + ), + gl.c.GL_DEBUG_SEVERITY_MEDIUM => log.warn( + "[{d}] ({s}: {s}) {s}", + .{ id, src_str, typ_str, msg_str }, + ), + gl.c.GL_DEBUG_SEVERITY_LOW => log.info( + "[{d}] ({s}: {s}) {s}", + .{ id, src_str, typ_str, msg_str }, + ), + gl.c.GL_DEBUG_SEVERITY_NOTIFICATION => log.debug( + "[{d}] ({s}: {s}) {s}", + .{ id, src_str, typ_str, msg_str }, + ), + else => log.warn( + "UNKNOWN SEVERITY [{d}] ({s}: {s}) {s}", + .{ id, src_str, typ_str, msg_str }, + ), + }); +} + +/// Prepares the provided GL context, loading it with glad. +fn prepareContext(getProcAddress: anytype) !void { + const version = try gl.glad.load(getProcAddress); + const major = gl.glad.versionMajor(@intCast(version)); + const minor = gl.glad.versionMinor(@intCast(version)); + errdefer gl.glad.unload(); + log.info("loaded OpenGL {}.{}", .{ major, minor }); + + // Enable debug output for the context. + try gl.enable(gl.c.GL_DEBUG_OUTPUT); + + // Register our debug message callback with the OpenGL context. + gl.glad.context.DebugMessageCallback.?(glDebugMessageCallback, null); + + // Enable SRGB framebuffer for linear blending support. + try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB); + + if (major < MIN_VERSION_MAJOR or + (major == MIN_VERSION_MAJOR and minor < MIN_VERSION_MINOR)) + { + log.warn( + "OpenGL version is too old. Ghostty requires OpenGL {d}.{d}", + .{ MIN_VERSION_MAJOR, MIN_VERSION_MINOR }, + ); + return error.OpenGLOutdated; + } +} + /// This is called early right after surface creation. pub fn surfaceInit(surface: *apprt.Surface) !void { // Treat this like a thread entry @@ -455,20 +178,8 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), - apprt.gtk => { - // GTK uses global OpenGL context so we load from null. - const version = try gl.glad.load(null); - const major = gl.glad.versionMajor(@intCast(version)); - const minor = gl.glad.versionMinor(@intCast(version)); - errdefer gl.glad.unload(); - log.info("loaded OpenGL {}.{}", .{ major, minor }); - - // We require at least OpenGL 3.3 - if (major < 3 or (major == 3 and minor < 3)) { - log.warn("OpenGL version is too old. Ghostty requires OpenGL 3.3", .{}); - return error.OpenGLOutdated; - } - }, + // GTK uses global OpenGL context so we load from null. + apprt.gtk => try prepareContext(null), apprt.glfw => try self.threadEnter(surface), @@ -489,69 +200,19 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { // } } -/// This is called just prior to spinning up the renderer thread for -/// final main thread setup requirements. +/// This is called just prior to spinning up the renderer +/// thread for final main thread setup requirements. pub fn finalizeSurfaceInit(self: *const OpenGL, surface: *apprt.Surface) !void { _ = self; _ = surface; - // For GLFW, we grabbed the OpenGL context in surfaceInit and we - // need to release it before we start the renderer thread. + // For GLFW, we grabbed the OpenGL context in surfaceInit and + // we need to release it before we start the renderer thread. if (apprt.runtime == apprt.glfw) { glfw.makeContextCurrent(null); } } -/// Called when the OpenGL context is made invalid, so we need to free -/// all previous resources and stop rendering. -pub fn displayUnrealized(self: *OpenGL) void { - if (single_threaded_draw) self.draw_mutex.lock(); - defer if (single_threaded_draw) self.draw_mutex.unlock(); - - if (self.gl_state) |*v| { - v.deinit(self.alloc); - self.gl_state = null; - } -} - -/// Called when the OpenGL is ready to be initialized. -pub fn displayRealize(self: *OpenGL) !void { - if (single_threaded_draw) self.draw_mutex.lock(); - defer if (single_threaded_draw) self.draw_mutex.unlock(); - - // Make our new state - var gl_state = gl_state: { - self.font_grid.lock.lockShared(); - defer self.font_grid.lock.unlockShared(); - break :gl_state try GLState.init( - self.alloc, - self.config, - self.font_grid, - ); - }; - errdefer gl_state.deinit(); - - // Unrealize if we have to - if (self.gl_state) |*v| v.deinit(self.alloc); - - // Set our new state - self.gl_state = gl_state; - - // Make sure we invalidate all the fields so that we - // reflush everything - self.gl_cells_size = 0; - self.gl_cells_written = 0; - self.texture_grayscale_modified = 0; - self.texture_color_modified = 0; - self.texture_grayscale_resized = 0; - self.texture_color_resized = 0; - - // We need to reset our uniforms - self.deferred_screen_size = .{ .size = self.size }; - self.deferred_font_size = .{ .metrics = self.grid_metrics }; - self.deferred_config = .{}; -} - /// Callback called by renderer.Thread when it begins. pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { _ = self; @@ -568,22 +229,17 @@ pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { apprt.glfw => { // We need to make the OpenGL context current. OpenGL requires - // that a single thread own the a single OpenGL context (if any). This - // ensures that the context switches over to our thread. Important: - // the prior thread MUST have detached the context prior to calling - // this entrypoint. + // that a single thread own the a single OpenGL context (if any). + // This ensures that the context switches over to our thread. + // Important: the prior thread MUST have detached the context + // prior to calling this entrypoint. glfw.makeContextCurrent(surface.window); errdefer glfw.makeContextCurrent(null); glfw.swapInterval(1); // Load OpenGL bindings. This API is context-aware so this sets // a threadlocal context for these pointers. - const version = try gl.glad.load(&glfw.getProcAddress); - errdefer gl.glad.unload(); - log.info("loaded OpenGL {}.{}", .{ - gl.glad.versionMajor(@intCast(version)), - gl.glad.versionMinor(@intCast(version)), - }); + try prepareContext(&glfw.getProcAddress); }, apprt.embedded => { @@ -617,2068 +273,199 @@ pub fn threadExit(self: *const OpenGL) void { } } -/// True if our renderer has animations so that a higher frequency -/// timer is used. -pub fn hasAnimations(self: *const OpenGL) bool { - const state = self.gl_state orelse return false; - return state.custom != null; -} - -/// See Metal -pub fn hasVsync(self: *const OpenGL) bool { +pub fn displayRealized(self: *const OpenGL) void { _ = self; - // OpenGL currently never has vsync - return false; -} - -/// See Metal. -pub fn markDirty(self: *OpenGL) void { - // Do nothing, we don't have dirty tracking yet. - _ = self; -} - -/// Callback when the focus changes for the terminal this is rendering. -/// -/// Must be called on the render thread. -pub fn setFocus(self: *OpenGL, focus: bool) !void { - self.focused = focus; -} - -/// Callback when the window is visible or occluded. -/// -/// Must be called on the render thread. -pub fn setVisible(self: *OpenGL, visible: bool) void { - _ = self; - _ = visible; -} - -/// Set the new font grid. -/// -/// Must be called on the render thread. -pub fn setFontGrid(self: *OpenGL, grid: *font.SharedGrid) void { - if (single_threaded_draw) self.draw_mutex.lock(); - defer if (single_threaded_draw) self.draw_mutex.unlock(); - - // Reset our font grid - self.font_grid = grid; - self.grid_metrics = grid.metrics; - self.texture_grayscale_modified = 0; - self.texture_grayscale_resized = 0; - self.texture_color_modified = 0; - self.texture_color_resized = 0; - - // Reset our shaper cache. If our font changed (not just the size) then - // the data in the shaper cache may be invalid and cannot be used, so we - // always clear the cache just in case. - const font_shaper_cache = font.ShaperCache.init(); - self.font_shaper_cache.deinit(self.alloc); - self.font_shaper_cache = font_shaper_cache; - - // Update our screen size because the font grid can affect grid - // metrics which update uniforms. - self.deferred_screen_size = .{ .size = self.size }; - - // Defer our GPU updates - self.deferred_font_size = .{ .metrics = grid.metrics }; -} - -/// The primary render callback that is completely thread-safe. -pub fn updateFrame( - self: *OpenGL, - surface: *apprt.Surface, - state: *renderer.State, - cursor_blink_visible: bool, -) !void { - _ = surface; - - // Data we extract out of the critical area. - const Critical = struct { - full_rebuild: bool, - gl_bg: terminal.color.RGB, - screen: terminal.Screen, - screen_type: terminal.ScreenType, - mouse: renderer.State.Mouse, - preedit: ?renderer.State.Preedit, - cursor_style: ?renderer.CursorStyle, - color_palette: terminal.color.Palette, - }; - - // Update all our data as tightly as possible within the mutex. - var critical: Critical = critical: { - state.mutex.lock(); - defer state.mutex.unlock(); - - // If we're in a synchronized output state, we pause all rendering. - if (state.terminal.modes.get(.synchronized_output)) { - log.debug("synchronized output started, skipping render", .{}); - return; - } - - // Swap bg/fg if the terminal is reversed - const bg = self.background_color orelse self.default_background_color; - const fg = self.foreground_color orelse self.default_foreground_color; - defer { - if (self.background_color) |*c| { - c.* = bg; - } else { - self.default_background_color = bg; - } - - if (self.foreground_color) |*c| { - c.* = fg; - } else { - self.default_foreground_color = fg; - } - } - - if (state.terminal.modes.get(.reverse_colors)) { - if (self.background_color) |*c| { - c.* = fg; - } else { - self.default_background_color = fg; - } - - if (self.foreground_color) |*c| { - c.* = bg; - } else { - self.default_foreground_color = bg; - } - } - - // Get the viewport pin so that we can compare it to the current. - const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?; - - // We used to share terminal state, but we've since learned through - // analysis that it is faster to copy the terminal state than to - // hold the lock while rebuilding GPU cells. - var screen_copy = try state.terminal.screen.clone( - self.alloc, - .{ .viewport = .{} }, - null, - ); - errdefer screen_copy.deinit(); - - // Whether to draw our cursor or not. - const cursor_style = if (state.terminal.flags.password_input) - .lock - else - renderer.cursorStyle( - state, - self.focused, - cursor_blink_visible, - ); - - // Get our preedit state - const preedit: ?renderer.State.Preedit = preedit: { - if (cursor_style == null) break :preedit null; - const p = state.preedit orelse break :preedit null; - break :preedit try p.clone(self.alloc); - }; - errdefer if (preedit) |p| p.deinit(self.alloc); - - // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path. - // We only do this if the Kitty image state is dirty meaning only if - // it changes. - // - // If we have any virtual references, we must also rebuild our - // kitty state on every frame because any cell change can move - // an image. - if (state.terminal.screen.kitty_images.dirty or - self.image_virtual) - { - // prepKittyGraphics touches self.images which is also used - // in drawFrame so if we're drawing on a separate thread we need - // to lock this. - if (single_threaded_draw) self.draw_mutex.lock(); - defer if (single_threaded_draw) self.draw_mutex.unlock(); - try self.prepKittyGraphics(state.terminal); - } - - // If we have any terminal dirty flags set then we need to rebuild - // the entire screen. This can be optimized in the future. - const full_rebuild: bool = rebuild: { - { - const Int = @typeInfo(terminal.Terminal.Dirty).@"struct".backing_integer.?; - const v: Int = @bitCast(state.terminal.flags.dirty); - if (v > 0) break :rebuild true; - } - { - const Int = @typeInfo(terminal.Screen.Dirty).@"struct".backing_integer.?; - const v: Int = @bitCast(state.terminal.screen.dirty); - if (v > 0) break :rebuild true; - } - - // If our viewport changed then we need to rebuild the entire - // screen because it means we scrolled. If we have no previous - // viewport then we must rebuild. - const prev_viewport = self.cells_viewport orelse break :rebuild true; - if (!prev_viewport.eql(viewport_pin)) break :rebuild true; - - break :rebuild false; - }; - - // Reset the dirty flags in the terminal and screen. We assume - // that our rebuild will be successful since so we optimize for - // success and reset while we hold the lock. This is much easier - // than coordinating row by row or as changes are persisted. - state.terminal.flags.dirty = .{}; - state.terminal.screen.dirty = .{}; - { - var it = state.terminal.screen.pages.pageIterator( - .right_down, - .{ .screen = .{} }, - null, - ); - while (it.next()) |chunk| { - var dirty_set = chunk.node.data.dirtyBitSet(); - dirty_set.unsetAll(); - } - } - - // Update our viewport pin for dirty tracking - self.cells_viewport = viewport_pin; - - break :critical .{ - .full_rebuild = full_rebuild, - .gl_bg = self.background_color orelse self.default_background_color, - .screen = screen_copy, - .screen_type = state.terminal.active_screen, - .mouse = state.mouse, - .preedit = preedit, - .cursor_style = cursor_style, - .color_palette = state.terminal.color_palette.colors, - }; - }; - defer { - critical.screen.deinit(); - if (critical.preedit) |p| p.deinit(self.alloc); - } - - // Grab our draw mutex if we have it and update our data - { - if (single_threaded_draw) self.draw_mutex.lock(); - defer if (single_threaded_draw) self.draw_mutex.unlock(); - - // Set our draw data - self.draw_background = critical.gl_bg; - - // Build our GPU cells - try self.rebuildCells( - critical.full_rebuild, - &critical.screen, - critical.screen_type, - critical.mouse, - critical.preedit, - critical.cursor_style, - &critical.color_palette, - ); - - // Notify our shaper we're done for the frame. For some shapers like - // CoreText this triggers off-thread cleanup logic. - self.font_shaper.endFrame(); - } -} - -/// This goes through the Kitty graphic placements and accumulates the -/// placements we need to render on our viewport. It also ensures that -/// the visible images are loaded on the GPU. -fn prepKittyGraphics( - self: *OpenGL, - t: *terminal.Terminal, -) !void { - const storage = &t.screen.kitty_images; - defer storage.dirty = false; - - // We always clear our previous placements no matter what because - // we rebuild them from scratch. - self.image_placements.clearRetainingCapacity(); - self.image_virtual = false; - - // Go through our known images and if there are any that are no longer - // in use then mark them to be freed. - // - // This never conflicts with the below because a placement can't - // reference an image that doesn't exist. - { - var it = self.images.iterator(); - while (it.next()) |kv| { - if (storage.imageById(kv.key_ptr.*) == null) { - kv.value_ptr.image.markForUnload(); - } - } - } - - // The top-left and bottom-right corners of our viewport in screen - // points. This lets us determine offsets and containment of placements. - const top = t.screen.pages.getTopLeft(.viewport); - const bot = t.screen.pages.getBottomRight(.viewport).?; - const top_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; - const bot_y = t.screen.pages.pointFromPin(.screen, bot).?.screen.y; - - // Go through the placements and ensure the image is loaded on the GPU. - var it = storage.placements.iterator(); - while (it.next()) |kv| { - // Find the image in storage - const p = kv.value_ptr; - - // Special logic based on location - switch (p.location) { - .pin => {}, - .virtual => { - // We need to mark virtual placements on our renderer so that - // we know to rebuild in more scenarios since cell changes can - // now trigger placement changes. - self.image_virtual = true; - - // We also continue out because virtual placements are - // only triggered by the unicode placeholder, not by the - // placement itself. - continue; - }, - } - - const image = storage.imageById(kv.key_ptr.image_id) orelse { - log.warn( - "missing image for placement, ignoring image_id={}", - .{kv.key_ptr.image_id}, - ); - continue; - }; - - try self.prepKittyPlacement(t, top_y, bot_y, &image, p); - } - - // If we have virtual placements then we need to scan for placeholders. - if (self.image_virtual) { - var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot); - while (v_it.next()) |virtual_p| try self.prepKittyVirtualPlacement( - t, - &virtual_p, - ); - } - - // Sort the placements by their Z value. - std.mem.sortUnstable( - gl_image.Placement, - self.image_placements.items, - {}, - struct { - fn lessThan( - ctx: void, - lhs: gl_image.Placement, - rhs: gl_image.Placement, - ) bool { - _ = ctx; - return lhs.z < rhs.z or (lhs.z == rhs.z and lhs.image_id < rhs.image_id); - } - }.lessThan, - ); - - // Find our indices. The values are sorted by z so we can find the - // first placement out of bounds to find the limits. - var bg_end: ?u32 = null; - var text_end: ?u32 = null; - const bg_limit = std.math.minInt(i32) / 2; - for (self.image_placements.items, 0..) |p, i| { - if (bg_end == null and p.z >= bg_limit) { - bg_end = @intCast(i); - } - if (text_end == null and p.z >= 0) { - text_end = @intCast(i); - } - } - - self.image_bg_end = bg_end orelse 0; - self.image_text_end = text_end orelse self.image_bg_end; -} - -fn prepKittyVirtualPlacement( - self: *OpenGL, - t: *terminal.Terminal, - p: *const terminal.kitty.graphics.unicode.Placement, -) !void { - const storage = &t.screen.kitty_images; - const image = storage.imageById(p.image_id) orelse { - log.warn( - "missing image for virtual placement, ignoring image_id={}", - .{p.image_id}, - ); - return; - }; - - const rp = p.renderPlacement( - storage, - &image, - self.grid_metrics.cell_width, - self.grid_metrics.cell_height, - ) catch |err| { - log.warn("error rendering virtual placement err={}", .{err}); - return; - }; - - // If our placement is zero sized then we don't do anything. - if (rp.dest_width == 0 or rp.dest_height == 0) return; - - const viewport: terminal.point.Point = t.screen.pages.pointFromPin( - .viewport, - rp.top_left, - ) orelse { - // This is unreachable with virtual placements because we should - // only ever be looking at virtual placements that are in our - // viewport in the renderer and virtual placements only ever take - // up one row. - unreachable; - }; - - // Send our image to the GPU and store the placement for rendering. - try self.prepKittyImage(&image); - try self.image_placements.append(self.alloc, .{ - .image_id = image.id, - .x = @intCast(rp.top_left.x), - .y = @intCast(viewport.viewport.y), - .z = -1, - .width = rp.dest_width, - .height = rp.dest_height, - .cell_offset_x = rp.offset_x, - .cell_offset_y = rp.offset_y, - .source_x = rp.source_x, - .source_y = rp.source_y, - .source_width = rp.source_width, - .source_height = rp.source_height, - }); -} - -fn prepKittyPlacement( - self: *OpenGL, - t: *terminal.Terminal, - top_y: u32, - bot_y: u32, - image: *const terminal.kitty.graphics.Image, - p: *const terminal.kitty.graphics.ImageStorage.Placement, -) !void { - // Get the rect for the placement. If this placement doesn't have - // a rect then its virtual or something so skip it. - const rect = p.rect(image.*, t) orelse return; - - // This is expensive but necessary. - const img_top_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; - const img_bot_y = t.screen.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; - - // If the selection isn't within our viewport then skip it. - if (img_top_y > bot_y) return; - if (img_bot_y < top_y) return; - - // We need to prep this image for upload if it isn't in the cache OR - // it is in the cache but the transmit time doesn't match meaning this - // image is different. - try self.prepKittyImage(image); - - // Calculate the dimensions of our image, taking in to - // account the rows / columns specified by the placement. - const dest_size = p.calculatedSize(image.*, t); - - // Calculate the source rectangle - const source_x = @min(image.width, p.source_x); - const source_y = @min(image.height, p.source_y); - const source_width = if (p.source_width > 0) - @min(image.width - source_x, p.source_width) - else - image.width; - const source_height = if (p.source_height > 0) - @min(image.height - source_y, p.source_height) - else - image.height; - - // Get the viewport-relative Y position of the placement. - const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y)); - - // Accumulate the placement - if (dest_size.width > 0 and dest_size.height > 0) { - try self.image_placements.append(self.alloc, .{ - .image_id = image.id, - .x = @intCast(rect.top_left.x), - .y = y_pos, - .z = p.z, - .width = dest_size.width, - .height = dest_size.height, - .cell_offset_x = p.x_offset, - .cell_offset_y = p.y_offset, - .source_x = source_x, - .source_y = source_y, - .source_width = source_width, - .source_height = source_height, - }); - } -} - -fn prepKittyImage( - self: *OpenGL, - image: *const terminal.kitty.graphics.Image, -) !void { - // We need to prep this image for upload if it isn't in the cache OR - // it is in the cache but the transmit time doesn't match meaning this - // image is different. - const gop = try self.images.getOrPut(self.alloc, image.id); - if (gop.found_existing and - gop.value_ptr.transmit_time.order(image.transmit_time) == .eq) - { - return; - } - - // Copy the data into the pending state. - const data = try self.alloc.dupe(u8, image.data); - errdefer self.alloc.free(data); - - // Store it in the map - const pending: Image.Pending = .{ - .width = image.width, - .height = image.height, - .data = data.ptr, - }; - - const new_image: Image = switch (image.format) { - .gray => .{ .pending_gray = pending }, - .gray_alpha => .{ .pending_gray_alpha = pending }, - .rgb => .{ .pending_rgb = pending }, - .rgba => .{ .pending_rgba = pending }, - .png => unreachable, // should be decoded by now - }; - - if (!gop.found_existing) { - gop.value_ptr.* = .{ - .image = new_image, - .transmit_time = undefined, - }; - } else { - try gop.value_ptr.image.markForReplace( - self.alloc, - new_image, - ); - } - - gop.value_ptr.transmit_time = image.transmit_time; -} - -/// rebuildCells rebuilds all the GPU cells from our CPU state. This is a -/// slow operation but ensures that the GPU state exactly matches the CPU state. -/// In steady-state operation, we use some GPU tricks to send down stale data -/// that is ignored. This accumulates more memory; rebuildCells clears it. -/// -/// Note this doesn't have to typically be manually called. Internally, -/// the renderer will do this when it needs more memory space. -pub fn rebuildCells( - self: *OpenGL, - rebuild: bool, - screen: *terminal.Screen, - screen_type: terminal.ScreenType, - mouse: renderer.State.Mouse, - preedit: ?renderer.State.Preedit, - cursor_style_: ?renderer.CursorStyle, - color_palette: *const terminal.color.Palette, -) !void { - _ = screen_type; - - // Bg cells at most will need space for the visible screen size - self.cells_bg.clearRetainingCapacity(); - self.cells.clearRetainingCapacity(); - - // Create an arena for all our temporary allocations while rebuilding - var arena = ArenaAllocator.init(self.alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); - - // We've written no data to the GPU, refresh it all - self.gl_cells_written = 0; - - // Create our match set for the links. - var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( - arena_alloc, - screen, - mouse_pt, - mouse.mods, - ) else .{}; - - // Determine our x/y range for preedit. We don't want to render anything - // here because we will render the preedit separately. - const preedit_range: ?struct { - y: terminal.size.CellCountInt, - x: [2]terminal.size.CellCountInt, - cp_offset: usize, - } = if (preedit) |preedit_v| preedit: { - const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1); - break :preedit .{ - .y = screen.cursor.y, - .x = .{ range.start, range.end }, - .cp_offset = range.cp_offset, - }; - } else null; - - // These are all the foreground cells underneath the cursor. - // - // We keep track of these so that we can invert the colors and move them - // in front of the block cursor so that the character remains visible. - // - // We init with a capacity of 4 to account for decorations such - // as underline and strikethrough, as well as combining chars. - var cursor_cells = try std.ArrayListUnmanaged(CellProgram.Cell).initCapacity(arena_alloc, 4); - defer cursor_cells.deinit(arena_alloc); - - if (rebuild) { - switch (self.config.padding_color) { - .background => {}, - - .extend, .@"extend-always" => { - self.padding_extend_top = true; - self.padding_extend_bottom = true; - }, - } - } - - const grid_size = self.size.grid(); - - // We rebuild the cells row-by-row because we do font shaping by row. - var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null); - // If our cell contents buffer is shorter than the screen viewport, - // we render the rows that fit, starting from the bottom. If instead - // the viewport is shorter than the cell contents buffer, we align - // the top of the viewport with the top of the contents buffer. - var y: terminal.size.CellCountInt = @min( - screen.pages.rows, - grid_size.rows, - ); - while (row_it.next()) |row| { - // The viewport may have more rows than our cell contents, - // so we need to break from the loop early if we hit y = 0. - if (y == 0) break; - - y -= 1; - - // True if we want to do font shaping around the cursor. We want to - // do font shaping as long as the cursor is enabled. - const shape_cursor = screen.viewportIsBottom() and - y == screen.cursor.y; - - // If this is the row with our cursor, then we may have to modify - // the cell with the cursor. - const start_i: usize = self.cells.items.len; - defer if (shape_cursor and cursor_style_ == .block) { - const x = screen.cursor.x; - const wide = row.cells(.all)[x].wide; - const min_x = switch (wide) { - .narrow, .spacer_head, .wide => x, - .spacer_tail => x -| 1, - }; - const max_x = switch (wide) { - .narrow, .spacer_head, .spacer_tail => x, - .wide => x +| 1, - }; - for (self.cells.items[start_i..]) |cell| { - if (cell.grid_col < min_x or cell.grid_col > max_x) continue; - if (cell.mode.isFg()) { - cursor_cells.append(arena_alloc, cell) catch { - // We silently ignore if this fails because - // worst case scenario some combining glyphs - // aren't visible under the cursor '\_('-')_/' - }; - } - } - }; - - // We need to get this row's selection if there is one for proper - // run splitting. - const row_selection = sel: { - const sel = screen.selection orelse break :sel null; - const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse - break :sel null; - break :sel sel.containedRow(screen, pin) orelse null; - }; - - // On primary screen, we still apply vertical padding extension - // under certain conditions we feel are safe. This helps make some - // scenarios look better while avoiding scenarios we know do NOT look - // good. - switch (self.config.padding_color) { - // These already have the correct values set above. - .background, .@"extend-always" => {}, - - // Apply heuristics for padding extension. - .extend => if (y == 0) { - self.padding_extend_top = !row.neverExtendBg( - color_palette, - self.background_color orelse self.default_background_color, - ); - } else if (y == self.size.grid().rows - 1) { - self.padding_extend_bottom = !row.neverExtendBg( - color_palette, - self.background_color orelse self.default_background_color, - ); - }, - } - - // Iterator of runs for shaping. - var run_iter = self.font_shaper.runIterator( - self.font_grid, - screen, - row, - row_selection, - if (shape_cursor) screen.cursor.x else null, - ); - var shaper_run: ?font.shape.TextRun = try run_iter.next(self.alloc); - var shaper_cells: ?[]const font.shape.Cell = null; - var shaper_cells_i: usize = 0; - - const row_cells_all = row.cells(.all); - - // If our viewport is wider than our cell contents buffer, - // we still only process cells up to the width of the buffer. - const row_cells = row_cells_all[0..@min(row_cells_all.len, grid_size.columns)]; - - for (row_cells, 0..) |*cell, x| { - // If this cell falls within our preedit range then we - // skip this because preedits are setup separately. - if (preedit_range) |range| preedit: { - // We're not on the preedit line, no actions necessary. - if (range.y != y) break :preedit; - // We're before the preedit range, no actions necessary. - if (x < range.x[0]) break :preedit; - // We're in the preedit range, skip this cell. - if (x <= range.x[1]) continue; - // After exiting the preedit range we need to catch - // the run position up because of the missed cells. - // In all other cases, no action is necessary. - if (x != range.x[1] + 1) break :preedit; - - // Step the run iterator until we find a run that ends - // after the current cell, which will be the soonest run - // that might contain glyphs for our cell. - while (shaper_run) |run| { - if (run.offset + run.cells > x) break; - shaper_run = try run_iter.next(self.alloc); - shaper_cells = null; - shaper_cells_i = 0; - } - - const run = shaper_run orelse break :preedit; - - // If we haven't shaped this run, do so now. - shaper_cells = shaper_cells orelse - // Try to read the cells from the shaping cache if we can. - self.font_shaper_cache.get(run) orelse - cache: { - // Otherwise we have to shape them. - const cells = try self.font_shaper.shape(run); - - // Try to cache them. If caching fails for any reason we - // continue because it is just a performance optimization, - // not a correctness issue. - self.font_shaper_cache.put( - self.alloc, - run, - cells, - ) catch |err| { - log.warn( - "error caching font shaping results err={}", - .{err}, - ); - }; - - // The cells we get from direct shaping are always owned - // by the shaper and valid until the next shaping call so - // we can safely use them. - break :cache cells; - }; - - // Advance our index until we reach or pass - // our current x position in the shaper cells. - while (shaper_cells.?[shaper_cells_i].x < x) { - shaper_cells_i += 1; - } - } - - const wide = cell.wide; - - const style = row.style(cell); - - const cell_pin: terminal.Pin = cell: { - var copy = row; - copy.x = @intCast(x); - break :cell copy; - }; - - // True if this cell is selected - const selected: bool = if (screen.selection) |sel| - sel.contains(screen, .{ - .node = row.node, - .y = row.y, - .x = @intCast( - // Spacer tails should show the selection - // state of the wide cell they belong to. - if (wide == .spacer_tail) - x -| 1 - else - x, - ), - }) - else - false; - - const bg_style = style.bg(cell, color_palette); - const fg_style = style.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color; - - // The final background color for the cell. - const bg = bg: { - if (selected) { - break :bg if (self.config.invert_selection_fg_bg) - if (style.flags.inverse) - // Cell is selected with invert selection fg/bg - // enabled, and the cell has the inverse style - // flag, so they cancel out and we get the normal - // bg color. - bg_style - else - // If it doesn't have the inverse style - // flag then we use the fg color instead. - fg_style - else - // If we don't have invert selection fg/bg set then we - // just use the selection background if set, otherwise - // the default fg color. - break :bg self.config.selection_background orelse self.foreground_color orelse self.default_foreground_color; - } - - // Not selected - break :bg if (style.flags.inverse != isCovering(cell.codepoint())) - // Two cases cause us to invert (use the fg color as the bg) - // - The "inverse" style flag. - // - A "covering" glyph; we use fg for bg in that case to - // help make sure that padding extension works correctly. - // If one of these is true (but not the other) - // then we use the fg style color for the bg. - fg_style - else - // Otherwise they cancel out. - bg_style; - }; - - const fg = fg: { - if (selected and !self.config.invert_selection_fg_bg) { - // If we don't have invert selection fg/bg set - // then we just use the selection foreground if - // set, otherwise the default bg color. - break :fg self.config.selection_foreground orelse self.background_color orelse self.default_background_color; - } - - // Whether we need to use the bg color as our fg color: - // - Cell is inverted and not selected - // - Cell is selected and not inverted - // Note: if selected then invert sel fg / bg must be - // false since we separately handle it if true above. - break :fg if (style.flags.inverse != selected) - bg_style orelse self.background_color orelse self.default_background_color - else - fg_style; - }; - - // Foreground alpha for this cell. - const alpha: u8 = if (style.flags.faint) 175 else 255; - - // If the cell has a background color, set it. - const bg_color: [4]u8 = if (bg) |rgb| bg: { - // Determine our background alpha. If we have transparency configured - // then this is dynamic depending on some situations. This is all - // in an attempt to make transparency look the best for various - // situations. See inline comments. - const bg_alpha: u8 = bg_alpha: { - const default: u8 = 255; - - if (self.config.background_opacity >= 1) break :bg_alpha default; - - // If we're selected, we do not apply background opacity - if (selected) break :bg_alpha default; - - // If we're reversed, do not apply background opacity - if (style.flags.inverse) break :bg_alpha default; - - // If we have a background and its not the default background - // then we apply background opacity - if (style.bg(cell, color_palette) != null and !rgb.eql(self.background_color orelse self.default_background_color)) { - break :bg_alpha default; - } - - // We apply background opacity. - var bg_alpha: f64 = @floatFromInt(default); - bg_alpha *= self.config.background_opacity; - bg_alpha = @ceil(bg_alpha); - break :bg_alpha @intFromFloat(bg_alpha); - }; - - try self.cells_bg.append(self.alloc, .{ - .mode = .bg, - .grid_col = @intCast(x), - .grid_row = @intCast(y), - .grid_width = cell.gridWidth(), - .glyph_x = 0, - .glyph_y = 0, - .glyph_width = 0, - .glyph_height = 0, - .glyph_offset_x = 0, - .glyph_offset_y = 0, - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - .a = bg_alpha, - .bg_r = 0, - .bg_g = 0, - .bg_b = 0, - .bg_a = 0, - }); - - break :bg .{ - rgb.r, rgb.g, rgb.b, bg_alpha, - }; - } else .{ - self.draw_background.r, - self.draw_background.g, - self.draw_background.b, - @intFromFloat(@max(0, @min(255, @round(self.config.background_opacity * 255)))), - }; - - // If the invisible flag is set on this cell then we - // don't need to render any foreground elements, so - // we just skip all glyphs with this x coordinate. - // - // NOTE: This behavior matches xterm. Some other terminal - // emulators, e.g. Alacritty, still render text decorations - // and only make the text itself invisible. The decision - // has been made here to match xterm's behavior for this. - if (style.flags.invisible) { - continue; - } - - // Give links a single underline, unless they already have - // an underline, in which case use a double underline to - // distinguish them. - const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin)) - if (style.flags.underline == .single) - .double - else - .single - else - style.flags.underline; - - // We draw underlines first so that they layer underneath text. - // This improves readability when a colored underline is used - // which intersects parts of the text (descenders). - if (underline != .none) self.addUnderline( - @intCast(x), - @intCast(y), - underline, - style.underlineColor(color_palette) orelse fg, - alpha, - bg_color, - ) catch |err| { - log.warn( - "error adding underline to cell, will be invalid x={} y={}, err={}", - .{ x, y, err }, - ); - }; - - if (style.flags.overline) self.addOverline( - @intCast(x), - @intCast(y), - fg, - alpha, - bg_color, - ) catch |err| { - log.warn( - "error adding overline to cell, will be invalid x={} y={}, err={}", - .{ x, y, err }, - ); - }; - - // If we're at or past the end of our shaper run then - // we need to get the next run from the run iterator. - if (shaper_cells != null and shaper_cells_i >= shaper_cells.?.len) { - shaper_run = try run_iter.next(self.alloc); - shaper_cells = null; - shaper_cells_i = 0; - } - - if (shaper_run) |run| glyphs: { - // If we haven't shaped this run yet, do so. - shaper_cells = shaper_cells orelse - // Try to read the cells from the shaping cache if we can. - self.font_shaper_cache.get(run) orelse - cache: { - // Otherwise we have to shape them. - const cells = try self.font_shaper.shape(run); - - // Try to cache them. If caching fails for any reason we - // continue because it is just a performance optimization, - // not a correctness issue. - self.font_shaper_cache.put( - self.alloc, - run, - cells, - ) catch |err| { - log.warn( - "error caching font shaping results err={}", - .{err}, - ); - }; - - // The cells we get from direct shaping are always owned - // by the shaper and valid until the next shaping call so - // we can safely use them. - break :cache cells; - }; - - const cells = shaper_cells orelse break :glyphs; - - // If there are no shaper cells for this run, ignore it. - // This can occur for runs of empty cells, and is fine. - if (cells.len == 0) break :glyphs; - - // If we encounter a shaper cell to the left of the current - // cell then we have some problems. This logic relies on x - // position monotonically increasing. - assert(cells[shaper_cells_i].x >= x); - - // NOTE: An assumption is made here that a single cell will never - // be present in more than one shaper run. If that assumption is - // violated, this logic breaks. - - while (shaper_cells_i < cells.len and cells[shaper_cells_i].x == x) : ({ - shaper_cells_i += 1; - }) { - self.addGlyph( - @intCast(x), - @intCast(y), - cell_pin, - cells[shaper_cells_i], - shaper_run.?, - fg, - alpha, - bg_color, - ) catch |err| { - log.warn( - "error adding glyph to cell, will be invalid x={} y={}, err={}", - .{ x, y, err }, - ); - }; - } - } - - // Finally, draw a strikethrough if necessary. - if (style.flags.strikethrough) self.addStrikethrough( - @intCast(x), - @intCast(y), - fg, - alpha, - bg_color, - ) catch |err| { - log.warn( - "error adding strikethrough to cell, will be invalid x={} y={}, err={}", - .{ x, y, err }, - ); - }; - } - } - - // Add the cursor at the end so that it overlays everything. If we have - // a cursor cell then we invert the colors on that and add it in so - // that we can always see it. - if (cursor_style_) |cursor_style| cursor_style: { - // If we have a preedit, we try to render the preedit text on top - // of the cursor. - if (preedit) |preedit_v| { - const range = preedit_range.?; - var x = range.x[0]; - for (preedit_v.codepoints[range.cp_offset..]) |cp| { - self.addPreeditCell(cp, x, range.y) catch |err| { - log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{ - x, - range.y, - err, - }); - }; - - x += if (cp.wide) 2 else 1; - } - - // Preedit hides the cursor - break :cursor_style; - } - - const cursor_color = self.cursor_color orelse self.default_cursor_color orelse color: { - if (self.cursor_invert) { - // Use the foreground color from the cell under the cursor, if any. - const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - break :color if (sty.flags.inverse) - // If the cell is reversed, use background color instead. - (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color) - else - (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color); - } else { - break :color self.foreground_color orelse self.default_foreground_color; - } - }; - - _ = try self.addCursor(screen, cursor_style, cursor_color); - for (cursor_cells.items) |*cell| { - if (cell.mode.isFg() and cell.mode != .fg_color) { - const cell_color = if (self.cursor_invert) blk: { - // Use the background color from the cell under the cursor, if any. - const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - break :blk if (sty.flags.inverse) - // If the cell is reversed, use foreground color instead. - (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color) - else - (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color); - } else if (self.config.cursor_text) |txt| - txt - else - self.background_color orelse self.default_background_color; - - cell.r = cell_color.r; - cell.g = cell_color.g; - cell.b = cell_color.b; - cell.a = 255; - } - try self.cells.append(self.alloc, cell.*); - } - } - - // Free up memory, generally in case where surface has shrunk. - // If more than half of the capacity is unused, remove all unused capacity. - if (self.cells.items.len * 2 < self.cells.capacity) { - self.cells.shrinkAndFree(self.alloc, self.cells.items.len); - } - if (self.cells_bg.items.len * 2 < self.cells_bg.capacity) { - self.cells_bg.shrinkAndFree(self.alloc, self.cells_bg.items.len); - } - - // Some debug mode safety checks - if (std.debug.runtime_safety) { - for (self.cells_bg.items) |cell| assert(cell.mode == .bg); - for (self.cells.items) |cell| assert(cell.mode != .bg); - } -} - -fn addPreeditCell( - self: *OpenGL, - cp: renderer.State.Preedit.Codepoint, - x: usize, - y: usize, -) !void { - // Preedit is rendered inverted - const bg = self.foreground_color orelse self.default_foreground_color; - const fg = self.background_color orelse self.default_background_color; - - // Render the glyph for our preedit text - const render_ = self.font_grid.renderCodepoint( - self.alloc, - @intCast(cp.codepoint), - .regular, - .text, - .{ .grid_metrics = self.grid_metrics }, - ) catch |err| { - log.warn("error rendering preedit glyph err={}", .{err}); - return; - }; - const render = render_ orelse { - log.warn("failed to find font for preedit codepoint={X}", .{cp.codepoint}); - return; - }; - - // Add our opaque background cell - try self.cells_bg.append(self.alloc, .{ - .mode = .bg, - .grid_col = @intCast(x), - .grid_row = @intCast(y), - .grid_width = if (cp.wide) 2 else 1, - .glyph_x = 0, - .glyph_y = 0, - .glyph_width = 0, - .glyph_height = 0, - .glyph_offset_x = 0, - .glyph_offset_y = 0, - .r = bg.r, - .g = bg.g, - .b = bg.b, - .a = 255, - .bg_r = 0, - .bg_g = 0, - .bg_b = 0, - .bg_a = 0, - }); - - // Add our text - try self.cells.append(self.alloc, .{ - .mode = .fg, - .grid_col = @intCast(x), - .grid_row = @intCast(y), - .grid_width = if (cp.wide) 2 else 1, - .glyph_x = render.glyph.atlas_x, - .glyph_y = render.glyph.atlas_y, - .glyph_width = render.glyph.width, - .glyph_height = render.glyph.height, - .glyph_offset_x = render.glyph.offset_x, - .glyph_offset_y = render.glyph.offset_y, - .r = fg.r, - .g = fg.g, - .b = fg.b, - .a = 255, - .bg_r = bg.r, - .bg_g = bg.g, - .bg_b = bg.b, - .bg_a = 255, - }); -} - -fn addCursor( - self: *OpenGL, - screen: *terminal.Screen, - cursor_style: renderer.CursorStyle, - cursor_color: terminal.color.RGB, -) !?*const CellProgram.Cell { - // Add the cursor. We render the cursor over the wide character if - // we're on the wide character tail. - const wide, const x = cell: { - // The cursor goes over the screen cursor position. - const cell = screen.cursor.page_cell; - if (cell.wide != .spacer_tail or screen.cursor.x == 0) - break :cell .{ cell.wide == .wide, screen.cursor.x }; - - // If we're part of a wide character, we move the cursor back to - // the actual character. - const prev_cell = screen.cursorCellLeft(1); - break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 }; - }; - - const alpha: u8 = if (!self.focused) 255 else alpha: { - const alpha = 255 * self.config.cursor_opacity; - break :alpha @intFromFloat(@ceil(alpha)); - }; - - const render = switch (cursor_style) { - .block, - .block_hollow, - .bar, - .underline, - => render: { - const sprite: font.Sprite = switch (cursor_style) { - .block => .cursor_rect, - .block_hollow => .cursor_hollow_rect, - .bar => .cursor_bar, - .underline => .underline, - .lock => unreachable, - }; - - break :render self.font_grid.renderGlyph( - self.alloc, - font.sprite_index, - @intFromEnum(sprite), - .{ - .cell_width = if (wide) 2 else 1, - .grid_metrics = self.grid_metrics, - }, - ) catch |err| { - log.warn("error rendering cursor glyph err={}", .{err}); - return null; - }; - }, - - .lock => self.font_grid.renderCodepoint( - self.alloc, - 0xF023, // lock symbol - .regular, - .text, - .{ - .cell_width = if (wide) 2 else 1, - .grid_metrics = self.grid_metrics, - }, - ) catch |err| { - log.warn("error rendering cursor glyph err={}", .{err}); - return null; - } orelse { - // This should never happen because we embed nerd - // fonts so we just log and return instead of fallback. - log.warn("failed to find lock symbol for cursor codepoint=0xF023", .{}); - return null; - }, - }; - - try self.cells.append(self.alloc, .{ - .mode = .fg, - .grid_col = @intCast(x), - .grid_row = @intCast(screen.cursor.y), - .grid_width = if (wide) 2 else 1, - .r = cursor_color.r, - .g = cursor_color.g, - .b = cursor_color.b, - .a = alpha, - .bg_r = 0, - .bg_g = 0, - .bg_b = 0, - .bg_a = 0, - .glyph_x = render.glyph.atlas_x, - .glyph_y = render.glyph.atlas_y, - .glyph_width = render.glyph.width, - .glyph_height = render.glyph.height, - .glyph_offset_x = render.glyph.offset_x, - .glyph_offset_y = render.glyph.offset_y, - }); - - return &self.cells.items[self.cells.items.len - 1]; -} - -/// Add an underline decoration to the specified cell -fn addUnderline( - self: *OpenGL, - x: terminal.size.CellCountInt, - y: terminal.size.CellCountInt, - style: terminal.Attribute.Underline, - color: terminal.color.RGB, - alpha: u8, - bg: [4]u8, -) !void { - const sprite: font.Sprite = switch (style) { - .none => unreachable, - .single => .underline, - .double => .underline_double, - .dotted => .underline_dotted, - .dashed => .underline_dashed, - .curly => .underline_curly, - }; - - const render = try self.font_grid.renderGlyph( - self.alloc, - font.sprite_index, - @intFromEnum(sprite), - .{ - .cell_width = 1, - .grid_metrics = self.grid_metrics, - }, - ); - - try self.cells.append(self.alloc, .{ - .mode = .fg, - .grid_col = @intCast(x), - .grid_row = @intCast(y), - .grid_width = 1, - .glyph_x = render.glyph.atlas_x, - .glyph_y = render.glyph.atlas_y, - .glyph_width = render.glyph.width, - .glyph_height = render.glyph.height, - .glyph_offset_x = render.glyph.offset_x, - .glyph_offset_y = render.glyph.offset_y, - .r = color.r, - .g = color.g, - .b = color.b, - .a = alpha, - .bg_r = bg[0], - .bg_g = bg[1], - .bg_b = bg[2], - .bg_a = bg[3], - }); -} - -/// Add an overline decoration to the specified cell -fn addOverline( - self: *OpenGL, - x: terminal.size.CellCountInt, - y: terminal.size.CellCountInt, - color: terminal.color.RGB, - alpha: u8, - bg: [4]u8, -) !void { - const render = try self.font_grid.renderGlyph( - self.alloc, - font.sprite_index, - @intFromEnum(font.Sprite.overline), - .{ - .cell_width = 1, - .grid_metrics = self.grid_metrics, - }, - ); - - try self.cells.append(self.alloc, .{ - .mode = .fg, - .grid_col = @intCast(x), - .grid_row = @intCast(y), - .grid_width = 1, - .glyph_x = render.glyph.atlas_x, - .glyph_y = render.glyph.atlas_y, - .glyph_width = render.glyph.width, - .glyph_height = render.glyph.height, - .glyph_offset_x = render.glyph.offset_x, - .glyph_offset_y = render.glyph.offset_y, - .r = color.r, - .g = color.g, - .b = color.b, - .a = alpha, - .bg_r = bg[0], - .bg_g = bg[1], - .bg_b = bg[2], - .bg_a = bg[3], - }); -} - -/// Add a strikethrough decoration to the specified cell -fn addStrikethrough( - self: *OpenGL, - x: terminal.size.CellCountInt, - y: terminal.size.CellCountInt, - color: terminal.color.RGB, - alpha: u8, - bg: [4]u8, -) !void { - const render = try self.font_grid.renderGlyph( - self.alloc, - font.sprite_index, - @intFromEnum(font.Sprite.strikethrough), - .{ - .cell_width = 1, - .grid_metrics = self.grid_metrics, - }, - ); - - try self.cells.append(self.alloc, .{ - .mode = .fg, - .grid_col = @intCast(x), - .grid_row = @intCast(y), - .grid_width = 1, - .glyph_x = render.glyph.atlas_x, - .glyph_y = render.glyph.atlas_y, - .glyph_width = render.glyph.width, - .glyph_height = render.glyph.height, - .glyph_offset_x = render.glyph.offset_x, - .glyph_offset_y = render.glyph.offset_y, - .r = color.r, - .g = color.g, - .b = color.b, - .a = alpha, - .bg_r = bg[0], - .bg_g = bg[1], - .bg_b = bg[2], - .bg_a = bg[3], - }); -} - -// Add a glyph to the specified cell. -fn addGlyph( - self: *OpenGL, - x: terminal.size.CellCountInt, - y: terminal.size.CellCountInt, - cell_pin: terminal.Pin, - shaper_cell: font.shape.Cell, - shaper_run: font.shape.TextRun, - color: terminal.color.RGB, - alpha: u8, - bg: [4]u8, -) !void { - const rac = cell_pin.rowAndCell(); - const cell = rac.cell; - - // Render - const render = try self.font_grid.renderGlyph( - self.alloc, - shaper_run.font_index, - shaper_cell.glyph_index, - .{ - .cell_width = if (cell.wide == .wide) 2 else 1, - .grid_metrics = self.grid_metrics, - .thicken = self.config.font_thicken, - .thicken_strength = self.config.font_thicken_strength, - }, - ); - - // If the glyph is 0 width or height, it will be invisible - // when drawn, so don't bother adding it to the buffer. - if (render.glyph.width == 0 or render.glyph.height == 0) { - return; - } - - // If we're rendering a color font, we use the color atlas - const mode: CellProgram.CellMode = switch (try fgMode( - render.presentation, - cell_pin, - )) { - .normal => .fg, - .color => .fg_color, - .constrained => .fg_constrained, - .powerline => .fg_powerline, - }; - - try self.cells.append(self.alloc, .{ - .mode = mode, - .grid_col = @intCast(x), - .grid_row = @intCast(y), - .grid_width = cell.gridWidth(), - .glyph_x = render.glyph.atlas_x, - .glyph_y = render.glyph.atlas_y, - .glyph_width = render.glyph.width, - .glyph_height = render.glyph.height, - .glyph_offset_x = render.glyph.offset_x + shaper_cell.x_offset, - .glyph_offset_y = render.glyph.offset_y + shaper_cell.y_offset, - .r = color.r, - .g = color.g, - .b = color.b, - .a = alpha, - .bg_r = bg[0], - .bg_g = bg[1], - .bg_b = bg[2], - .bg_a = bg[3], - }); -} - -/// Update the configuration. -pub fn changeConfig(self: *OpenGL, config: *DerivedConfig) !void { - // We always redo the font shaper in case font features changed. We - // could check to see if there was an actual config change but this is - // easier and rare enough to not cause performance issues. - { - var font_shaper = try font.Shaper.init(self.alloc, .{ - .features = config.font_features.items, - }); - errdefer font_shaper.deinit(); - self.font_shaper.deinit(); - self.font_shaper = font_shaper; - } - - // We also need to reset the shaper cache so shaper info - // from the previous font isn't re-used for the new font. - const font_shaper_cache = font.ShaperCache.init(); - self.font_shaper_cache.deinit(self.alloc); - self.font_shaper_cache = font_shaper_cache; - - // Set our new colors - self.default_background_color = config.background; - self.default_foreground_color = config.foreground; - self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null; - self.cursor_invert = config.cursor_invert; - - // Update our uniforms - self.deferred_config = .{}; - - self.config.deinit(); - self.config = config.*; -} - -/// Set the screen size for rendering. This will update the projection -/// used for the shader so that the scaling of the grid is correct. -pub fn setScreenSize( - self: *OpenGL, - size: renderer.Size, -) !void { - if (single_threaded_draw) self.draw_mutex.lock(); - defer if (single_threaded_draw) self.draw_mutex.unlock(); - - // Store our screen size - self.size = size; - - // Defer our OpenGL updates - self.deferred_screen_size = .{ .size = size }; - - log.debug("screen size size={}", .{size}); -} - -/// Updates the font texture atlas if it is dirty. -fn flushAtlas(self: *OpenGL) !void { - const gl_state = self.gl_state orelse return; - try flushAtlasSingle( - &self.font_grid.lock, - gl_state.texture, - &self.font_grid.atlas_grayscale, - &self.texture_grayscale_modified, - &self.texture_grayscale_resized, - .red, - .red, - ); - try flushAtlasSingle( - &self.font_grid.lock, - gl_state.texture_color, - &self.font_grid.atlas_color, - &self.texture_color_modified, - &self.texture_color_resized, - .rgba, - .bgra, - ); -} - -/// Flush a single atlas, grabbing all necessary locks, checking for -/// changes, etc. -fn flushAtlasSingle( - lock: *std.Thread.RwLock, - texture: gl.Texture, - atlas: *font.Atlas, - modified: *usize, - resized: *usize, - internal_format: gl.Texture.InternalFormat, - format: gl.Texture.Format, -) !void { - // If the texture isn't modified we do nothing - const new_modified = atlas.modified.load(.monotonic); - if (new_modified <= modified.*) return; - - // If it is modified we need to grab a read-lock - lock.lockShared(); - defer lock.unlockShared(); - - var texbind = try texture.bind(.@"2D"); - defer texbind.unbind(); - - const new_resized = atlas.resized.load(.monotonic); - if (new_resized > resized.*) { - try texbind.image2D( - 0, - internal_format, - @intCast(atlas.size), - @intCast(atlas.size), - 0, - format, - .UnsignedByte, - atlas.data.ptr, - ); - - // Only update the resized number after successful resize - resized.* = new_resized; - } else { - try texbind.subImage2D( - 0, - 0, - 0, - @intCast(atlas.size), - @intCast(atlas.size), - format, - .UnsignedByte, - atlas.data.ptr, - ); - } - - // Update our modified tracker after successful update - modified.* = atlas.modified.load(.monotonic); -} - -/// Render renders the current cell state. This will not modify any of -/// the cells. -pub fn drawFrame(self: *OpenGL, surface: *apprt.Surface) !void { - // If we're in single-threaded more we grab a lock since we use shared data. - if (single_threaded_draw) self.draw_mutex.lock(); - defer if (single_threaded_draw) self.draw_mutex.unlock(); - const gl_state: *GLState = if (self.gl_state) |*v| v else return; - - // Go through our images and see if we need to setup any textures. - { - var image_it = self.images.iterator(); - while (image_it.next()) |kv| { - switch (kv.value_ptr.image) { - .ready => {}, - - .pending_gray, - .pending_gray_alpha, - .pending_rgb, - .pending_rgba, - .replace_gray, - .replace_gray_alpha, - .replace_rgb, - .replace_rgba, - => try kv.value_ptr.image.upload(self.alloc), - - .unload_pending, - .unload_replace, - .unload_ready, - => { - kv.value_ptr.image.deinit(self.alloc); - self.images.removeByPtr(kv.key_ptr); - }, - } - } - } - - // In the "OpenGL Programming Guide for Mac" it explains that: "When you - // use an NSOpenGLView object with OpenGL calls that are issued from a - // thread other than the main one, you must set up mutex locking." - // This locks the context and avoids crashes that can happen due to - // races with the underlying Metal layer that Apple is using to - // implement OpenGL. - const is_darwin = builtin.target.os.tag.isDarwin(); - const ogl = if (comptime is_darwin) @cImport({ - @cInclude("OpenGL/OpenGL.h"); - }) else {}; - const cgl_ctx = if (comptime is_darwin) ogl.CGLGetCurrentContext(); - if (comptime is_darwin) _ = ogl.CGLLockContext(cgl_ctx); - defer _ = if (comptime is_darwin) ogl.CGLUnlockContext(cgl_ctx); - - // If our viewport size doesn't match the saved screen size then - // we need to update it. We rely on this over setScreenSize because - // we can pull it directly from the OpenGL context instead of relying - // on the eventual message. - { - var viewport: [4]gl.c.GLint = undefined; - gl.glad.context.GetIntegerv.?(gl.c.GL_VIEWPORT, &viewport); - const screen: renderer.ScreenSize = .{ - .width = @intCast(viewport[2]), - .height = @intCast(viewport[3]), - }; - if (!screen.equals(self.size.screen)) { - self.size.screen = screen; - self.deferred_screen_size = .{ .size = self.size }; - } - } - - // Draw our terminal cells - try self.drawCellProgram(gl_state); - - // Draw our custom shaders - if (gl_state.custom) |*custom_state| { - try self.drawCustomPrograms(custom_state); - } - - // Swap our window buffers switch (apprt.runtime) { - apprt.glfw => surface.window.swapBuffers(), - apprt.gtk => {}, - apprt.embedded => {}, - else => @compileError("unsupported runtime"), + apprt.gtk => prepareContext(null) catch |err| { + log.warn( + "Error preparing GL context in displayRealized, err={}", + .{err}, + ); + }, + + else => @compileError("only GTK should be calling displayRealized"), } } -/// Draw the custom shaders. -fn drawCustomPrograms(self: *OpenGL, custom_state: *custom.State) !void { - _ = self; - assert(custom_state.programs.len > 0); - - // Bind our state that is global to all custom shaders - const custom_bind = try custom_state.bind(); - defer custom_bind.unbind(); - - // Setup the new frame - try custom_state.newFrame(); - - // Go through each custom shader and draw it. - for (custom_state.programs) |program| { - const bind = try program.bind(); - defer bind.unbind(); - try bind.draw(); - try custom_state.copyFramebuffer(); - } -} - -/// Runs the cell program (shaders) to draw the terminal grid. -fn drawCellProgram( - self: *OpenGL, - gl_state: *const GLState, -) !void { - // Try to flush our atlas, this will only do something if there - // are changes to the atlas. - try self.flushAtlas(); - - // If we have custom shaders, then we draw to the custom - // shader framebuffer. - const fbobind: ?gl.Framebuffer.Binding = fbobind: { - const state = gl_state.custom orelse break :fbobind null; - break :fbobind try state.fbo.bind(.framebuffer); - }; - defer if (fbobind) |v| v.unbind(); - - // Clear the surface - gl.clearColor( - @floatCast(@as(f32, @floatFromInt(self.draw_background.r)) / 255 * self.config.background_opacity), - @floatCast(@as(f32, @floatFromInt(self.draw_background.g)) / 255 * self.config.background_opacity), - @floatCast(@as(f32, @floatFromInt(self.draw_background.b)) / 255 * self.config.background_opacity), - @floatCast(self.config.background_opacity), - ); - gl.clear(gl.c.GL_COLOR_BUFFER_BIT); - - // If we have deferred operations, run them. - if (self.deferred_screen_size) |v| { - try v.apply(self); - self.deferred_screen_size = null; - } - if (self.deferred_font_size) |v| { - try v.apply(self); - self.deferred_font_size = null; - } - if (self.deferred_config) |v| { - try v.apply(self); - self.deferred_config = null; - } - - // Apply our padding extension fields - { - const program = gl_state.cell_program; - const bind = try program.program.use(); - defer bind.unbind(); - try program.program.setUniform( - "padding_vertical_top", - self.padding_extend_top, - ); - try program.program.setUniform( - "padding_vertical_bottom", - self.padding_extend_bottom, - ); - } - - // Draw background images first - try self.drawImages( - gl_state, - self.image_placements.items[0..self.image_bg_end], - ); - - // Draw our background - try self.drawCells(gl_state, self.cells_bg); - - // Then draw images under text - try self.drawImages( - gl_state, - self.image_placements.items[self.image_bg_end..self.image_text_end], - ); - - // Drag foreground - try self.drawCells(gl_state, self.cells); - - // Draw remaining images - try self.drawImages( - gl_state, - self.image_placements.items[self.image_text_end..], - ); -} - -/// Runs the image program to draw images. -fn drawImages( - self: *OpenGL, - gl_state: *const GLState, - placements: []const gl_image.Placement, -) !void { - if (placements.len == 0) return; - - // Bind our image program - const bind = try gl_state.image_program.bind(); - defer bind.unbind(); - - // For each placement we need to bind the texture - for (placements) |p| { - // Get the image and image texture - const image = self.images.get(p.image_id) orelse { - log.warn("image not found for placement image_id={}", .{p.image_id}); - continue; - }; - - const texture = switch (image.image) { - .ready => |t| t, - else => { - log.warn("image not ready for placement image_id={}", .{p.image_id}); - continue; - }, - }; - - // Bind the texture - try gl.Texture.active(gl.c.GL_TEXTURE0); - var texbind = try texture.bind(.@"2D"); - defer texbind.unbind(); - - // Setup our data - try bind.vbo.setData(ImageProgram.Input{ - .grid_col = p.x, - .grid_row = p.y, - .cell_offset_x = p.cell_offset_x, - .cell_offset_y = p.cell_offset_y, - .source_x = p.source_x, - .source_y = p.source_y, - .source_width = p.source_width, - .source_height = p.source_height, - .dest_width = p.width, - .dest_height = p.height, - }, .static_draw); - - try gl.drawElementsInstanced( - gl.c.GL_TRIANGLES, - 6, - gl.c.GL_UNSIGNED_BYTE, - 1, - ); - } -} - -/// Loads some set of cell data into our buffer and issues a draw call. -/// This expects all the OpenGL state to be setup. +/// Actions taken before doing anything in `drawFrame`. /// -/// Future: when we move to multiple shaders, this will go away and -/// we'll have a draw call per-shader. -fn drawCells( - self: *OpenGL, - gl_state: *const GLState, - cells: std.ArrayListUnmanaged(CellProgram.Cell), -) !void { - // If we have no cells to render, then we render nothing. - if (cells.items.len == 0) return; +/// Right now there's nothing we need to do for OpenGL. +pub fn drawFrameStart(self: *OpenGL) void { + _ = self; +} - // Todo: get rid of this completely - self.gl_cells_written = 0; +/// Actions taken after `drawFrame` is done. +/// +/// Right now there's nothing we need to do for OpenGL. +pub fn drawFrameEnd(self: *OpenGL) void { + _ = self; +} - // Bind our cell program state, buffers - const bind = try gl_state.cell_program.bind(); - defer bind.unbind(); - - // Bind our textures - try gl.Texture.active(gl.c.GL_TEXTURE0); - var texbind = try gl_state.texture.bind(.@"2D"); - defer texbind.unbind(); - - try gl.Texture.active(gl.c.GL_TEXTURE1); - var texbind1 = try gl_state.texture_color.bind(.@"2D"); - defer texbind1.unbind(); - - // Our allocated buffer on the GPU is smaller than our capacity. - // We reallocate a new buffer with the full new capacity. - if (self.gl_cells_size < cells.capacity) { - log.info("reallocating GPU buffer old={} new={}", .{ - self.gl_cells_size, - cells.capacity, - }); - - try bind.vbo.setDataNullManual( - @sizeOf(CellProgram.Cell) * cells.capacity, - .static_draw, - ); - - self.gl_cells_size = cells.capacity; - self.gl_cells_written = 0; - } - - // If we have data to write to the GPU, send it. - if (self.gl_cells_written < cells.items.len) { - const data = cells.items[self.gl_cells_written..]; - // log.info("sending {} cells to GPU", .{data.len}); - try bind.vbo.setSubData(self.gl_cells_written * @sizeOf(CellProgram.Cell), data); - - self.gl_cells_written += data.len; - assert(data.len > 0); - assert(self.gl_cells_written <= cells.items.len); - } - - try gl.drawElementsInstanced( - gl.c.GL_TRIANGLES, - 6, - gl.c.GL_UNSIGNED_BYTE, - cells.items.len, +pub fn initShaders( + self: *const OpenGL, + alloc: Allocator, + custom_shaders: []const [:0]const u8, +) !shaders.Shaders { + _ = alloc; + return try shaders.Shaders.init( + self.alloc, + custom_shaders, ); } -/// The OpenGL objects that are associated with a renderer. This makes it -/// easy to create/destroy these as a set in situations i.e. where the -/// OpenGL context is replaced. -const GLState = struct { - cell_program: CellProgram, - image_program: ImageProgram, - texture: gl.Texture, - texture_color: gl.Texture, - custom: ?custom.State, +/// Get the current size of the runtime surface. +pub fn surfaceSize(self: *const OpenGL) !struct { width: u32, height: u32 } { + _ = self; + var viewport: [4]gl.c.GLint = undefined; + gl.glad.context.GetIntegerv.?(gl.c.GL_VIEWPORT, &viewport); + return .{ + .width = @intCast(viewport[2]), + .height = @intCast(viewport[3]), + }; +} - pub fn init( - alloc: Allocator, - config: DerivedConfig, - font_grid: *font.SharedGrid, - ) !GLState { - var arena = ArenaAllocator.init(alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); +/// Initialize a new render target which can be presented by this API. +pub fn initTarget(self: *const OpenGL, width: usize, height: usize) !Target { + return Target.init(.{ + .internal_format = if (self.blending.isLinear()) .srgba else .rgba, + .width = width, + .height = height, + }); +} - // Load our custom shaders - const custom_state: ?custom.State = custom: { - const shaders: []const [:0]const u8 = shadertoy.loadFromFiles( - arena_alloc, - config.custom_shaders, - .glsl, - ) catch |err| err: { - log.warn("error loading custom shaders err={}", .{err}); - break :err &.{}; - }; - if (shaders.len == 0) break :custom null; +/// Present the provided target. +pub fn present(self: *OpenGL, target: Target) !void { + // In order to present a target we blit it to the default framebuffer. - break :custom custom.State.init( - alloc, - shaders, - ) catch |err| err: { - log.warn("error initializing custom shaders err={}", .{err}); - break :err null; - }; + // We disable GL_FRAMEBUFFER_SRGB while doing this blit, otherwise the + // values may be linearized as they're copied, but even though the draw + // framebuffer has a linear internal format, the values in it should be + // sRGB, not linear! + try gl.disable(gl.c.GL_FRAMEBUFFER_SRGB); + defer gl.enable(gl.c.GL_FRAMEBUFFER_SRGB) catch |err| { + log.err("Error re-enabling GL_FRAMEBUFFER_SRGB, err={}", .{err}); + }; + + // Bind the target for reading. + const fbobind = try target.framebuffer.bind(.read); + defer fbobind.unbind(); + + // Blit + gl.glad.context.BlitFramebuffer.?( + 0, + 0, + @intCast(target.width), + @intCast(target.height), + 0, + 0, + @intCast(target.width), + @intCast(target.height), + gl.c.GL_COLOR_BUFFER_BIT, + gl.c.GL_NEAREST, + ); + + // Keep track of this target in case we need to repeat it. + self.last_target = target; +} + +/// Present the last presented target again. +pub fn presentLastTarget(self: *OpenGL) !void { + if (self.last_target) |target| try self.present(target); +} + +/// Returns the options to use when constructing buffers. +pub inline fn bufferOptions(self: OpenGL) bufferpkg.Options { + _ = self; + return .{ + .target = .array, + .usage = .dynamic_draw, + }; +} + +pub const instanceBufferOptions = bufferOptions; +pub const uniformBufferOptions = bufferOptions; +pub const fgBufferOptions = bufferOptions; +pub const bgBufferOptions = bufferOptions; +pub const imageBufferOptions = bufferOptions; +pub const bgImageBufferOptions = bufferOptions; + +/// Returns the options to use when constructing textures. +pub inline fn textureOptions(self: OpenGL) Texture.Options { + _ = self; + return .{ + .format = .rgba, + .internal_format = .srgba, + .target = .@"2D", + }; +} + +/// Pixel format for image texture options. +pub const ImageTextureFormat = enum { + /// 1 byte per pixel grayscale. + gray, + /// 4 bytes per pixel RGBA. + rgba, + /// 4 bytes per pixel BGRA. + bgra, + + fn toPixelFormat(self: ImageTextureFormat) gl.Texture.Format { + return switch (self) { + .gray => .red, + .rgba => .rgba, + .bgra => .bgra, }; - - // Blending for text. We use GL_ONE here because we should be using - // premultiplied alpha for all our colors in our fragment shaders. - // This avoids having a blurry border where transparency is expected on - // pixels. - try gl.enable(gl.c.GL_BLEND); - try gl.blendFunc(gl.c.GL_ONE, gl.c.GL_ONE_MINUS_SRC_ALPHA); - - // Build our texture - const tex = try gl.Texture.create(); - errdefer tex.destroy(); - { - const texbind = try tex.bind(.@"2D"); - try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.MinFilter, gl.c.GL_LINEAR); - try texbind.parameter(.MagFilter, gl.c.GL_LINEAR); - try texbind.image2D( - 0, - .red, - @intCast(font_grid.atlas_grayscale.size), - @intCast(font_grid.atlas_grayscale.size), - 0, - .red, - .UnsignedByte, - font_grid.atlas_grayscale.data.ptr, - ); - } - - // Build our color texture - const tex_color = try gl.Texture.create(); - errdefer tex_color.destroy(); - { - const texbind = try tex_color.bind(.@"2D"); - try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.MinFilter, gl.c.GL_LINEAR); - try texbind.parameter(.MagFilter, gl.c.GL_LINEAR); - try texbind.image2D( - 0, - .rgba, - @intCast(font_grid.atlas_color.size), - @intCast(font_grid.atlas_color.size), - 0, - .bgra, - .UnsignedByte, - font_grid.atlas_color.data.ptr, - ); - } - - // Build our cell renderer - const cell_program = try CellProgram.init(); - errdefer cell_program.deinit(); - - // Build our image renderer - const image_program = try ImageProgram.init(); - errdefer image_program.deinit(); - - return .{ - .cell_program = cell_program, - .image_program = image_program, - .texture = tex, - .texture_color = tex_color, - .custom = custom_state, - }; - } - - pub fn deinit(self: *GLState, alloc: Allocator) void { - if (self.custom) |v| v.deinit(alloc); - self.texture.destroy(); - self.texture_color.destroy(); - self.image_program.deinit(); - self.cell_program.deinit(); } }; + +/// Returns the options to use when constructing textures for images. +pub inline fn imageTextureOptions( + self: OpenGL, + format: ImageTextureFormat, + srgb: bool, +) Texture.Options { + _ = self; + return .{ + .format = format.toPixelFormat(), + .internal_format = if (srgb) .srgba else .rgba, + .target = .@"2D", + }; +} + +/// Initializes a Texture suitable for the provided font atlas. +pub fn initAtlasTexture( + self: *const OpenGL, + atlas: *const font.Atlas, +) Texture.Error!Texture { + _ = self; + const format: gl.Texture.Format, const internal_format: gl.Texture.InternalFormat = + switch (atlas.format) { + .grayscale => .{ .red, .red }, + .bgra => .{ .bgra, .srgba }, + else => @panic("unsupported atlas format for OpenGL texture"), + }; + + return try Texture.init( + .{ + .format = format, + .internal_format = internal_format, + .target = .Rectangle, + }, + atlas.size, + atlas.size, + null, + ); +} + +/// Begin a frame. +pub inline fn beginFrame( + self: *const OpenGL, + /// Once the frame has been completed, the `frameCompleted` method + /// on the renderer is called with the health status of the frame. + renderer: *Renderer, + /// The target is presented via the provided renderer's API when completed. + target: *Target, +) !Frame { + _ = self; + return try Frame.begin(.{}, renderer, target); +} diff --git a/src/renderer/Options.zig b/src/renderer/Options.zig index e7d9b3a42..85ff8e310 100644 --- a/src/renderer/Options.zig +++ b/src/renderer/Options.zig @@ -20,3 +20,6 @@ surface_mailbox: apprt.surface.Mailbox, /// The apprt surface. rt_surface: *apprt.Surface, + +/// The renderer thread. +thread: *renderer.Thread, diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 46ef8609b..b8884f2fb 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -20,6 +20,16 @@ const log = std.log.scoped(.renderer_thread); const DRAW_INTERVAL = 8; // 120 FPS const CURSOR_BLINK_INTERVAL = 600; +/// Whether calls to `drawFrame` must be done from the app thread. +/// +/// If this is `true` then we send a `redraw_surface` message to the apprt +/// whenever we need to draw instead of calling `drawFrame` directly. +const must_draw_from_app_thread = + if (@hasDecl(apprt.App, "must_draw_from_app_thread")) + apprt.App.must_draw_from_app_thread + else + false; + /// The type used for sending messages to the IO thread. For now this is /// hardcoded with a capacity. We can make this a comptime parameter in /// the future if we want it configurable. @@ -155,7 +165,7 @@ pub fn init( return .{ .alloc = alloc, - .config = DerivedConfig.init(config), + .config = .init(config), .loop = loop, .wakeup = wakeup_h, .stop = stop_h, @@ -198,6 +208,13 @@ pub fn threadMain(self: *Thread) void { fn threadMain_(self: *Thread) !void { defer log.debug("renderer thread exited", .{}); + // Right now, on Darwin, `std.Thread.setName` can only name the current + // thread, and we have no way to get the current thread from within it, + // so instead we use this code to name the thread instead. + if (builtin.os.tag.isDarwin()) { + internal_os.macos.pthread_setname_np(&"renderer".*); + } + // Setup our crash metadata crash.sentry.thread_state = .{ .type = .renderer, @@ -307,6 +324,16 @@ fn stopDrawTimer(self: *Thread) void { /// Drain the mailbox. fn drainMailbox(self: *Thread) !void { + // There's probably a more elegant way to do this... + // + // This is effectively an @autoreleasepool{} block, which we need in + // order to ensure that autoreleased objects are properly released. + const pool = if (builtin.os.tag.isDarwin()) + @import("objc").AutoreleasePool.init() + else + void; + defer if (builtin.os.tag.isDarwin()) pool.deinit(); + while (self.mailbox.pop()) |message| { log.debug("mailbox message={}", .{message}); switch (message) { @@ -425,7 +452,7 @@ fn drainMailbox(self: *Thread) !void { self.renderer.markDirty(); }, - .resize => |v| try self.renderer.setScreenSize(v), + .resize => |v| self.renderer.setScreenSize(v), .change_config => |config| { defer config.alloc.destroy(config.thread); @@ -461,20 +488,16 @@ fn drawFrame(self: *Thread, now: bool) void { if (!self.flags.visible) return; // If the renderer is managing a vsync on its own, we only draw - // when we're forced to via now. + // when we're forced to via `now`. if (!now and self.renderer.hasVsync()) return; - // If we're doing single-threaded GPU calls then we just wake up the - // app thread to redraw at this point. - if (rendererpkg.Renderer == rendererpkg.OpenGL and - rendererpkg.OpenGL.single_threaded_draw) - { + if (must_draw_from_app_thread) { _ = self.app_mailbox.push( .{ .redraw_surface = self.surface }, .{ .instant = {} }, ); } else { - self.renderer.drawFrame(self.surface) catch |err| + self.renderer.drawFrame(false) catch |err| log.warn("error drawing err={}", .{err}); } } @@ -582,7 +605,6 @@ fn renderCallback( // Update our frame data t.renderer.updateFrame( - t.surface, t.state, t.flags.cursor_blink_visible, ) catch |err| diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index c84fbcc6f..ef7122699 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -1,6 +1,197 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; const ziglyph = @import("ziglyph"); const font = @import("../font/main.zig"); const terminal = @import("../terminal/main.zig"); +const renderer = @import("../renderer.zig"); +const shaderpkg = renderer.Renderer.API.shaders; +const ArrayListCollection = @import("../datastruct/array_list_collection.zig").ArrayListCollection; + +/// The possible cell content keys that exist. +pub const Key = enum { + bg, + text, + underline, + strikethrough, + overline, + + /// Returns the GPU vertex type for this key. + pub fn CellType(self: Key) type { + return switch (self) { + .bg => shaderpkg.CellBg, + + .text, + .underline, + .strikethrough, + .overline, + => shaderpkg.CellText, + }; + } +}; + +/// The contents of all the cells in the terminal. +/// +/// The goal of this data structure is to allow for efficient row-wise +/// clearing of data from the GPU buffers, to allow for row-wise dirty +/// tracking to eliminate the overhead of rebuilding the GPU buffers +/// each frame. +/// +/// Must be initialized by resizing before calling any operations. +pub const Contents = struct { + size: renderer.GridSize = .{ .rows = 0, .columns = 0 }, + + /// Flat array containing cell background colors for the terminal grid. + /// + /// Indexed as `bg_cells[row * size.columns + col]`. + /// + /// Prefer accessing with `Contents.bgCell(row, col).*` instead + /// of directly indexing in order to avoid integer size bugs. + bg_cells: []shaderpkg.CellBg = undefined, + + /// The ArrayListCollection which holds all of the foreground cells. When + /// sized with Contents.resize the individual ArrayLists are given enough + /// room that they can hold a single row with #cols glyphs, underlines, and + /// strikethroughs; however, appendAssumeCapacity MUST NOT be used since + /// it is possible to exceed this with combining glyphs that add a glyph + /// but take up no column since they combine with the previous one, as + /// well as with fonts that perform multi-substitutions for glyphs, which + /// can result in a similar situation where multiple glyphs reside in the + /// same column. + /// + /// Allocations should nevertheless be exceedingly rare since hitting the + /// initial capacity of a list would require a row filled with underlined + /// struck through characters, at least one of which is a multi-glyph + /// composite. + /// + /// Rows are indexed as Contents.fg_rows[y + 1], because the first list in + /// the collection is reserved for the cursor, which must be the first item + /// in the buffer. + /// + /// Must be initialized by calling resize on the Contents struct before + /// calling any operations. + fg_rows: ArrayListCollection(shaderpkg.CellText) = .{ .lists = &.{} }, + + pub fn deinit(self: *Contents, alloc: Allocator) void { + alloc.free(self.bg_cells); + self.fg_rows.deinit(alloc); + } + + /// Resize the cell contents for the given grid size. This will + /// always invalidate the entire cell contents. + pub fn resize( + self: *Contents, + alloc: Allocator, + size: renderer.GridSize, + ) Allocator.Error!void { + self.size = size; + + const cell_count = @as(usize, size.columns) * @as(usize, size.rows); + + const bg_cells = try alloc.alloc(shaderpkg.CellBg, cell_count); + errdefer alloc.free(bg_cells); + + @memset(bg_cells, .{ 0, 0, 0, 0 }); + + // The foreground lists can hold 3 types of items: + // - Glyphs + // - Underlines + // - Strikethroughs + // So we give them an initial capacity of size.columns * 3, which will + // avoid any further allocations in the vast majority of cases. Sadly + // we can not assume capacity though, since with combining glyphs that + // form a single grapheme, and multi-substitutions in fonts, the number + // of glyphs in a row is theoretically unlimited. + // + // We have size.rows + 1 lists because index 0 is used for a special + // list containing the cursor cell which needs to be first in the buffer. + var fg_rows = try ArrayListCollection(shaderpkg.CellText).init( + alloc, + size.rows + 1, + size.columns * 3, + ); + errdefer fg_rows.deinit(alloc); + + alloc.free(self.bg_cells); + self.fg_rows.deinit(alloc); + + self.bg_cells = bg_cells; + self.fg_rows = fg_rows; + + // We don't need 3*cols worth of cells for the cursor list, so we can + // replace it with a smaller list. This is technically a tiny bit of + // extra work but resize is not a hot function so it's worth it to not + // waste the memory. + self.fg_rows.lists[0].deinit(alloc); + self.fg_rows.lists[0] = try std.ArrayListUnmanaged( + shaderpkg.CellText, + ).initCapacity(alloc, 1); + } + + /// Reset the cell contents to an empty state without resizing. + pub fn reset(self: *Contents) void { + @memset(self.bg_cells, .{ 0, 0, 0, 0 }); + self.fg_rows.reset(); + } + + /// Set the cursor value. If the value is null then the cursor is hidden. + pub fn setCursor(self: *Contents, v: ?shaderpkg.CellText) void { + self.fg_rows.lists[0].clearRetainingCapacity(); + + if (v) |cell| { + self.fg_rows.lists[0].appendAssumeCapacity(cell); + } + } + + /// Access a background cell. Prefer this function over direct indexing + /// of `bg_cells` in order to avoid integer size bugs causing overflows. + pub inline fn bgCell( + self: *Contents, + row: usize, + col: usize, + ) *shaderpkg.CellBg { + return &self.bg_cells[row * self.size.columns + col]; + } + + /// Add a cell to the appropriate list. Adding the same cell twice will + /// result in duplication in the vertex buffer. The caller should clear + /// the corresponding row with Contents.clear to remove old cells first. + pub fn add( + self: *Contents, + alloc: Allocator, + comptime key: Key, + cell: key.CellType(), + ) Allocator.Error!void { + const y = cell.grid_pos[1]; + + assert(y < self.size.rows); + + switch (key) { + .bg => comptime unreachable, + + .text, + .underline, + .strikethrough, + .overline, + // We have a special list containing the cursor cell at the start + // of our fg row collection, so we need to add 1 to the y to get + // the correct index. + => try self.fg_rows.lists[y + 1].append(alloc, cell), + } + } + + /// Clear all of the cell contents for a given row. + pub fn clear(self: *Contents, y: terminal.size.CellCountInt) void { + assert(y < self.size.rows); + + @memset(self.bg_cells[@as(usize, y) * self.size.columns ..][0..self.size.columns], .{ 0, 0, 0, 0 }); + + // We have a special list containing the cursor cell at the start + // of our fg row collection, so we need to add 1 to the y to get + // the correct index. + self.fg_rows.lists[y + 1].clearRetainingCapacity(); + } +}; /// Returns true if a codepoint for a cell is a covering character. A covering /// character is a character that covers the entire cell. This is used to @@ -38,7 +229,7 @@ pub const FgMode = enum { pub fn fgMode( presentation: font.Presentation, cell_pin: terminal.Pin, -) !FgMode { +) FgMode { return switch (presentation) { // Emoji is always full size and color. .emoji => .color, @@ -131,3 +322,141 @@ fn isPowerline(char: u21) bool { else => false, }; } + +test Contents { + const testing = std.testing; + const alloc = testing.allocator; + + const rows = 10; + const cols = 10; + + var c: Contents = .{}; + try c.resize(alloc, .{ .rows = rows, .columns = cols }); + defer c.deinit(alloc); + + // We should start off empty after resizing. + for (0..rows) |y| { + try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); + for (0..cols) |x| { + try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); + } + } + // And the cursor row should have a capacity of 1 and also be empty. + try testing.expect(c.fg_rows.lists[0].capacity == 1); + try testing.expect(c.fg_rows.lists[0].items.len == 0); + + // Add some contents. + const bg_cell: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 1 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(1, 4).* = bg_cell; + try c.add(alloc, .text, fg_cell); + try testing.expectEqual(bg_cell, c.bgCell(1, 4).*); + // The fg row index is offset by 1 because of the cursor list. + try testing.expectEqual(fg_cell, c.fg_rows.lists[2].items[0]); + + // And we should be able to clear it. + c.clear(1); + for (0..rows) |y| { + try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); + for (0..cols) |x| { + try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); + } + } + + // Add a cursor. + const cursor_cell: shaderpkg.CellText = .{ + .mode = .cursor, + .grid_pos = .{ 2, 3 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.setCursor(cursor_cell); + try testing.expectEqual(cursor_cell, c.fg_rows.lists[0].items[0]); + + // And remove it. + c.setCursor(null); + try testing.expectEqual(0, c.fg_rows.lists[0].items.len); +} + +test "Contents clear retains other content" { + const testing = std.testing; + const alloc = testing.allocator; + + const rows = 10; + const cols = 10; + + var c: Contents = .{}; + try c.resize(alloc, .{ .rows = rows, .columns = cols }); + defer c.deinit(alloc); + + // Set some contents + // bg and fg cells in row 1 + const bg_cell_1: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_1: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 1 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(1, 4).* = bg_cell_1; + try c.add(alloc, .text, fg_cell_1); + // bg and fg cells in row 2 + const bg_cell_2: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_2: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 2 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(2, 4).* = bg_cell_2; + try c.add(alloc, .text, fg_cell_2); + + // Clear row 1, this should leave row 2 untouched + c.clear(1); + + // Row 2 should still contain its cells. + try testing.expectEqual(bg_cell_2, c.bgCell(2, 4).*); + // Fg row index is +1 because of cursor list at start + try testing.expectEqual(fg_cell_2, c.fg_rows.lists[3].items[0]); +} + +test "Contents clear last added content" { + const testing = std.testing; + const alloc = testing.allocator; + + const rows = 10; + const cols = 10; + + var c: Contents = .{}; + try c.resize(alloc, .{ .rows = rows, .columns = cols }); + defer c.deinit(alloc); + + // Set some contents + // bg and fg cells in row 1 + const bg_cell_1: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_1: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 1 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(1, 4).* = bg_cell_1; + try c.add(alloc, .text, fg_cell_1); + // bg and fg cells in row 2 + const bg_cell_2: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_2: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 2 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(2, 4).* = bg_cell_2; + try c.add(alloc, .text, fg_cell_2); + + // Clear row 2, this should leave row 1 untouched + c.clear(2); + + // Row 1 should still contain its cells. + try testing.expectEqual(bg_cell_1, c.bgCell(1, 4).*); + // Fg row index is +1 because of cursor list at start + try testing.expectEqual(fg_cell_1, c.fg_rows.lists[2].items[0]); +} diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index d8769d9e2..287b83450 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -62,7 +62,7 @@ pub fn style( } // Otherwise, we use whatever style the terminal wants. - return Style.fromTerminal(state.terminal.screen.cursor.cursor_style); + return .fromTerminal(state.terminal.screen.cursor.cursor_style); } test "cursor: default uses configured style" { diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig new file mode 100644 index 000000000..bf189fc4c --- /dev/null +++ b/src/renderer/generic.zig @@ -0,0 +1,3216 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const glfw = @import("glfw"); +const xev = @import("xev"); +const wuffs = @import("wuffs"); +const apprt = @import("../apprt.zig"); +const configpkg = @import("../config.zig"); +const font = @import("../font/main.zig"); +const os = @import("../os/main.zig"); +const terminal = @import("../terminal/main.zig"); +const renderer = @import("../renderer.zig"); +const math = @import("../math.zig"); +const Surface = @import("../Surface.zig"); +const link = @import("link.zig"); +const cellpkg = @import("cell.zig"); +const fgMode = cellpkg.fgMode; +const isCovering = cellpkg.isCovering; +const imagepkg = @import("image.zig"); +const Image = imagepkg.Image; +const ImageMap = imagepkg.ImageMap; +const ImagePlacementList = std.ArrayListUnmanaged(imagepkg.Placement); +const shadertoy = @import("shadertoy.zig"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const Terminal = terminal.Terminal; +const Health = renderer.Health; + +const FileType = @import("../file_type.zig").FileType; + +const macos = switch (builtin.os.tag) { + .macos => @import("macos"), + else => void, +}; + +const DisplayLink = switch (builtin.os.tag) { + .macos => *macos.video.DisplayLink, + else => void, +}; + +const log = std.log.scoped(.generic_renderer); + +/// Create a renderer type with the provided graphics API wrapper. +/// +/// The graphics API wrapper must provide the interface outlined below. +/// Specific details for the interfaces are documented on the existing +/// implementations (`Metal` and `OpenGL`). +/// +/// Hierarchy of graphics abstractions: +/// +/// [ GraphicsAPI ] - Responsible for configuring the runtime surface +/// | | and providing render `Target`s that draw to it, +/// | | as well as `Frame`s and `Pipeline`s. +/// | V +/// | [ Target ] - Represents an abstract target for rendering, which +/// | could be a surface directly but is also used as an +/// | abstraction for off-screen frame buffers. +/// V +/// [ Frame ] - Represents the context for drawing a given frame, +/// | provides `RenderPass`es for issuing draw commands +/// | to, and reports the frame health when complete. +/// V +/// [ RenderPass ] - Represents a render pass in a frame, consisting of +/// : one or more `Step`s applied to the same target(s), +/// [ Step ] - - - - each describing the input buffers and textures and +/// : the vertex/fragment functions and geometry to use. +/// :_ _ _ _ _ _ _ _ _ _/ +/// v +/// [ Pipeline ] - Describes a vertex and fragment function to be used +/// for a `Step`; the `GraphicsAPI` is responsible for +/// these and they should be constructed and cached +/// ahead of time. +/// +/// [ Buffer ] - An abstraction over a GPU buffer. +/// +/// [ Texture ] - An abstraction over a GPU texture. +/// +pub fn Renderer(comptime GraphicsAPI: type) type { + return struct { + const Self = @This(); + + pub const API = GraphicsAPI; + + const Target = GraphicsAPI.Target; + const Buffer = GraphicsAPI.Buffer; + const Texture = GraphicsAPI.Texture; + const RenderPass = GraphicsAPI.RenderPass; + + const shaderpkg = GraphicsAPI.shaders; + const Shaders = shaderpkg.Shaders; + + /// Allocator that can be used + alloc: std.mem.Allocator, + + /// This mutex must be held whenever any state used in `drawFrame` is + /// being modified, and also when it's being accessed in `drawFrame`. + draw_mutex: std.Thread.Mutex = .{}, + + /// The configuration we need derived from the main config. + config: DerivedConfig, + + /// The mailbox for communicating with the window. + surface_mailbox: apprt.surface.Mailbox, + + /// Current font metrics defining our grid. + grid_metrics: font.Metrics, + + /// The size of everything. + size: renderer.Size, + + /// True if the window is focused + focused: bool, + + /// The foreground color set by an OSC 10 sequence. If unset then + /// default_foreground_color is used. + foreground_color: ?terminal.color.RGB, + + /// Foreground color set in the user's config file. + default_foreground_color: terminal.color.RGB, + + /// The background color set by an OSC 11 sequence. If unset then + /// default_background_color is used. + background_color: ?terminal.color.RGB, + + /// Background color set in the user's config file. + default_background_color: terminal.color.RGB, + + /// The cursor color set by an OSC 12 sequence. If unset then + /// default_cursor_color is used. + cursor_color: ?terminal.color.RGB, + + /// Default cursor color when no color is set explicitly by an OSC 12 command. + /// This is cursor color as set in the user's config, if any. If no cursor color + /// is set in the user's config, then the cursor color is determined by the + /// current foreground color. + default_cursor_color: ?terminal.color.RGB, + + /// When `cursor_color` is null, swap the foreground and background colors of + /// the cell under the cursor for the cursor color. Otherwise, use the default + /// foreground color as the cursor color. + cursor_invert: bool, + + /// The current set of cells to render. This is rebuilt on every frame + /// but we keep this around so that we don't reallocate. Each set of + /// cells goes into a separate shader. + cells: cellpkg.Contents, + + /// The last viewport that we based our rebuild off of. If this changes, + /// then we do a full rebuild of the cells. The pointer values in this pin + /// are NOT SAFE to read because they may be modified, freed, etc from the + /// termio thread. We treat the pointers as integers for comparison only. + cells_viewport: ?terminal.Pin = null, + + /// Set to true after rebuildCells is called. This can be used + /// to determine if any possible changes have been made to the + /// cells for the draw call. + cells_rebuilt: bool = false, + + /// The current GPU uniform values. + uniforms: shaderpkg.Uniforms, + + /// Custom shader uniform values. + custom_shader_uniforms: shadertoy.Uniforms, + + /// Timestamp we rendered out first frame. + /// + /// This is used when updating custom shader uniforms. + first_frame_time: ?std.time.Instant = null, + + /// Timestamp when we rendered out more recent frame. + /// + /// This is used when updating custom shader uniforms. + last_frame_time: ?std.time.Instant = null, + + /// The font structures. + font_grid: *font.SharedGrid, + font_shaper: font.Shaper, + font_shaper_cache: font.ShaperCache, + + /// The images that we may render. + images: ImageMap = .{}, + image_placements: ImagePlacementList = .{}, + image_bg_end: u32 = 0, + image_text_end: u32 = 0, + image_virtual: bool = false, + + /// Background image, if we have one. + bg_image: ?imagepkg.Image = null, + /// Set whenever the background image changes, singalling + /// that the new background image needs to be uploaded to + /// the GPU. + /// + /// This is initialized as true so that we load the image + /// on renderer initialization, not just on config change. + bg_image_changed: bool = true, + /// Background image vertex buffer. + bg_image_buffer: shaderpkg.BgImage, + /// This value is used to force-update the swap chain copy + /// of the background image buffer whenever we change it. + bg_image_buffer_modified: usize = 0, + + /// Graphics API state. + api: GraphicsAPI, + + /// The CVDisplayLink used to drive the rendering loop in + /// sync with the display. This is void on platforms that + /// don't support a display link. + display_link: ?DisplayLink = null, + + /// Health of the most recently completed frame. + health: std.atomic.Value(Health) = .{ .raw = .healthy }, + + /// Our swap chain (multiple buffering) + swap_chain: SwapChain, + + /// This value is used to force-update swap chain targets in the + /// event of a config change that requires it (such as blending mode). + target_config_modified: usize = 0, + + /// If something happened that requires us to reinitialize our shaders, + /// this is set to true so that we can do that whenever possible. + reinitialize_shaders: bool = false, + + /// Whether or not we have custom shaders. + has_custom_shaders: bool = false, + + /// Our shader pipelines. + shaders: Shaders, + + /// Swap chain which maintains multiple copies of the state needed to + /// render a frame, so that we can start building the next frame while + /// the previous frame is still being processed on the GPU. + const SwapChain = struct { + // The count of buffers we use for double/triple buffering. + // If this is one then we don't do any double+ buffering at all. + // This is comptime because there isn't a good reason to change + // this at runtime and there is a lot of complexity to support it. + const buf_count = GraphicsAPI.swap_chain_count; + + /// `buf_count` structs that can hold the + /// data needed by the GPU to draw a frame. + frames: [buf_count]FrameState, + /// Index of the most recently used frame state struct. + frame_index: std.math.IntFittingRange(0, buf_count) = 0, + /// Semaphore that we wait on to make sure we have an available + /// frame state struct so we can start working on a new frame. + frame_sema: std.Thread.Semaphore = .{ .permits = buf_count }, + + /// Set to true when deinited, if you try to deinit a defunct + /// swap chain it will just be ignored, to prevent double-free. + /// + /// This is required because of `displayUnrealized`, since it + /// `deinits` the swapchain, which leads to a double-free if + /// the renderer is deinited after that. + defunct: bool = false, + + pub fn init(api: GraphicsAPI, custom_shaders: bool) !SwapChain { + var result: SwapChain = .{ .frames = undefined }; + + // Initialize all of our frame state. + for (&result.frames) |*frame| { + frame.* = try FrameState.init(api, custom_shaders); + } + + return result; + } + + pub fn deinit(self: *SwapChain) void { + if (self.defunct) return; + self.defunct = true; + + // Wait for all of our inflight draws to complete + // so that we can cleanly deinit our GPU state. + for (0..buf_count) |_| self.frame_sema.wait(); + for (&self.frames) |*frame| frame.deinit(); + } + + /// Get the next frame state to draw to. This will wait on the + /// semaphore to ensure that the frame is available. This must + /// always be paired with a call to releaseFrame. + pub fn nextFrame(self: *SwapChain) error{Defunct}!*FrameState { + if (self.defunct) return error.Defunct; + + self.frame_sema.wait(); + errdefer self.frame_sema.post(); + self.frame_index = (self.frame_index + 1) % buf_count; + return &self.frames[self.frame_index]; + } + + /// This should be called when the frame has completed drawing. + pub fn releaseFrame(self: *SwapChain) void { + self.frame_sema.post(); + } + }; + + /// State we need duplicated for every frame. Any state that could be + /// in a data race between the GPU and CPU while a frame is being drawn + /// should be in this struct. + /// + /// While a draw is in-process, we "lock" the state (via a semaphore) + /// and prevent the CPU from updating the state until our graphics API + /// reports that the frame is complete. + /// + /// This is used to implement double/triple buffering. + const FrameState = struct { + uniforms: UniformBuffer, + cells: CellTextBuffer, + cells_bg: CellBgBuffer, + + grayscale: Texture, + grayscale_modified: usize = 0, + color: Texture, + color_modified: usize = 0, + + target: Target, + /// See property of same name on Renderer for explanation. + target_config_modified: usize = 0, + + /// Buffer with the vertex data for our background image. + /// + /// TODO: Make this an optional and only create it + /// if we actually have a background image. + bg_image_buffer: BgImageBuffer, + /// See property of same name on Renderer for explanation. + bg_image_buffer_modified: usize = 0, + + /// Custom shader state, this is null if we have no custom shaders. + custom_shader_state: ?CustomShaderState = null, + + const UniformBuffer = Buffer(shaderpkg.Uniforms); + const CellBgBuffer = Buffer(shaderpkg.CellBg); + const CellTextBuffer = Buffer(shaderpkg.CellText); + const BgImageBuffer = Buffer(shaderpkg.BgImage); + + pub fn init(api: GraphicsAPI, custom_shaders: bool) !FrameState { + // Uniform buffer contains exactly 1 uniform struct. The + // uniform data will be undefined so this must be set before + // a frame is drawn. + var uniforms = try UniformBuffer.init(api.uniformBufferOptions(), 1); + errdefer uniforms.deinit(); + + // Create GPU buffers for our cells. + // + // We start them off with a size of 1, which will of course be + // too small, but they will be resized as needed. This is a bit + // wasteful but since it's a one-time thing it's not really a + // huge concern. + var cells = try CellTextBuffer.init(api.fgBufferOptions(), 1); + errdefer cells.deinit(); + var cells_bg = try CellBgBuffer.init(api.bgBufferOptions(), 1); + errdefer cells_bg.deinit(); + + // Create a GPU buffer for our background image info. + var bg_image_buffer = try BgImageBuffer.init( + api.bgImageBufferOptions(), + 1, + ); + errdefer bg_image_buffer.deinit(); + + // Initialize our textures for our font atlas. + // + // As with the buffers above, we start these off as small + // as possible since they'll inevitably be resized anyway. + const grayscale = try api.initAtlasTexture(&.{ + .data = undefined, + .size = 1, + .format = .grayscale, + }); + errdefer grayscale.deinit(); + const color = try api.initAtlasTexture(&.{ + .data = undefined, + .size = 1, + .format = .bgra, + }); + errdefer color.deinit(); + + var custom_shader_state = + if (custom_shaders) + try CustomShaderState.init(api) + else + null; + errdefer if (custom_shader_state) |*state| state.deinit(); + + // Initialize the target. Just as with the other resources, + // start it off as small as we can since it'll be resized. + const target = try api.initTarget(1, 1); + + return .{ + .uniforms = uniforms, + .cells = cells, + .cells_bg = cells_bg, + .bg_image_buffer = bg_image_buffer, + .grayscale = grayscale, + .color = color, + .target = target, + .custom_shader_state = custom_shader_state, + }; + } + + pub fn deinit(self: *FrameState) void { + self.uniforms.deinit(); + self.cells.deinit(); + self.cells_bg.deinit(); + self.grayscale.deinit(); + self.color.deinit(); + self.bg_image_buffer.deinit(); + if (self.custom_shader_state) |*state| state.deinit(); + } + + pub fn resize( + self: *FrameState, + api: GraphicsAPI, + width: usize, + height: usize, + ) !void { + if (self.custom_shader_state) |*state| { + try state.resize(api, width, height); + } + const target = try api.initTarget(width, height); + self.target.deinit(); + self.target = target; + } + }; + + /// State relevant to our custom shaders if we have any. + const CustomShaderState = struct { + /// When we have a custom shader state, we maintain a front + /// and back texture which we use as a swap chain to render + /// between when multiple custom shaders are defined. + front_texture: Texture, + back_texture: Texture, + + uniforms: UniformBuffer, + + const UniformBuffer = Buffer(shadertoy.Uniforms); + + /// Swap the front and back textures. + pub fn swap(self: *CustomShaderState) void { + std.mem.swap(Texture, &self.front_texture, &self.back_texture); + } + + pub fn init(api: GraphicsAPI) !CustomShaderState { + // Create a GPU buffer to hold our uniforms. + var uniforms = try UniformBuffer.init(api.uniformBufferOptions(), 1); + errdefer uniforms.deinit(); + + // Initialize the front and back textures at 1x1 px, this + // is slightly wasteful but it's only done once so whatever. + const front_texture = try Texture.init( + api.textureOptions(), + 1, + 1, + null, + ); + errdefer front_texture.deinit(); + const back_texture = try Texture.init( + api.textureOptions(), + 1, + 1, + null, + ); + errdefer back_texture.deinit(); + + return .{ + .front_texture = front_texture, + .back_texture = back_texture, + .uniforms = uniforms, + }; + } + + pub fn deinit(self: *CustomShaderState) void { + self.front_texture.deinit(); + self.back_texture.deinit(); + self.uniforms.deinit(); + } + + pub fn resize( + self: *CustomShaderState, + api: GraphicsAPI, + width: usize, + height: usize, + ) !void { + const front_texture = try Texture.init( + api.textureOptions(), + @intCast(width), + @intCast(height), + null, + ); + errdefer front_texture.deinit(); + const back_texture = try Texture.init( + api.textureOptions(), + @intCast(width), + @intCast(height), + null, + ); + errdefer back_texture.deinit(); + + self.front_texture.deinit(); + self.back_texture.deinit(); + + self.front_texture = front_texture; + self.back_texture = back_texture; + } + }; + + /// The configuration for this renderer that is derived from the main + /// configuration. This must be exported so that we don't need to + /// pass around Config pointers which makes memory management a pain. + pub const DerivedConfig = struct { + arena: ArenaAllocator, + + font_thicken: bool, + font_thicken_strength: u8, + font_features: std.ArrayListUnmanaged([:0]const u8), + font_styles: font.CodepointResolver.StyleStatus, + cursor_color: ?terminal.color.RGB, + cursor_invert: bool, + cursor_opacity: f64, + cursor_text: ?terminal.color.RGB, + background: terminal.color.RGB, + background_opacity: f64, + foreground: terminal.color.RGB, + selection_background: ?terminal.color.RGB, + selection_foreground: ?terminal.color.RGB, + invert_selection_fg_bg: bool, + bold_is_bright: bool, + min_contrast: f32, + padding_color: configpkg.WindowPaddingColor, + custom_shaders: configpkg.RepeatablePath, + bg_image: ?configpkg.Path, + bg_image_opacity: f32, + bg_image_position: configpkg.BackgroundImagePosition, + bg_image_fit: configpkg.BackgroundImageFit, + bg_image_repeat: bool, + links: link.Set, + vsync: bool, + colorspace: configpkg.Config.WindowColorspace, + blending: configpkg.Config.AlphaBlending, + + pub fn init( + alloc_gpa: Allocator, + config: *const configpkg.Config, + ) !DerivedConfig { + var arena = ArenaAllocator.init(alloc_gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Copy our shaders + const custom_shaders = try config.@"custom-shader".clone(alloc); + + // Copy our background image + const bg_image = + if (config.@"background-image") |bg| + try bg.clone(alloc) + else + null; + + // Copy our font features + const font_features = try config.@"font-feature".clone(alloc); + + // Get our font styles + var font_styles = font.CodepointResolver.StyleStatus.initFill(true); + font_styles.set(.bold, config.@"font-style-bold" != .false); + font_styles.set(.italic, config.@"font-style-italic" != .false); + font_styles.set(.bold_italic, config.@"font-style-bold-italic" != .false); + + // Our link configs + const links = try link.Set.fromConfig( + alloc, + config.link.links.items, + ); + + const cursor_invert = config.@"cursor-invert-fg-bg"; + + return .{ + .background_opacity = @max(0, @min(1, config.@"background-opacity")), + .font_thicken = config.@"font-thicken", + .font_thicken_strength = config.@"font-thicken-strength", + .font_features = font_features.list, + .font_styles = font_styles, + + .cursor_color = if (!cursor_invert and config.@"cursor-color" != null) + config.@"cursor-color".?.toTerminalRGB() + else + null, + + .cursor_invert = cursor_invert, + + .cursor_text = if (config.@"cursor-text") |txt| + txt.toTerminalRGB() + else + null, + + .cursor_opacity = @max(0, @min(1, config.@"cursor-opacity")), + + .background = config.background.toTerminalRGB(), + .foreground = config.foreground.toTerminalRGB(), + .invert_selection_fg_bg = config.@"selection-invert-fg-bg", + .bold_is_bright = config.@"bold-is-bright", + .min_contrast = @floatCast(config.@"minimum-contrast"), + .padding_color = config.@"window-padding-color", + + .selection_background = if (config.@"selection-background") |bg| + bg.toTerminalRGB() + else + null, + + .selection_foreground = if (config.@"selection-foreground") |bg| + bg.toTerminalRGB() + else + null, + + .custom_shaders = custom_shaders, + .bg_image = bg_image, + .bg_image_opacity = config.@"background-image-opacity", + .bg_image_position = config.@"background-image-position", + .bg_image_fit = config.@"background-image-fit", + .bg_image_repeat = config.@"background-image-repeat", + .links = links, + .vsync = config.@"window-vsync", + .colorspace = config.@"window-colorspace", + .blending = config.@"alpha-blending", + .arena = arena, + }; + } + + pub fn deinit(self: *DerivedConfig) void { + const alloc = self.arena.allocator(); + self.links.deinit(alloc); + self.arena.deinit(); + } + }; + + /// Returns the hints that we want for this window. + pub fn glfwWindowHints(config: *const configpkg.Config) glfw.Window.Hints { + // If our graphics API provides hints, use them, + // otherwise fall back to generic hints. + if (@hasDecl(GraphicsAPI, "glfwWindowHints")) { + return GraphicsAPI.glfwWindowHints(config); + } + + return .{ + .client_api = .no_api, + .transparent_framebuffer = config.@"background-opacity" < 1, + }; + } + + pub fn init(alloc: Allocator, options: renderer.Options) !Self { + // Initialize our graphics API wrapper, this will prepare the + // surface provided by the apprt and set up any API-specific + // GPU resources. + var api = try GraphicsAPI.init(alloc, options); + errdefer api.deinit(); + + const has_custom_shaders = options.config.custom_shaders.value.items.len > 0; + + // Prepare our swap chain + var swap_chain = try SwapChain.init( + api, + has_custom_shaders, + ); + errdefer swap_chain.deinit(); + + // Create the font shaper. + var font_shaper = try font.Shaper.init(alloc, .{ + .features = options.config.font_features.items, + }); + errdefer font_shaper.deinit(); + + // Initialize all the data that requires a critical font section. + const font_critical: struct { + metrics: font.Metrics, + } = font_critical: { + const grid: *font.SharedGrid = options.font_grid; + grid.lock.lockShared(); + defer grid.lock.unlockShared(); + break :font_critical .{ + .metrics = grid.metrics, + }; + }; + + const display_link: ?DisplayLink = switch (builtin.os.tag) { + .macos => if (options.config.vsync) + try macos.video.DisplayLink.createWithActiveCGDisplays() + else + null, + else => null, + }; + errdefer if (display_link) |v| v.release(); + + var result: Self = .{ + .alloc = alloc, + .config = options.config, + .surface_mailbox = options.surface_mailbox, + .grid_metrics = font_critical.metrics, + .size = options.size, + .focused = true, + .foreground_color = null, + .default_foreground_color = options.config.foreground, + .background_color = null, + .default_background_color = options.config.background, + .cursor_color = null, + .default_cursor_color = options.config.cursor_color, + .cursor_invert = options.config.cursor_invert, + + // Render state + .cells = .{}, + .uniforms = .{ + .projection_matrix = undefined, + .cell_size = undefined, + .grid_size = undefined, + .grid_padding = undefined, + .screen_size = undefined, + .padding_extend = .{}, + .min_contrast = options.config.min_contrast, + .cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16) }, + .cursor_color = undefined, + .bg_color = .{ + options.config.background.r, + options.config.background.g, + options.config.background.b, + @intFromFloat(@round(options.config.background_opacity * 255.0)), + }, + .bools = .{ + .cursor_wide = false, + .use_display_p3 = options.config.colorspace == .@"display-p3", + .use_linear_blending = options.config.blending.isLinear(), + .use_linear_correction = options.config.blending == .@"linear-corrected", + }, + }, + .custom_shader_uniforms = .{ + .resolution = .{ 0, 0, 1 }, + .time = 0, + .time_delta = 0, + .frame_rate = 60, // not currently updated + .frame = 0, + .channel_time = @splat(@splat(0)), // not currently updated + .channel_resolution = @splat(@splat(0)), + .mouse = @splat(0), // not currently updated + .date = @splat(0), // not currently updated + .sample_rate = 0, // N/A, we don't have any audio + .current_cursor = @splat(0), + .previous_cursor = @splat(0), + .current_cursor_color = @splat(0), + .previous_cursor_color = @splat(0), + .cursor_change_time = 0, + }, + .bg_image_buffer = undefined, + + // Fonts + .font_grid = options.font_grid, + .font_shaper = font_shaper, + .font_shaper_cache = font.ShaperCache.init(), + + // Shaders (initialized below) + .shaders = undefined, + + // Graphics API stuff + .api = api, + .swap_chain = swap_chain, + .display_link = display_link, + }; + + try result.initShaders(); + + // Ensure our undefined values above are correctly initialized. + result.updateFontGridUniforms(); + result.updateScreenSizeUniforms(); + result.updateBgImageBuffer(); + try result.prepBackgroundImage(); + + return result; + } + + pub fn deinit(self: *Self) void { + self.swap_chain.deinit(); + + if (DisplayLink != void) { + if (self.display_link) |display_link| { + display_link.stop() catch {}; + display_link.release(); + } + } + + self.cells.deinit(self.alloc); + + self.font_shaper.deinit(); + self.font_shaper_cache.deinit(self.alloc); + + self.config.deinit(); + + { + var it = self.images.iterator(); + while (it.next()) |kv| kv.value_ptr.image.deinit(self.alloc); + self.images.deinit(self.alloc); + } + self.image_placements.deinit(self.alloc); + + if (self.bg_image) |img| img.deinit(self.alloc); + + self.deinitShaders(); + + self.api.deinit(); + + self.* = undefined; + } + + fn deinitShaders(self: *Self) void { + self.shaders.deinit(self.alloc); + } + + fn initShaders(self: *Self) !void { + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const arena_alloc = arena.allocator(); + + // Load our custom shaders + const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles( + arena_alloc, + self.config.custom_shaders, + GraphicsAPI.custom_shader_target, + ) catch |err| err: { + log.warn("error loading custom shaders err={}", .{err}); + break :err &.{}; + }; + + const has_custom_shaders = custom_shaders.len > 0; + + var shaders = try self.api.initShaders( + self.alloc, + custom_shaders, + ); + errdefer shaders.deinit(self.alloc); + + self.shaders = shaders; + self.has_custom_shaders = has_custom_shaders; + } + + /// This is called early right after surface creation. + pub fn surfaceInit(surface: *apprt.Surface) !void { + // If our API has to do things here, let it. + if (@hasDecl(GraphicsAPI, "surfaceInit")) { + try GraphicsAPI.surfaceInit(surface); + } + } + + /// This is called just prior to spinning up the renderer thread for + /// final main thread setup requirements. + pub fn finalizeSurfaceInit(self: *Self, surface: *apprt.Surface) !void { + // If our API has to do things to finalize surface init, let it. + if (@hasDecl(GraphicsAPI, "finalizeSurfaceInit")) { + try self.api.finalizeSurfaceInit(surface); + } + } + + /// Callback called by renderer.Thread when it begins. + pub fn threadEnter(self: *const Self, surface: *apprt.Surface) !void { + // If our API has to do things on thread enter, let it. + if (@hasDecl(GraphicsAPI, "threadEnter")) { + try self.api.threadEnter(surface); + } + } + + /// Callback called by renderer.Thread when it exits. + pub fn threadExit(self: *const Self) void { + // If our API has to do things on thread exit, let it. + if (@hasDecl(GraphicsAPI, "threadExit")) { + self.api.threadExit(); + } + } + + /// Called by renderer.Thread when it starts the main loop. + pub fn loopEnter(self: *Self, thr: *renderer.Thread) !void { + // If our API has to do things on loop enter, let it. + if (@hasDecl(GraphicsAPI, "loopEnter")) { + self.api.loopEnter(); + } + + // If we don't support a display link we have no work to do. + if (comptime DisplayLink == void) return; + + // This is when we know our "self" pointer is stable so we can + // setup the display link. To setup the display link we set our + // callback and we can start it immediately. + const display_link = self.display_link orelse return; + try display_link.setOutputCallback( + xev.Async, + &displayLinkCallback, + &thr.draw_now, + ); + display_link.start() catch {}; + } + + /// Called by renderer.Thread when it exits the main loop. + pub fn loopExit(self: *Self) void { + // If our API has to do things on loop exit, let it. + if (@hasDecl(GraphicsAPI, "loopExit")) { + self.api.loopExit(); + } + + // If we don't support a display link we have no work to do. + if (comptime DisplayLink == void) return; + + // Stop our display link. If this fails its okay it just means + // that we either never started it or the view its attached to + // is gone which is fine. + const display_link = self.display_link orelse return; + display_link.stop() catch {}; + } + + /// This is called by the GTK apprt after the surface is + /// reinitialized due to any of the events mentioned in + /// the doc comment for `displayUnrealized`. + pub fn displayRealized(self: *Self) !void { + // If our API has to do things on realize, let it. + if (@hasDecl(GraphicsAPI, "displayRealized")) { + self.api.displayRealized(); + } + + // Lock the draw mutex so that we can + // safely reinitialize our GPU resources. + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // We assume that the swap chain was deinited in + // `displayUnrealized`, in which case it should be + // marked defunct. If not, we have a problem. + assert(self.swap_chain.defunct); + + // We reinitialize our shaders and our swap chain. + try self.initShaders(); + self.swap_chain = try SwapChain.init( + self.api, + self.has_custom_shaders, + ); + self.reinitialize_shaders = false; + self.target_config_modified = 1; + } + + /// This is called by the GTK apprt when the surface is being destroyed. + /// This can happen because the surface is being closed but also when + /// moving the window between displays or splitting. + pub fn displayUnrealized(self: *Self) void { + // If our API has to do things on unrealize, let it. + if (@hasDecl(GraphicsAPI, "displayUnrealized")) { + self.api.displayUnrealized(); + } + + // Lock the draw mutex so that we can + // safely deinitialize our GPU resources. + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // We deinit our swap chain and shaders. + // + // This will mark them as defunct so that they + // can't be double-freed or used in draw calls. + self.swap_chain.deinit(); + self.shaders.deinit(self.alloc); + } + + fn displayLinkCallback( + _: *macos.video.DisplayLink, + ud: ?*xev.Async, + ) void { + const draw_now = ud orelse return; + draw_now.notify() catch |err| { + log.err("error notifying draw_now err={}", .{err}); + }; + } + + /// Mark the full screen as dirty so that we redraw everything. + pub fn markDirty(self: *Self) void { + self.cells_viewport = null; + } + + /// Called when we get an updated display ID for our display link. + pub fn setMacOSDisplayID(self: *Self, id: u32) !void { + if (comptime DisplayLink == void) return; + const display_link = self.display_link orelse return; + log.info("updating display link display id={}", .{id}); + display_link.setCurrentCGDisplay(id) catch |err| { + log.warn("error setting display link display id err={}", .{err}); + }; + } + + /// True if our renderer has animations so that a higher frequency + /// timer is used. + pub fn hasAnimations(self: *const Self) bool { + return self.has_custom_shaders; + } + + /// True if our renderer is using vsync. If true, the renderer or apprt + /// is responsible for triggering draw_now calls to the render thread. + /// That is the only way to trigger a drawFrame. + pub fn hasVsync(self: *const Self) bool { + if (comptime DisplayLink == void) return false; + const display_link = self.display_link orelse return false; + return display_link.isRunning(); + } + + /// Callback when the focus changes for the terminal this is rendering. + /// + /// Must be called on the render thread. + pub fn setFocus(self: *Self, focus: bool) !void { + self.focused = focus; + + // If we're not focused, then we want to stop the display link + // because it is a waste of resources and we can move to pure + // change-driven updates. + if (comptime DisplayLink != void) link: { + const display_link = self.display_link orelse break :link; + if (focus) { + display_link.start() catch {}; + } else { + display_link.stop() catch {}; + } + } + } + + /// Callback when the window is visible or occluded. + /// + /// Must be called on the render thread. + pub fn setVisible(self: *Self, visible: bool) void { + // If we're not visible, then we want to stop the display link + // because it is a waste of resources and we can move to pure + // change-driven updates. + if (comptime DisplayLink != void) link: { + const display_link = self.display_link orelse break :link; + if (visible and self.focused) { + display_link.start() catch {}; + } else { + display_link.stop() catch {}; + } + } + } + + /// Set the new font grid. + /// + /// Must be called on the render thread. + pub fn setFontGrid(self: *Self, grid: *font.SharedGrid) void { + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // Update our grid + self.font_grid = grid; + + // Update all our textures so that they sync on the next frame. + // We can modify this without a lock because the GPU does not + // touch this data. + for (&self.swap_chain.frames) |*frame| { + frame.grayscale_modified = 0; + frame.color_modified = 0; + } + + // Get our metrics from the grid. This doesn't require a lock because + // the metrics are never recalculated. + const metrics = grid.metrics; + self.grid_metrics = metrics; + + // Reset our shaper cache. If our font changed (not just the size) then + // the data in the shaper cache may be invalid and cannot be used, so we + // always clear the cache just in case. + const font_shaper_cache = font.ShaperCache.init(); + self.font_shaper_cache.deinit(self.alloc); + self.font_shaper_cache = font_shaper_cache; + + // Update cell size. + self.size.cell = .{ + .width = metrics.cell_width, + .height = metrics.cell_height, + }; + + // Update relevant uniforms + self.updateFontGridUniforms(); + } + + /// Update uniforms that are based on the font grid. + /// + /// Caller must hold the draw mutex. + fn updateFontGridUniforms(self: *Self) void { + self.uniforms.cell_size = .{ + @floatFromInt(self.grid_metrics.cell_width), + @floatFromInt(self.grid_metrics.cell_height), + }; + } + + /// Update the frame data. + pub fn updateFrame( + self: *Self, + state: *renderer.State, + cursor_blink_visible: bool, + ) !void { + // Data we extract out of the critical area. + const Critical = struct { + bg: terminal.color.RGB, + screen: terminal.Screen, + screen_type: terminal.ScreenType, + mouse: renderer.State.Mouse, + preedit: ?renderer.State.Preedit, + cursor_style: ?renderer.CursorStyle, + color_palette: terminal.color.Palette, + + /// If true, rebuild the full screen. + full_rebuild: bool, + }; + + // Update all our data as tightly as possible within the mutex. + var critical: Critical = critical: { + // const start = try std.time.Instant.now(); + // const start_micro = std.time.microTimestamp(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // // "[updateFrame critical time] \t" + // std.log.err("[updateFrame critical time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us}); + // } + + state.mutex.lock(); + defer state.mutex.unlock(); + + // If we're in a synchronized output state, we pause all rendering. + if (state.terminal.modes.get(.synchronized_output)) { + log.debug("synchronized output started, skipping render", .{}); + return; + } + + // Swap bg/fg if the terminal is reversed + const bg = self.background_color orelse self.default_background_color; + const fg = self.foreground_color orelse self.default_foreground_color; + defer { + if (self.background_color) |*c| { + c.* = bg; + } else { + self.default_background_color = bg; + } + + if (self.foreground_color) |*c| { + c.* = fg; + } else { + self.default_foreground_color = fg; + } + } + + if (state.terminal.modes.get(.reverse_colors)) { + if (self.background_color) |*c| { + c.* = fg; + } else { + self.default_background_color = fg; + } + + if (self.foreground_color) |*c| { + c.* = bg; + } else { + self.default_foreground_color = bg; + } + } + + // Get the viewport pin so that we can compare it to the current. + const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?; + + // We used to share terminal state, but we've since learned through + // analysis that it is faster to copy the terminal state than to + // hold the lock while rebuilding GPU cells. + var screen_copy = try state.terminal.screen.clone( + self.alloc, + .{ .viewport = .{} }, + null, + ); + errdefer screen_copy.deinit(); + + // Whether to draw our cursor or not. + const cursor_style = if (state.terminal.flags.password_input) + .lock + else + renderer.cursorStyle( + state, + self.focused, + cursor_blink_visible, + ); + + // Get our preedit state + const preedit: ?renderer.State.Preedit = preedit: { + if (cursor_style == null) break :preedit null; + const p = state.preedit orelse break :preedit null; + break :preedit try p.clone(self.alloc); + }; + errdefer if (preedit) |p| p.deinit(self.alloc); + + // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path. + // We only do this if the Kitty image state is dirty meaning only if + // it changes. + // + // If we have any virtual references, we must also rebuild our + // kitty state on every frame because any cell change can move + // an image. + if (state.terminal.screen.kitty_images.dirty or + self.image_virtual) + { + try self.prepKittyGraphics(state.terminal); + } + + // If we have any terminal dirty flags set then we need to rebuild + // the entire screen. This can be optimized in the future. + const full_rebuild: bool = rebuild: { + { + const Int = @typeInfo(terminal.Terminal.Dirty).@"struct".backing_integer.?; + const v: Int = @bitCast(state.terminal.flags.dirty); + if (v > 0) break :rebuild true; + } + { + const Int = @typeInfo(terminal.Screen.Dirty).@"struct".backing_integer.?; + const v: Int = @bitCast(state.terminal.screen.dirty); + if (v > 0) break :rebuild true; + } + + // If our viewport changed then we need to rebuild the entire + // screen because it means we scrolled. If we have no previous + // viewport then we must rebuild. + const prev_viewport = self.cells_viewport orelse break :rebuild true; + if (!prev_viewport.eql(viewport_pin)) break :rebuild true; + + break :rebuild false; + }; + + // Reset the dirty flags in the terminal and screen. We assume + // that our rebuild will be successful since so we optimize for + // success and reset while we hold the lock. This is much easier + // than coordinating row by row or as changes are persisted. + state.terminal.flags.dirty = .{}; + state.terminal.screen.dirty = .{}; + { + var it = state.terminal.screen.pages.pageIterator( + .right_down, + .{ .screen = .{} }, + null, + ); + while (it.next()) |chunk| { + var dirty_set = chunk.node.data.dirtyBitSet(); + dirty_set.unsetAll(); + } + } + + // Update our viewport pin + self.cells_viewport = viewport_pin; + + break :critical .{ + .bg = self.background_color orelse self.default_background_color, + .screen = screen_copy, + .screen_type = state.terminal.active_screen, + .mouse = state.mouse, + .preedit = preedit, + .cursor_style = cursor_style, + .color_palette = state.terminal.color_palette.colors, + .full_rebuild = full_rebuild, + }; + }; + defer { + critical.screen.deinit(); + if (critical.preedit) |p| p.deinit(self.alloc); + } + + // Build our GPU cells + try self.rebuildCells( + critical.full_rebuild, + &critical.screen, + critical.screen_type, + critical.mouse, + critical.preedit, + critical.cursor_style, + &critical.color_palette, + ); + + // Notify our shaper we're done for the frame. For some shapers, + // such as CoreText, this triggers off-thread cleanup logic. + self.font_shaper.endFrame(); + + // Acquire the draw mutex because we're modifying state here. + { + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // Update our background color + self.uniforms.bg_color = .{ + critical.bg.r, + critical.bg.g, + critical.bg.b, + @intFromFloat(@round(self.config.background_opacity * 255.0)), + }; + } + } + + /// Draw the frame to the screen. + /// + /// If `sync` is true, this will synchronously block until + /// the frame is finished drawing and has been presented. + pub fn drawFrame( + self: *Self, + sync: bool, + ) !void { + // We hold a the draw mutex to prevent changes to any + // data we access while we're in the middle of drawing. + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // Let our graphics API do any bookkeeping, etc. + // that it needs to do before / after `drawFrame`. + self.api.drawFrameStart(); + defer self.api.drawFrameEnd(); + + // Retrieve the most up-to-date surface size from the Graphics API + const surface_size = try self.api.surfaceSize(); + + // If either of our surface dimensions is zero + // then drawing is absurd, so we just return. + if (surface_size.width == 0 or surface_size.height == 0) return; + + const size_changed = + self.size.screen.width != surface_size.width or + self.size.screen.height != surface_size.height; + + // Conditions under which we need to draw the frame, otherwise we + // don't need to since the previous frame should be identical. + const needs_redraw = + size_changed or + self.cells_rebuilt or + self.hasAnimations() or + sync; + + if (!needs_redraw) { + // We still need to present the last target again, because the + // apprt may be swapping buffers and display an outdated frame + // if we don't draw something new. + try self.api.presentLastTarget(); + return; + } + self.cells_rebuilt = false; + + // Wait for a frame to be available. + const frame = try self.swap_chain.nextFrame(); + errdefer self.swap_chain.releaseFrame(); + // log.debug("drawing frame index={}", .{self.swap_chain.frame_index}); + + // If we need to reinitialize our shaders, do so. + if (self.reinitialize_shaders) { + self.reinitialize_shaders = false; + self.shaders.deinit(self.alloc); + try self.initShaders(); + } + + // Our shaders should not be defunct at this point. + assert(!self.shaders.defunct); + + // If we have custom shaders, make sure we have the + // custom shader state in our frame state, otherwise + // if we have a state but don't need it we remove it. + if (self.has_custom_shaders) { + if (frame.custom_shader_state == null) { + frame.custom_shader_state = try .init(self.api); + try frame.custom_shader_state.?.resize( + self.api, + surface_size.width, + surface_size.height, + ); + } + } else if (frame.custom_shader_state) |*state| { + state.deinit(); + frame.custom_shader_state = null; + } + + // If our stored size doesn't match the + // surface size we need to update it. + if (size_changed) { + self.size.screen = .{ + .width = surface_size.width, + .height = surface_size.height, + }; + self.updateScreenSizeUniforms(); + } + + // If this frame's target isn't the correct size, or the target + // config has changed (such as when the blending mode changes), + // remove it and replace it with a new one with the right values. + if (frame.target.width != self.size.screen.width or + frame.target.height != self.size.screen.height or + frame.target_config_modified != self.target_config_modified) + { + try frame.resize( + self.api, + self.size.screen.width, + self.size.screen.height, + ); + frame.target_config_modified = self.target_config_modified; + } + + // Upload images to the GPU as necessary. + try self.uploadKittyImages(); + + // Upload the background image to the GPU as necessary. + try self.uploadBackgroundImage(); + + // Update custom shader uniforms if necessary. + try self.updateCustomShaderUniforms(); + + // Setup our frame data + try frame.uniforms.sync(&.{self.uniforms}); + try frame.cells_bg.sync(self.cells.bg_cells); + const fg_count = try frame.cells.syncFromArrayLists(self.cells.fg_rows.lists); + + // If our background image buffer has changed, sync it. + if (frame.bg_image_buffer_modified != self.bg_image_buffer_modified) { + try frame.bg_image_buffer.sync(&.{self.bg_image_buffer}); + + frame.bg_image_buffer_modified = self.bg_image_buffer_modified; + } + + // If our font atlas changed, sync the texture data + texture: { + const modified = self.font_grid.atlas_grayscale.modified.load(.monotonic); + if (modified <= frame.grayscale_modified) break :texture; + self.font_grid.lock.lockShared(); + defer self.font_grid.lock.unlockShared(); + frame.grayscale_modified = self.font_grid.atlas_grayscale.modified.load(.monotonic); + try self.syncAtlasTexture(&self.font_grid.atlas_grayscale, &frame.grayscale); + } + texture: { + const modified = self.font_grid.atlas_color.modified.load(.monotonic); + if (modified <= frame.color_modified) break :texture; + self.font_grid.lock.lockShared(); + defer self.font_grid.lock.unlockShared(); + frame.color_modified = self.font_grid.atlas_color.modified.load(.monotonic); + try self.syncAtlasTexture(&self.font_grid.atlas_color, &frame.color); + } + + // Get a frame context from the graphics API. + var frame_ctx = try self.api.beginFrame(self, &frame.target); + defer frame_ctx.complete(sync); + + { + var pass = frame_ctx.renderPass(&.{.{ + .target = if (frame.custom_shader_state) |state| + .{ .texture = state.back_texture } + else + .{ .target = frame.target }, + .clear_color = .{ 0.0, 0.0, 0.0, 0.0 }, + }}); + defer pass.complete(); + + // First we draw our background image, if we have one. + // The bg image shader also draws the main bg color. + // + // Otherwise, if we don't have a background image, we + // draw the background color by itself in its own step. + // + // NOTE: We don't use the clear_color for this because that + // would require us to do color space conversion on the + // CPU-side. In the future when we have utilities for + // that we should remove this step and use clear_color. + if (self.bg_image) |img| switch (img) { + .ready => |texture| pass.step(.{ + .pipeline = self.shaders.pipelines.bg_image, + .uniforms = frame.uniforms.buffer, + .buffers = &.{frame.bg_image_buffer.buffer}, + .textures = &.{texture}, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }), + else => {}, + } else { + pass.step(.{ + .pipeline = self.shaders.pipelines.bg_color, + .uniforms = frame.uniforms.buffer, + .buffers = &.{ null, frame.cells_bg.buffer }, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }); + } + + // Then we draw any kitty images that need + // to be behind text AND cell backgrounds. + try self.drawImagePlacements( + &pass, + self.image_placements.items[0..self.image_bg_end], + ); + + // Then we draw any opaque cell backgrounds. + pass.step(.{ + .pipeline = self.shaders.pipelines.cell_bg, + .uniforms = frame.uniforms.buffer, + .buffers = &.{ null, frame.cells_bg.buffer }, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }); + + // Kitty images between cell backgrounds and text. + try self.drawImagePlacements( + &pass, + self.image_placements.items[self.image_bg_end..self.image_text_end], + ); + + // Text. + pass.step(.{ + .pipeline = self.shaders.pipelines.cell_text, + .uniforms = frame.uniforms.buffer, + .buffers = &.{ + frame.cells.buffer, + frame.cells_bg.buffer, + }, + .textures = &.{ + frame.grayscale, + frame.color, + }, + .draw = .{ + .type = .triangle_strip, + .vertex_count = 4, + .instance_count = fg_count, + }, + }); + + // Kitty images in front of text. + try self.drawImagePlacements( + &pass, + self.image_placements.items[self.image_text_end..], + ); + } + + // If we have custom shaders, then we render them. + if (frame.custom_shader_state) |*state| { + // Sync our uniforms. + try state.uniforms.sync(&.{self.custom_shader_uniforms}); + + for (self.shaders.post_pipelines, 0..) |pipeline, i| { + defer state.swap(); + + var pass = frame_ctx.renderPass(&.{.{ + .target = if (i < self.shaders.post_pipelines.len - 1) + .{ .texture = state.front_texture } + else + .{ .target = frame.target }, + .clear_color = .{ 0.0, 0.0, 0.0, 0.0 }, + }}); + defer pass.complete(); + + pass.step(.{ + .pipeline = pipeline, + .uniforms = state.uniforms.buffer, + .textures = &.{state.back_texture}, + .draw = .{ + .type = .triangle, + .vertex_count = 3, + }, + }); + } + } + } + + // Callback from the graphics API when a frame is completed. + pub fn frameCompleted( + self: *Self, + health: Health, + ) void { + // If our health value hasn't changed, then we do nothing. We don't + // do a cmpxchg here because strict atomicity isn't important. + if (self.health.load(.seq_cst) != health) { + self.health.store(health, .seq_cst); + + // Our health value changed, so we notify the surface so that it + // can do something about it. + _ = self.surface_mailbox.push(.{ + .renderer_health = health, + }, .{ .forever = {} }); + } + + // Always release our semaphore + self.swap_chain.releaseFrame(); + } + + fn drawImagePlacements( + self: *Self, + pass: *RenderPass, + placements: []const imagepkg.Placement, + ) !void { + if (placements.len == 0) return; + + for (placements) |p| { + + // Look up the image + const image = self.images.get(p.image_id) orelse { + log.warn("image not found for placement image_id={}", .{p.image_id}); + return; + }; + + // Get the texture + const texture = switch (image.image) { + .ready => |t| t, + else => { + log.warn("image not ready for placement image_id={}", .{p.image_id}); + return; + }, + }; + + // Create our vertex buffer, which is always exactly one item. + // future(mitchellh): we can group rendering multiple instances of a single image + var buf = try Buffer(shaderpkg.Image).initFill( + self.api.imageBufferOptions(), + &.{.{ + .grid_pos = .{ + @as(f32, @floatFromInt(p.x)), + @as(f32, @floatFromInt(p.y)), + }, + + .cell_offset = .{ + @as(f32, @floatFromInt(p.cell_offset_x)), + @as(f32, @floatFromInt(p.cell_offset_y)), + }, + + .source_rect = .{ + @as(f32, @floatFromInt(p.source_x)), + @as(f32, @floatFromInt(p.source_y)), + @as(f32, @floatFromInt(p.source_width)), + @as(f32, @floatFromInt(p.source_height)), + }, + + .dest_size = .{ + @as(f32, @floatFromInt(p.width)), + @as(f32, @floatFromInt(p.height)), + }, + }}, + ); + defer buf.deinit(); + + pass.step(.{ + .pipeline = self.shaders.pipelines.image, + .buffers = &.{buf.buffer}, + .textures = &.{texture}, + .draw = .{ + .type = .triangle_strip, + .vertex_count = 4, + }, + }); + } + } + + /// This goes through the Kitty graphic placements and accumulates the + /// placements we need to render on our viewport. + fn prepKittyGraphics( + self: *Self, + t: *terminal.Terminal, + ) !void { + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + const storage = &t.screen.kitty_images; + defer storage.dirty = false; + + // We always clear our previous placements no matter what because + // we rebuild them from scratch. + self.image_placements.clearRetainingCapacity(); + self.image_virtual = false; + + // Go through our known images and if there are any that are no longer + // in use then mark them to be freed. + // + // This never conflicts with the below because a placement can't + // reference an image that doesn't exist. + { + var it = self.images.iterator(); + while (it.next()) |kv| { + if (storage.imageById(kv.key_ptr.*) == null) { + kv.value_ptr.image.markForUnload(); + } + } + } + + // The top-left and bottom-right corners of our viewport in screen + // points. This lets us determine offsets and containment of placements. + const top = t.screen.pages.getTopLeft(.viewport); + const bot = t.screen.pages.getBottomRight(.viewport).?; + const top_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; + const bot_y = t.screen.pages.pointFromPin(.screen, bot).?.screen.y; + + // Go through the placements and ensure the image is + // on the GPU or else is ready to be sent to the GPU. + var it = storage.placements.iterator(); + while (it.next()) |kv| { + const p = kv.value_ptr; + + // Special logic based on location + switch (p.location) { + .pin => {}, + .virtual => { + // We need to mark virtual placements on our renderer so that + // we know to rebuild in more scenarios since cell changes can + // now trigger placement changes. + self.image_virtual = true; + + // We also continue out because virtual placements are + // only triggered by the unicode placeholder, not by the + // placement itself. + continue; + }, + } + + // Get the image for the placement + const image = storage.imageById(kv.key_ptr.image_id) orelse { + log.warn( + "missing image for placement, ignoring image_id={}", + .{kv.key_ptr.image_id}, + ); + continue; + }; + + try self.prepKittyPlacement(t, top_y, bot_y, &image, p); + } + + // If we have virtual placements then we need to scan for placeholders. + if (self.image_virtual) { + var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot); + while (v_it.next()) |virtual_p| try self.prepKittyVirtualPlacement( + t, + &virtual_p, + ); + } + + // Sort the placements by their Z value. + std.mem.sortUnstable( + imagepkg.Placement, + self.image_placements.items, + {}, + struct { + fn lessThan( + ctx: void, + lhs: imagepkg.Placement, + rhs: imagepkg.Placement, + ) bool { + _ = ctx; + return lhs.z < rhs.z or (lhs.z == rhs.z and lhs.image_id < rhs.image_id); + } + }.lessThan, + ); + + // Find our indices. The values are sorted by z so we can + // find the first placement out of bounds to find the limits. + var bg_end: ?u32 = null; + var text_end: ?u32 = null; + const bg_limit = std.math.minInt(i32) / 2; + for (self.image_placements.items, 0..) |p, i| { + if (bg_end == null and p.z >= bg_limit) { + bg_end = @intCast(i); + } + if (text_end == null and p.z >= 0) { + text_end = @intCast(i); + } + } + + // If we didn't see any images with a z > the bg limit, + // then our bg end is the end of our placement list. + self.image_bg_end = + bg_end orelse @intCast(self.image_placements.items.len); + + // Same idea for the image_text_end. + self.image_text_end = + text_end orelse @intCast(self.image_placements.items.len); + } + + fn prepKittyVirtualPlacement( + self: *Self, + t: *terminal.Terminal, + p: *const terminal.kitty.graphics.unicode.Placement, + ) !void { + const storage = &t.screen.kitty_images; + const image = storage.imageById(p.image_id) orelse { + log.warn( + "missing image for virtual placement, ignoring image_id={}", + .{p.image_id}, + ); + return; + }; + + const rp = p.renderPlacement( + storage, + &image, + self.grid_metrics.cell_width, + self.grid_metrics.cell_height, + ) catch |err| { + log.warn("error rendering virtual placement err={}", .{err}); + return; + }; + + // If our placement is zero sized then we don't do anything. + if (rp.dest_width == 0 or rp.dest_height == 0) return; + + const viewport: terminal.point.Point = t.screen.pages.pointFromPin( + .viewport, + rp.top_left, + ) orelse { + // This is unreachable with virtual placements because we should + // only ever be looking at virtual placements that are in our + // viewport in the renderer and virtual placements only ever take + // up one row. + unreachable; + }; + + // Prepare the image for the GPU and store the placement. + try self.prepKittyImage(&image); + try self.image_placements.append(self.alloc, .{ + .image_id = image.id, + .x = @intCast(rp.top_left.x), + .y = @intCast(viewport.viewport.y), + .z = -1, + .width = rp.dest_width, + .height = rp.dest_height, + .cell_offset_x = rp.offset_x, + .cell_offset_y = rp.offset_y, + .source_x = rp.source_x, + .source_y = rp.source_y, + .source_width = rp.source_width, + .source_height = rp.source_height, + }); + } + + /// Get the viewport-relative position for this + /// placement and add it to the placements list. + fn prepKittyPlacement( + self: *Self, + t: *terminal.Terminal, + top_y: u32, + bot_y: u32, + image: *const terminal.kitty.graphics.Image, + p: *const terminal.kitty.graphics.ImageStorage.Placement, + ) !void { + // Get the rect for the placement. If this placement doesn't have + // a rect then its virtual or something so skip it. + const rect = p.rect(image.*, t) orelse return; + + // This is expensive but necessary. + const img_top_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + const img_bot_y = t.screen.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; + + // If the selection isn't within our viewport then skip it. + if (img_top_y > bot_y) return; + if (img_bot_y < top_y) return; + + // We need to prep this image for upload if it isn't in the + // cache OR it is in the cache but the transmit time doesn't + // match meaning this image is different. + try self.prepKittyImage(image); + + // Calculate the dimensions of our image, taking in to + // account the rows / columns specified by the placement. + const dest_size = p.calculatedSize(image.*, t); + + // Calculate the source rectangle + const source_x = @min(image.width, p.source_x); + const source_y = @min(image.height, p.source_y); + const source_width = if (p.source_width > 0) + @min(image.width - source_x, p.source_width) + else + image.width; + const source_height = if (p.source_height > 0) + @min(image.height - source_y, p.source_height) + else + image.height; + + // Get the viewport-relative Y position of the placement. + const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y)); + + // Accumulate the placement + if (dest_size.width > 0 and dest_size.height > 0) { + try self.image_placements.append(self.alloc, .{ + .image_id = image.id, + .x = @intCast(rect.top_left.x), + .y = y_pos, + .z = p.z, + .width = dest_size.width, + .height = dest_size.height, + .cell_offset_x = p.x_offset, + .cell_offset_y = p.y_offset, + .source_x = source_x, + .source_y = source_y, + .source_width = source_width, + .source_height = source_height, + }); + } + } + + /// Prepare the provided image for upload to the GPU by copying its + /// data with our allocator and setting it to the pending state. + fn prepKittyImage( + self: *Self, + image: *const terminal.kitty.graphics.Image, + ) !void { + // If this image exists and its transmit time is the same we assume + // it is the identical image so we don't need to send it to the GPU. + const gop = try self.images.getOrPut(self.alloc, image.id); + if (gop.found_existing and + gop.value_ptr.transmit_time.order(image.transmit_time) == .eq) + { + return; + } + + // Copy the data into the pending state. + const data = try self.alloc.dupe(u8, image.data); + errdefer self.alloc.free(data); + + // Store it in the map + const pending: Image.Pending = .{ + .width = image.width, + .height = image.height, + .pixel_format = switch (image.format) { + .gray => .gray, + .gray_alpha => .gray_alpha, + .rgb => .rgb, + .rgba => .rgba, + .png => unreachable, // should be decoded by now + }, + .data = data.ptr, + }; + + const new_image: Image = .{ .pending = pending }; + + if (!gop.found_existing) { + gop.value_ptr.* = .{ + .image = new_image, + .transmit_time = undefined, + }; + } else { + try gop.value_ptr.image.markForReplace( + self.alloc, + new_image, + ); + } + + try gop.value_ptr.image.prepForUpload(self.alloc); + + gop.value_ptr.transmit_time = image.transmit_time; + } + + /// Upload any images to the GPU that need to be uploaded, + /// and remove any images that are no longer needed on the GPU. + fn uploadKittyImages(self: *Self) !void { + var image_it = self.images.iterator(); + while (image_it.next()) |kv| { + const img = &kv.value_ptr.image; + if (img.isUnloading()) { + img.deinit(self.alloc); + self.images.removeByPtr(kv.key_ptr); + return; + } + if (img.isPending()) try img.upload(self.alloc, &self.api); + } + } + + /// Call this any time the background image path changes. + /// + /// Caller must hold the draw mutex. + fn prepBackgroundImage(self: *Self) !void { + // Then we try to load the background image if we have a path. + if (self.config.bg_image) |p| load_background: { + const path = switch (p) { + .required, .optional => |slice| slice, + }; + + // Open the file + var file = std.fs.openFileAbsolute(path, .{}) catch |err| { + log.warn( + "error opening background image file \"{s}\": {}", + .{ path, err }, + ); + break :load_background; + }; + defer file.close(); + + // Read it + const contents = file.readToEndAlloc( + self.alloc, + std.math.maxInt(u32), // Max size of 4 GiB, for now. + ) catch |err| { + log.warn( + "error reading background image file \"{s}\": {}", + .{ path, err }, + ); + break :load_background; + }; + defer self.alloc.free(contents); + + // Figure out what type it probably is. + const file_type = switch (FileType.detect(contents)) { + .unknown => FileType.guessFromExtension( + std.fs.path.extension(path), + ), + else => |t| t, + }; + + // Decode it if we know how. + const image_data = switch (file_type) { + .png => try wuffs.png.decode(self.alloc, contents), + .jpeg => try wuffs.jpeg.decode(self.alloc, contents), + .unknown => { + log.warn( + "Cannot determine file type for background image file \"{s}\"!", + .{path}, + ); + break :load_background; + }, + else => |f| { + log.warn( + "Unsupported file type {} for background image file \"{s}\"!", + .{ f, path }, + ); + break :load_background; + }, + }; + + const image: imagepkg.Image = .{ + .pending = .{ + .width = image_data.width, + .height = image_data.height, + .pixel_format = .rgba, + .data = image_data.data.ptr, + }, + }; + + // If we have an existing background image, replace it. + // Otherwise, set this as our background image directly. + if (self.bg_image) |*img| { + try img.markForReplace(self.alloc, image); + } else { + self.bg_image = image; + } + } else { + // If we don't have a background image path, mark our + // background image for unload if we currently have one. + if (self.bg_image) |*img| img.markForUnload(); + } + } + + fn uploadBackgroundImage(self: *Self) !void { + // Make sure our bg image is uploaded if it needs to be. + if (self.bg_image) |*bg| { + if (bg.isUnloading()) { + bg.deinit(self.alloc); + self.bg_image = null; + return; + } + if (bg.isPending()) try bg.upload(self.alloc, &self.api); + } + } + + /// Update the configuration. + pub fn changeConfig(self: *Self, config: *DerivedConfig) !void { + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // We always redo the font shaper in case font features changed. We + // could check to see if there was an actual config change but this is + // easier and rare enough to not cause performance issues. + { + var font_shaper = try font.Shaper.init(self.alloc, .{ + .features = config.font_features.items, + }); + errdefer font_shaper.deinit(); + self.font_shaper.deinit(); + self.font_shaper = font_shaper; + } + + // We also need to reset the shaper cache so shaper info + // from the previous font isn't re-used for the new font. + const font_shaper_cache = font.ShaperCache.init(); + self.font_shaper_cache.deinit(self.alloc); + self.font_shaper_cache = font_shaper_cache; + + // Set our new minimum contrast + self.uniforms.min_contrast = config.min_contrast; + + // Set our new color space and blending + self.uniforms.bools.use_display_p3 = config.colorspace == .@"display-p3"; + self.uniforms.bools.use_linear_blending = config.blending.isLinear(); + self.uniforms.bools.use_linear_correction = config.blending == .@"linear-corrected"; + + // Set our new colors + self.default_background_color = config.background; + self.default_foreground_color = config.foreground; + self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null; + self.cursor_invert = config.cursor_invert; + + const bg_image_config_changed = + self.config.bg_image_fit != config.bg_image_fit or + self.config.bg_image_position != config.bg_image_position or + self.config.bg_image_repeat != config.bg_image_repeat or + self.config.bg_image_opacity != config.bg_image_opacity; + + const bg_image_changed = + if (self.config.bg_image) |old| + if (config.bg_image) |new| + !old.equal(new) + else + true + else + config.bg_image != null; + + const old_blending = self.config.blending; + const custom_shaders_changed = !self.config.custom_shaders.equal(config.custom_shaders); + + self.config.deinit(); + self.config = config.*; + + // If our background image path changed, prepare the new bg image. + if (bg_image_changed) try self.prepBackgroundImage(); + + // If our background image config changed, update the vertex buffer. + if (bg_image_config_changed) self.updateBgImageBuffer(); + + // Reset our viewport to force a rebuild, in case of a font change. + self.cells_viewport = null; + + const blending_changed = old_blending != config.blending; + + if (blending_changed) { + // We update our API's blending mode. + self.api.blending = config.blending; + // And indicate that we need to reinitialize our shaders. + self.reinitialize_shaders = true; + // And indicate that our swap chain targets need to + // be re-created to account for the new blending mode. + self.target_config_modified +%= 1; + } + + if (custom_shaders_changed) { + self.reinitialize_shaders = true; + } + } + + /// Resize the screen. + pub fn setScreenSize( + self: *Self, + size: renderer.Size, + ) void { + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // We only actually need the padding from this, + // everything else is derived elsewhere. + self.size.padding = size.padding; + + self.updateScreenSizeUniforms(); + + log.debug("screen size size={}", .{size}); + } + + /// Update uniforms that are based on the screen size. + /// + /// Caller must hold the draw mutex. + fn updateScreenSizeUniforms(self: *Self) void { + const terminal_size = self.size.terminal(); + + // Blank space around the grid. + const blank: renderer.Padding = self.size.screen.blankPadding( + self.size.padding, + .{ + .columns = self.cells.size.columns, + .rows = self.cells.size.rows, + }, + .{ + .width = self.grid_metrics.cell_width, + .height = self.grid_metrics.cell_height, + }, + ).add(self.size.padding); + + // Setup our uniforms + self.uniforms.projection_matrix = math.ortho2d( + -1 * @as(f32, @floatFromInt(self.size.padding.left)), + @floatFromInt(terminal_size.width + self.size.padding.right), + @floatFromInt(terminal_size.height + self.size.padding.bottom), + -1 * @as(f32, @floatFromInt(self.size.padding.top)), + ); + self.uniforms.grid_padding = .{ + @floatFromInt(blank.top), + @floatFromInt(blank.right), + @floatFromInt(blank.bottom), + @floatFromInt(blank.left), + }; + self.uniforms.screen_size = .{ + @floatFromInt(self.size.screen.width), + @floatFromInt(self.size.screen.height), + }; + } + + /// Update the background image vertex buffer (CPU-side). + /// + /// This should be called if and when configs change that + /// could affect the background image. + /// + /// Caller must hold the draw mutex. + fn updateBgImageBuffer(self: *Self) void { + self.bg_image_buffer = .{ + .opacity = self.config.bg_image_opacity, + .info = .{ + .position = switch (self.config.bg_image_position) { + .@"top-left" => .tl, + .@"top-center" => .tc, + .@"top-right" => .tr, + .@"center-left" => .ml, + .@"center-center", .center => .mc, + .@"center-right" => .mr, + .@"bottom-left" => .bl, + .@"bottom-center" => .bc, + .@"bottom-right" => .br, + }, + .fit = switch (self.config.bg_image_fit) { + .contain => .contain, + .cover => .cover, + .stretch => .stretch, + .none => .none, + }, + .repeat = self.config.bg_image_repeat, + }, + }; + // Signal that the buffer was modified. + self.bg_image_buffer_modified +%= 1; + } + + /// Update uniforms for the custom shaders, if necessary. + /// + /// This should be called exactly once per frame, inside `drawFrame`. + fn updateCustomShaderUniforms(self: *Self) !void { + // We only need to do this if we have custom shaders. + if (!self.has_custom_shaders) return; + + const now = try std.time.Instant.now(); + defer self.last_frame_time = now; + const first_frame_time = self.first_frame_time orelse t: { + self.first_frame_time = now; + break :t now; + }; + const last_frame_time = self.last_frame_time orelse now; + + const since_ns: f32 = @floatFromInt(now.since(first_frame_time)); + self.custom_shader_uniforms.time = since_ns / std.time.ns_per_s; + + const delta_ns: f32 = @floatFromInt(now.since(last_frame_time)); + self.custom_shader_uniforms.time_delta = delta_ns / std.time.ns_per_s; + + self.custom_shader_uniforms.frame += 1; + + const screen = self.size.screen; + const padding = self.size.padding; + const cell = self.size.cell; + + self.custom_shader_uniforms.resolution = .{ + @floatFromInt(screen.width), + @floatFromInt(screen.height), + 1, + }; + self.custom_shader_uniforms.channel_resolution[0] = .{ + @floatFromInt(screen.width), + @floatFromInt(screen.height), + 1, + 0, + }; + + // Update custom cursor uniforms, if we have a cursor. + if (self.cells.fg_rows.lists[0].items.len > 0) { + const cursor: shaderpkg.CellText = + self.cells.fg_rows.lists[0].items[0]; + + const cursor_width: f32 = @floatFromInt(cursor.glyph_size[0]); + const cursor_height: f32 = @floatFromInt(cursor.glyph_size[1]); + + var pixel_x: f32 = @floatFromInt( + cursor.grid_pos[0] * cell.width + padding.left, + ); + var pixel_y: f32 = @floatFromInt( + cursor.grid_pos[1] * cell.height + padding.top, + ); + + pixel_x += @floatFromInt(cursor.bearings[0]); + pixel_y += @floatFromInt(cursor.bearings[1]); + + // If +Y is up in our shaders, we need to flip the coordinate. + if (!GraphicsAPI.custom_shader_y_is_down) { + pixel_y = @as(f32, @floatFromInt(screen.height)) - pixel_y; + // We need to add the cursor height because we need the +Y + // edge for the Y coordinate, and flipping means that it's + // the -Y edge now. + pixel_y += cursor_height; + } + + const new_cursor: [4]f32 = .{ + pixel_x, + pixel_y, + cursor_width, + cursor_height, + }; + const cursor_color: [4]f32 = .{ + @as(f32, @floatFromInt(cursor.color[0])) / 255.0, + @as(f32, @floatFromInt(cursor.color[1])) / 255.0, + @as(f32, @floatFromInt(cursor.color[2])) / 255.0, + @as(f32, @floatFromInt(cursor.color[3])) / 255.0, + }; + + const uniforms = &self.custom_shader_uniforms; + + const cursor_changed: bool = + !std.meta.eql(new_cursor, uniforms.current_cursor) or + !std.meta.eql(cursor_color, uniforms.current_cursor_color); + + if (cursor_changed) { + uniforms.previous_cursor = uniforms.current_cursor; + uniforms.previous_cursor_color = uniforms.current_cursor_color; + uniforms.current_cursor = new_cursor; + uniforms.current_cursor_color = cursor_color; + uniforms.cursor_change_time = uniforms.time; + } + } + } + + /// Convert the terminal state to GPU cells stored in CPU memory. These + /// are then synced to the GPU in the next frame. This only updates CPU + /// memory and doesn't touch the GPU. + fn rebuildCells( + self: *Self, + wants_rebuild: bool, + screen: *terminal.Screen, + screen_type: terminal.ScreenType, + mouse: renderer.State.Mouse, + preedit: ?renderer.State.Preedit, + cursor_style_: ?renderer.CursorStyle, + color_palette: *const terminal.color.Palette, + ) !void { + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // const start = try std.time.Instant.now(); + // const start_micro = std.time.microTimestamp(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // // "[rebuildCells time] \t" + // std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us}); + // } + + _ = screen_type; // we might use this again later so not deleting it yet + + // Create an arena for all our temporary allocations while rebuilding + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const arena_alloc = arena.allocator(); + + // Create our match set for the links. + var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( + arena_alloc, + screen, + mouse_pt, + mouse.mods, + ) else .{}; + + // Determine our x/y range for preedit. We don't want to render anything + // here because we will render the preedit separately. + const preedit_range: ?struct { + y: terminal.size.CellCountInt, + x: [2]terminal.size.CellCountInt, + cp_offset: usize, + } = if (preedit) |preedit_v| preedit: { + const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1); + break :preedit .{ + .y = screen.cursor.y, + .x = .{ range.start, range.end }, + .cp_offset = range.cp_offset, + }; + } else null; + + const grid_size_diff = + self.cells.size.rows != screen.pages.rows or + self.cells.size.columns != screen.pages.cols; + + if (grid_size_diff) { + var new_size = self.cells.size; + new_size.rows = screen.pages.rows; + new_size.columns = screen.pages.cols; + try self.cells.resize(self.alloc, new_size); + + // Update our uniforms accordingly, otherwise + // our background cells will be out of place. + self.uniforms.grid_size = .{ new_size.columns, new_size.rows }; + } + + const rebuild = wants_rebuild or grid_size_diff; + + if (rebuild) { + // If we are doing a full rebuild, then we clear the entire cell buffer. + self.cells.reset(); + + // We also reset our padding extension depending on the screen type + switch (self.config.padding_color) { + .background => {}, + + // For extension, assume we are extending in all directions. + // For "extend" this may be disabled due to heuristics below. + .extend, .@"extend-always" => { + self.uniforms.padding_extend = .{ + .up = true, + .down = true, + .left = true, + .right = true, + }; + }, + } + } + + // We rebuild the cells row-by-row because we + // do font shaping and dirty tracking by row. + var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null); + // If our cell contents buffer is shorter than the screen viewport, + // we render the rows that fit, starting from the bottom. If instead + // the viewport is shorter than the cell contents buffer, we align + // the top of the viewport with the top of the contents buffer. + var y: terminal.size.CellCountInt = @min( + screen.pages.rows, + self.cells.size.rows, + ); + while (row_it.next()) |row| { + // The viewport may have more rows than our cell contents, + // so we need to break from the loop early if we hit y = 0. + if (y == 0) break; + + y -= 1; + + if (!rebuild) { + // Only rebuild if we are doing a full rebuild or this row is dirty. + if (!row.isDirty()) continue; + + // Clear the cells if the row is dirty + self.cells.clear(y); + } + + // True if we want to do font shaping around the cursor. + // We want to do font shaping as long as the cursor is enabled. + const shape_cursor = screen.viewportIsBottom() and + y == screen.cursor.y; + + // We need to get this row's selection, if + // there is one, for proper run splitting. + const row_selection = sel: { + const sel = screen.selection orelse break :sel null; + const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse + break :sel null; + break :sel sel.containedRow(screen, pin) orelse null; + }; + + // On primary screen, we still apply vertical padding + // extension under certain conditions we feel are safe. + // + // This helps make some scenarios look better while + // avoiding scenarios we know do NOT look good. + switch (self.config.padding_color) { + // These already have the correct values set above. + .background, .@"extend-always" => {}, + + // Apply heuristics for padding extension. + .extend => if (y == 0) { + self.uniforms.padding_extend.up = !row.neverExtendBg( + color_palette, + self.background_color orelse self.default_background_color, + ); + } else if (y == self.cells.size.rows - 1) { + self.uniforms.padding_extend.down = !row.neverExtendBg( + color_palette, + self.background_color orelse self.default_background_color, + ); + }, + } + + // Iterator of runs for shaping. + var run_iter = self.font_shaper.runIterator( + self.font_grid, + screen, + row, + row_selection, + if (shape_cursor) screen.cursor.x else null, + ); + var shaper_run: ?font.shape.TextRun = try run_iter.next(self.alloc); + var shaper_cells: ?[]const font.shape.Cell = null; + var shaper_cells_i: usize = 0; + + const row_cells_all = row.cells(.all); + + // If our viewport is wider than our cell contents buffer, + // we still only process cells up to the width of the buffer. + const row_cells = row_cells_all[0..@min(row_cells_all.len, self.cells.size.columns)]; + + for (row_cells, 0..) |*cell, x| { + // If this cell falls within our preedit range then we + // skip this because preedits are setup separately. + if (preedit_range) |range| preedit: { + // We're not on the preedit line, no actions necessary. + if (range.y != y) break :preedit; + // We're before the preedit range, no actions necessary. + if (x < range.x[0]) break :preedit; + // We're in the preedit range, skip this cell. + if (x <= range.x[1]) continue; + // After exiting the preedit range we need to catch + // the run position up because of the missed cells. + // In all other cases, no action is necessary. + if (x != range.x[1] + 1) break :preedit; + + // Step the run iterator until we find a run that ends + // after the current cell, which will be the soonest run + // that might contain glyphs for our cell. + while (shaper_run) |run| { + if (run.offset + run.cells > x) break; + shaper_run = try run_iter.next(self.alloc); + shaper_cells = null; + shaper_cells_i = 0; + } + + const run = shaper_run orelse break :preedit; + + // If we haven't shaped this run, do so now. + shaper_cells = shaper_cells orelse + // Try to read the cells from the shaping cache if we can. + self.font_shaper_cache.get(run) orelse + cache: { + // Otherwise we have to shape them. + const cells = try self.font_shaper.shape(run); + + // Try to cache them. If caching fails for any reason we + // continue because it is just a performance optimization, + // not a correctness issue. + self.font_shaper_cache.put( + self.alloc, + run, + cells, + ) catch |err| { + log.warn( + "error caching font shaping results err={}", + .{err}, + ); + }; + + // The cells we get from direct shaping are always owned + // by the shaper and valid until the next shaping call so + // we can safely use them. + break :cache cells; + }; + + // Advance our index until we reach or pass + // our current x position in the shaper cells. + while (shaper_cells.?[shaper_cells_i].x < x) { + shaper_cells_i += 1; + } + } + + const wide = cell.wide; + + const style = row.style(cell); + + const cell_pin: terminal.Pin = cell: { + var copy = row; + copy.x = @intCast(x); + break :cell copy; + }; + + // True if this cell is selected + const selected: bool = if (screen.selection) |sel| + sel.contains(screen, .{ + .node = row.node, + .y = row.y, + .x = @intCast( + // Spacer tails should show the selection + // state of the wide cell they belong to. + if (wide == .spacer_tail) + x -| 1 + else + x, + ), + }) + else + false; + + const bg_style = style.bg(cell, color_palette); + const fg_style = style.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color; + + // The final background color for the cell. + const bg = bg: { + if (selected) { + break :bg if (self.config.invert_selection_fg_bg) + if (style.flags.inverse) + // Cell is selected with invert selection fg/bg + // enabled, and the cell has the inverse style + // flag, so they cancel out and we get the normal + // bg color. + bg_style + else + // If it doesn't have the inverse style + // flag then we use the fg color instead. + fg_style + else + // If we don't have invert selection fg/bg set then we + // just use the selection background if set, otherwise + // the default fg color. + break :bg self.config.selection_background orelse self.foreground_color orelse self.default_foreground_color; + } + + // Not selected + break :bg if (style.flags.inverse != isCovering(cell.codepoint())) + // Two cases cause us to invert (use the fg color as the bg) + // - The "inverse" style flag. + // - A "covering" glyph; we use fg for bg in that + // case to help make sure that padding extension + // works correctly. + // + // If one of these is true (but not the other) + // then we use the fg style color for the bg. + fg_style + else + // Otherwise they cancel out. + bg_style; + }; + + const fg = fg: { + if (selected and !self.config.invert_selection_fg_bg) { + // If we don't have invert selection fg/bg set + // then we just use the selection foreground if + // set, otherwise the default bg color. + break :fg self.config.selection_foreground orelse self.background_color orelse self.default_background_color; + } + + // Whether we need to use the bg color as our fg color: + // - Cell is inverted and not selected + // - Cell is selected and not inverted + // Note: if selected then invert sel fg / bg must be + // false since we separately handle it if true above. + break :fg if (style.flags.inverse != selected) + bg_style orelse self.background_color orelse self.default_background_color + else + fg_style; + }; + + // Foreground alpha for this cell. + const alpha: u8 = if (style.flags.faint) 175 else 255; + + // Set the cell's background color. + { + const rgb = bg orelse self.background_color orelse self.default_background_color; + + // Determine our background alpha. If we have transparency configured + // then this is dynamic depending on some situations. This is all + // in an attempt to make transparency look the best for various + // situations. See inline comments. + const bg_alpha: u8 = bg_alpha: { + const default: u8 = 255; + + // Cells that are selected should be fully opaque. + if (selected) break :bg_alpha default; + + // Cells that are reversed should be fully opaque. + if (style.flags.inverse) break :bg_alpha default; + + // Cells that have an explicit bg color should be fully opaque. + if (bg_style != null) break :bg_alpha default; + + // Otherwise, we won't draw the bg for this cell, + // we'll let the already-drawn background color + // show through. + break :bg_alpha 0; + }; + + self.cells.bgCell(y, x).* = .{ + rgb.r, rgb.g, rgb.b, bg_alpha, + }; + } + + // If the invisible flag is set on this cell then we + // don't need to render any foreground elements, so + // we just skip all glyphs with this x coordinate. + // + // NOTE: This behavior matches xterm. Some other terminal + // emulators, e.g. Alacritty, still render text decorations + // and only make the text itself invisible. The decision + // has been made here to match xterm's behavior for this. + if (style.flags.invisible) { + continue; + } + + // Give links a single underline, unless they already have + // an underline, in which case use a double underline to + // distinguish them. + const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin)) + if (style.flags.underline == .single) + .double + else + .single + else + style.flags.underline; + + // We draw underlines first so that they layer underneath text. + // This improves readability when a colored underline is used + // which intersects parts of the text (descenders). + if (underline != .none) self.addUnderline( + @intCast(x), + @intCast(y), + underline, + style.underlineColor(color_palette) orelse fg, + alpha, + ) catch |err| { + log.warn( + "error adding underline to cell, will be invalid x={} y={}, err={}", + .{ x, y, err }, + ); + }; + + if (style.flags.overline) self.addOverline(@intCast(x), @intCast(y), fg, alpha) catch |err| { + log.warn( + "error adding overline to cell, will be invalid x={} y={}, err={}", + .{ x, y, err }, + ); + }; + + // If we're at or past the end of our shaper run then + // we need to get the next run from the run iterator. + if (shaper_cells != null and shaper_cells_i >= shaper_cells.?.len) { + shaper_run = try run_iter.next(self.alloc); + shaper_cells = null; + shaper_cells_i = 0; + } + + if (shaper_run) |run| glyphs: { + // If we haven't shaped this run yet, do so. + shaper_cells = shaper_cells orelse + // Try to read the cells from the shaping cache if we can. + self.font_shaper_cache.get(run) orelse + cache: { + // Otherwise we have to shape them. + const cells = try self.font_shaper.shape(run); + + // Try to cache them. If caching fails for any reason we + // continue because it is just a performance optimization, + // not a correctness issue. + self.font_shaper_cache.put( + self.alloc, + run, + cells, + ) catch |err| { + log.warn( + "error caching font shaping results err={}", + .{err}, + ); + }; + + // The cells we get from direct shaping are always owned + // by the shaper and valid until the next shaping call so + // we can safely use them. + break :cache cells; + }; + + const cells = shaper_cells orelse break :glyphs; + + // If there are no shaper cells for this run, ignore it. + // This can occur for runs of empty cells, and is fine. + if (cells.len == 0) break :glyphs; + + // If we encounter a shaper cell to the left of the current + // cell then we have some problems. This logic relies on x + // position monotonically increasing. + assert(cells[shaper_cells_i].x >= x); + + // NOTE: An assumption is made here that a single cell will never + // be present in more than one shaper run. If that assumption is + // violated, this logic breaks. + + while (shaper_cells_i < cells.len and cells[shaper_cells_i].x == x) : ({ + shaper_cells_i += 1; + }) { + self.addGlyph( + @intCast(x), + @intCast(y), + cell_pin, + cells[shaper_cells_i], + shaper_run.?, + fg, + alpha, + ) catch |err| { + log.warn( + "error adding glyph to cell, will be invalid x={} y={}, err={}", + .{ x, y, err }, + ); + }; + } + } + + // Finally, draw a strikethrough if necessary. + if (style.flags.strikethrough) self.addStrikethrough( + @intCast(x), + @intCast(y), + fg, + alpha, + ) catch |err| { + log.warn( + "error adding strikethrough to cell, will be invalid x={} y={}, err={}", + .{ x, y, err }, + ); + }; + } + } + + // Setup our cursor rendering information. + cursor: { + // By default, we don't handle cursor inversion on the shader. + self.cells.setCursor(null); + self.uniforms.cursor_pos = .{ + std.math.maxInt(u16), + std.math.maxInt(u16), + }; + + // If we have preedit text, we don't setup a cursor + if (preedit != null) break :cursor; + + // Prepare the cursor cell contents. + const style = cursor_style_ orelse break :cursor; + const cursor_color = self.cursor_color orelse self.default_cursor_color orelse color: { + if (self.cursor_invert) { + // Use the foreground color from the cell under the cursor, if any. + const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); + break :color if (sty.flags.inverse) + // If the cell is reversed, use background color instead. + (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color) + else + (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color); + } else { + break :color self.foreground_color orelse self.default_foreground_color; + } + }; + + self.addCursor(screen, style, cursor_color); + + // If the cursor is visible then we set our uniforms. + if (style == .block and screen.viewportIsBottom()) { + const wide = screen.cursor.page_cell.wide; + + self.uniforms.cursor_pos = .{ + // If we are a spacer tail of a wide cell, our cursor needs + // to move back one cell. The saturate is to ensure we don't + // overflow but this shouldn't happen with well-formed input. + switch (wide) { + .narrow, .spacer_head, .wide => screen.cursor.x, + .spacer_tail => screen.cursor.x -| 1, + }, + screen.cursor.y, + }; + + self.uniforms.bools.cursor_wide = switch (wide) { + .narrow, .spacer_head => false, + .wide, .spacer_tail => true, + }; + + const uniform_color = if (self.cursor_invert) blk: { + // Use the background color from the cell under the cursor, if any. + const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); + break :blk if (sty.flags.inverse) + // If the cell is reversed, use foreground color instead. + (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color) + else + (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color); + } else if (self.config.cursor_text) |txt| + txt + else + self.background_color orelse self.default_background_color; + + self.uniforms.cursor_color = .{ + uniform_color.r, + uniform_color.g, + uniform_color.b, + 255, + }; + } + } + + // Setup our preedit text. + if (preedit) |preedit_v| { + const range = preedit_range.?; + var x = range.x[0]; + for (preedit_v.codepoints[range.cp_offset..]) |cp| { + self.addPreeditCell(cp, .{ .x = x, .y = range.y }) catch |err| { + log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{ + x, + range.y, + err, + }); + }; + + x += if (cp.wide) 2 else 1; + } + } + + // Update that our cells rebuilt + self.cells_rebuilt = true; + + // Log some things + // log.debug("rebuildCells complete cached_runs={}", .{ + // self.font_shaper_cache.count(), + // }); + } + + /// Add an underline decoration to the specified cell + fn addUnderline( + self: *Self, + x: terminal.size.CellCountInt, + y: terminal.size.CellCountInt, + style: terminal.Attribute.Underline, + color: terminal.color.RGB, + alpha: u8, + ) !void { + const sprite: font.Sprite = switch (style) { + .none => unreachable, + .single => .underline, + .double => .underline_double, + .dotted => .underline_dotted, + .dashed => .underline_dashed, + .curly => .underline_curly, + }; + + const render = try self.font_grid.renderGlyph( + self.alloc, + font.sprite_index, + @intFromEnum(sprite), + .{ + .cell_width = 1, + .grid_metrics = self.grid_metrics, + }, + ); + + try self.cells.add(self.alloc, .underline, .{ + .mode = .fg, + .grid_pos = .{ @intCast(x), @intCast(y) }, + .constraint_width = 1, + .color = .{ color.r, color.g, color.b, alpha }, + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, + .glyph_size = .{ render.glyph.width, render.glyph.height }, + .bearings = .{ + @intCast(render.glyph.offset_x), + @intCast(render.glyph.offset_y), + }, + }); + } + + /// Add a overline decoration to the specified cell + fn addOverline( + self: *Self, + x: terminal.size.CellCountInt, + y: terminal.size.CellCountInt, + color: terminal.color.RGB, + alpha: u8, + ) !void { + const render = try self.font_grid.renderGlyph( + self.alloc, + font.sprite_index, + @intFromEnum(font.Sprite.overline), + .{ + .cell_width = 1, + .grid_metrics = self.grid_metrics, + }, + ); + + try self.cells.add(self.alloc, .overline, .{ + .mode = .fg, + .grid_pos = .{ @intCast(x), @intCast(y) }, + .constraint_width = 1, + .color = .{ color.r, color.g, color.b, alpha }, + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, + .glyph_size = .{ render.glyph.width, render.glyph.height }, + .bearings = .{ + @intCast(render.glyph.offset_x), + @intCast(render.glyph.offset_y), + }, + }); + } + + /// Add a strikethrough decoration to the specified cell + fn addStrikethrough( + self: *Self, + x: terminal.size.CellCountInt, + y: terminal.size.CellCountInt, + color: terminal.color.RGB, + alpha: u8, + ) !void { + const render = try self.font_grid.renderGlyph( + self.alloc, + font.sprite_index, + @intFromEnum(font.Sprite.strikethrough), + .{ + .cell_width = 1, + .grid_metrics = self.grid_metrics, + }, + ); + + try self.cells.add(self.alloc, .strikethrough, .{ + .mode = .fg, + .grid_pos = .{ @intCast(x), @intCast(y) }, + .constraint_width = 1, + .color = .{ color.r, color.g, color.b, alpha }, + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, + .glyph_size = .{ render.glyph.width, render.glyph.height }, + .bearings = .{ + @intCast(render.glyph.offset_x), + @intCast(render.glyph.offset_y), + }, + }); + } + + // Add a glyph to the specified cell. + fn addGlyph( + self: *Self, + x: terminal.size.CellCountInt, + y: terminal.size.CellCountInt, + cell_pin: terminal.Pin, + shaper_cell: font.shape.Cell, + shaper_run: font.shape.TextRun, + color: terminal.color.RGB, + alpha: u8, + ) !void { + const rac = cell_pin.rowAndCell(); + const cell = rac.cell; + + // Render + const render = try self.font_grid.renderGlyph( + self.alloc, + shaper_run.font_index, + shaper_cell.glyph_index, + .{ + .grid_metrics = self.grid_metrics, + .thicken = self.config.font_thicken, + .thicken_strength = self.config.font_thicken_strength, + }, + ); + + // If the glyph is 0 width or height, it will be invisible + // when drawn, so don't bother adding it to the buffer. + if (render.glyph.width == 0 or render.glyph.height == 0) { + return; + } + + const mode: shaderpkg.CellText.Mode = switch (fgMode( + render.presentation, + cell_pin, + )) { + .normal => .fg, + .color => .fg_color, + .constrained => .fg_constrained, + .powerline => .fg_powerline, + }; + + try self.cells.add(self.alloc, .text, .{ + .mode = mode, + .grid_pos = .{ @intCast(x), @intCast(y) }, + .constraint_width = cell.gridWidth(), + .color = .{ color.r, color.g, color.b, alpha }, + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, + .glyph_size = .{ render.glyph.width, render.glyph.height }, + .bearings = .{ + @intCast(render.glyph.offset_x + shaper_cell.x_offset), + @intCast(render.glyph.offset_y + shaper_cell.y_offset), + }, + }); + } + + fn addCursor( + self: *Self, + screen: *terminal.Screen, + cursor_style: renderer.CursorStyle, + cursor_color: terminal.color.RGB, + ) void { + // Add the cursor. We render the cursor over the wide character if + // we're on the wide character tail. + const wide, const x = cell: { + // The cursor goes over the screen cursor position. + const cell = screen.cursor.page_cell; + if (cell.wide != .spacer_tail or screen.cursor.x == 0) + break :cell .{ cell.wide == .wide, screen.cursor.x }; + + // If we're part of a wide character, we move the cursor back to + // the actual character. + const prev_cell = screen.cursorCellLeft(1); + break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 }; + }; + + const alpha: u8 = if (!self.focused) 255 else alpha: { + const alpha = 255 * self.config.cursor_opacity; + break :alpha @intFromFloat(@ceil(alpha)); + }; + + const render = switch (cursor_style) { + .block, + .block_hollow, + .bar, + .underline, + => render: { + const sprite: font.Sprite = switch (cursor_style) { + .block => .cursor_rect, + .block_hollow => .cursor_hollow_rect, + .bar => .cursor_bar, + .underline => .underline, + .lock => unreachable, + }; + + break :render self.font_grid.renderGlyph( + self.alloc, + font.sprite_index, + @intFromEnum(sprite), + .{ + .cell_width = if (wide) 2 else 1, + .grid_metrics = self.grid_metrics, + }, + ) catch |err| { + log.warn("error rendering cursor glyph err={}", .{err}); + return; + }; + }, + + .lock => self.font_grid.renderCodepoint( + self.alloc, + 0xF023, // lock symbol + .regular, + .text, + .{ + .cell_width = if (wide) 2 else 1, + .grid_metrics = self.grid_metrics, + }, + ) catch |err| { + log.warn("error rendering cursor glyph err={}", .{err}); + return; + } orelse { + // This should never happen because we embed nerd + // fonts so we just log and return instead of fallback. + log.warn("failed to find lock symbol for cursor codepoint=0xF023", .{}); + return; + }, + }; + + self.cells.setCursor(.{ + .mode = .cursor, + .grid_pos = .{ x, screen.cursor.y }, + .color = .{ cursor_color.r, cursor_color.g, cursor_color.b, alpha }, + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, + .glyph_size = .{ render.glyph.width, render.glyph.height }, + .bearings = .{ + @intCast(render.glyph.offset_x), + @intCast(render.glyph.offset_y), + }, + }); + } + + fn addPreeditCell( + self: *Self, + cp: renderer.State.Preedit.Codepoint, + coord: terminal.Coordinate, + ) !void { + // Preedit is rendered inverted + const bg = self.foreground_color orelse self.default_foreground_color; + const fg = self.background_color orelse self.default_background_color; + + // Render the glyph for our preedit text + const render_ = self.font_grid.renderCodepoint( + self.alloc, + @intCast(cp.codepoint), + .regular, + .text, + .{ .grid_metrics = self.grid_metrics }, + ) catch |err| { + log.warn("error rendering preedit glyph err={}", .{err}); + return; + }; + const render = render_ orelse { + log.warn("failed to find font for preedit codepoint={X}", .{cp.codepoint}); + return; + }; + + // Add our opaque background cell + self.cells.bgCell(coord.y, coord.x).* = .{ + bg.r, bg.g, bg.b, 255, + }; + if (cp.wide and coord.x < self.cells.size.columns - 1) { + self.cells.bgCell(coord.y, coord.x + 1).* = .{ + bg.r, bg.g, bg.b, 255, + }; + } + + // Add our text + try self.cells.add(self.alloc, .text, .{ + .mode = .fg, + .grid_pos = .{ @intCast(coord.x), @intCast(coord.y) }, + .color = .{ fg.r, fg.g, fg.b, 255 }, + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, + .glyph_size = .{ render.glyph.width, render.glyph.height }, + .bearings = .{ + @intCast(render.glyph.offset_x), + @intCast(render.glyph.offset_y), + }, + }); + } + + /// Sync the atlas data to the given texture. This copies the bytes + /// associated with the atlas to the given texture. If the atlas no + /// longer fits into the texture, the texture will be resized. + fn syncAtlasTexture( + self: *const Self, + atlas: *const font.Atlas, + texture: *Texture, + ) !void { + if (atlas.size > texture.width) { + // Free our old texture + texture.*.deinit(); + + // Reallocate + texture.* = try self.api.initAtlasTexture(atlas); + } + + try texture.replaceRegion(0, 0, atlas.size, atlas.size, atlas.data); + } + }; +} diff --git a/src/renderer/image.zig b/src/renderer/image.zig new file mode 100644 index 000000000..d89c46730 --- /dev/null +++ b/src/renderer/image.zig @@ -0,0 +1,302 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const wuffs = @import("wuffs"); + +const Renderer = @import("../renderer.zig").Renderer; +const GraphicsAPI = Renderer.API; +const Texture = GraphicsAPI.Texture; + +/// Represents a single image placement on the grid. +/// A placement is a request to render an instance of an image. +pub const Placement = struct { + /// The image being rendered. This MUST be in the image map. + image_id: u32, + + /// The grid x/y where this placement is located. + x: i32, + y: i32, + z: i32, + + /// The width/height of the placed image. + width: u32, + height: u32, + + /// The offset in pixels from the top left of the cell. + /// This is clamped to the size of a cell. + cell_offset_x: u32, + cell_offset_y: u32, + + /// The source rectangle of the placement. + source_x: u32, + source_y: u32, + source_width: u32, + source_height: u32, +}; + +/// The map used for storing images. +pub const ImageMap = std.AutoHashMapUnmanaged(u32, struct { + image: Image, + transmit_time: std.time.Instant, +}); + +/// The state for a single image that is to be rendered. +pub const Image = union(enum) { + /// The image data is pending upload to the GPU. + /// + /// This data is owned by this union so it must be freed once uploaded. + pending: Pending, + + /// This is the same as the pending states but there is + /// a texture already allocated that we want to replace. + replace: Replace, + + /// The image is uploaded and ready to be used. + ready: Texture, + + /// The image isn't uploaded yet but is scheduled to be unloaded. + unload_pending: Pending, + /// The image is uploaded and is scheduled to be unloaded. + unload_ready: Texture, + /// The image is uploaded and scheduled to be replaced + /// with new data, but it's also scheduled to be unloaded. + unload_replace: Replace, + + pub const Replace = struct { + texture: Texture, + pending: Pending, + }; + + /// Pending image data that needs to be uploaded to the GPU. + pub const Pending = struct { + height: u32, + width: u32, + pixel_format: PixelFormat, + + /// Data is always expected to be (width * height * bpp). + data: [*]u8, + + pub fn dataSlice(self: Pending) []u8 { + return self.data[0..self.len()]; + } + + pub fn len(self: Pending) usize { + return self.width * self.height * self.pixel_format.bpp(); + } + + pub const PixelFormat = enum { + /// 1 byte per pixel grayscale. + gray, + /// 2 bytes per pixel grayscale + alpha. + gray_alpha, + /// 3 bytes per pixel RGB. + rgb, + /// 3 bytes per pixel BGR. + bgr, + /// 4 byte per pixel RGBA. + rgba, + /// 4 byte per pixel BGRA. + bgra, + + /// Get bytes per pixel for this format. + pub inline fn bpp(self: PixelFormat) usize { + return switch (self) { + .gray => 1, + .gray_alpha => 2, + .rgb => 3, + .bgr => 3, + .rgba => 4, + .bgra => 4, + }; + } + }; + }; + + pub fn deinit(self: Image, alloc: Allocator) void { + switch (self) { + .pending, + .unload_pending, + => |p| alloc.free(p.dataSlice()), + + .replace, .unload_replace => |r| { + alloc.free(r.pending.dataSlice()); + r.texture.deinit(); + }, + + .ready, + .unload_ready, + => |t| t.deinit(), + } + } + + /// Mark this image for unload whatever state it is in. + pub fn markForUnload(self: *Image) void { + self.* = switch (self.*) { + .unload_pending, + .unload_replace, + .unload_ready, + => return, + + .ready => |t| .{ .unload_ready = t }, + .pending => |p| .{ .unload_pending = p }, + .replace => |r| .{ .unload_replace = r }, + }; + } + + /// Mark the current image to be replaced with a pending one. This will + /// attempt to update the existing texture if we have one, otherwise it + /// will act like a new upload. + pub fn markForReplace(self: *Image, alloc: Allocator, img: Image) !void { + assert(img.isPending()); + + // If we have pending data right now, free it. + if (self.getPending()) |p| { + alloc.free(p.dataSlice()); + } + // If we have an existing texture, use it in the replace. + if (self.getTexture()) |t| { + self.* = .{ .replace = .{ + .texture = t, + .pending = img.getPending().?, + } }; + return; + } + // Otherwise we just become a pending image. + self.* = .{ .pending = img.getPending().? }; + } + + /// Returns true if this image is pending upload. + pub fn isPending(self: Image) bool { + return self.getPending() != null; + } + + /// Returns true if this image has an associated texture. + pub fn hasTexture(self: Image) bool { + return self.getTexture() != null; + } + + /// Returns true if this image is marked for unload. + pub fn isUnloading(self: Image) bool { + return switch (self) { + .unload_pending, + .unload_replace, + .unload_ready, + => true, + + .pending, + .replace, + .ready, + => false, + }; + } + + /// Converts the image data to a format that can be uploaded to the GPU. + /// If the data is already in a format that can be uploaded, this is a + /// no-op. + pub fn convert(self: *Image, alloc: Allocator) wuffs.Error!void { + const p = self.getPendingPointer().?; + // As things stand, we currently convert all images to RGBA before + // uploading to the GPU. This just makes things easier. In the future + // we may want to support other formats. + if (p.pixel_format == .rgba) return; + // If the pending data isn't RGBA we'll need to swizzle it. + const data = p.dataSlice(); + const rgba = try switch (p.pixel_format) { + .gray => wuffs.swizzle.gToRgba(alloc, data), + .gray_alpha => wuffs.swizzle.gaToRgba(alloc, data), + .rgb => wuffs.swizzle.rgbToRgba(alloc, data), + .bgr => wuffs.swizzle.bgrToRgba(alloc, data), + .rgba => unreachable, + .bgra => wuffs.swizzle.bgraToRgba(alloc, data), + }; + alloc.free(data); + p.data = rgba.ptr; + p.pixel_format = .rgba; + } + + /// Prepare the pending image data for upload to the GPU. + /// This doesn't need GPU access so is safe to call any time. + pub fn prepForUpload(self: *Image, alloc: Allocator) !void { + assert(self.isPending()); + + try self.convert(alloc); + } + + /// Upload the pending image to the GPU and + /// change the state of this image to ready. + pub fn upload( + self: *Image, + alloc: Allocator, + api: *const GraphicsAPI, + ) !void { + assert(self.isPending()); + + try self.prepForUpload(alloc); + + // Get our pending info + const p = self.getPending().?; + + // Create our texture + const texture = try Texture.init( + api.imageTextureOptions(.rgba, true), + @intCast(p.width), + @intCast(p.height), + p.dataSlice(), + ); + + // Uploaded. We can now clear our data and change our state. + // + // NOTE: For the `replace` state, this will free the old texture. + // We don't currently actually replace the existing texture + // in-place but that is an optimization we can do later. + self.deinit(alloc); + self.* = .{ .ready = texture }; + } + + /// Returns any pending image data for this image that requires upload. + /// + /// If there is no pending data to upload, returns null. + fn getPending(self: Image) ?Pending { + return switch (self) { + .pending, + .unload_pending, + => |p| p, + + .replace, + .unload_replace, + => |r| r.pending, + + else => null, + }; + } + + /// Returns the texture for this image. + /// + /// If there is no texture for it yet, returns null. + fn getTexture(self: Image) ?Texture { + return switch (self) { + .ready, + .unload_ready, + => |t| t, + + .replace, + .unload_replace, + => |r| r.texture, + + else => null, + }; + } + + // Same as getPending but returns a pointer instead of a copy. + fn getPendingPointer(self: *Image) ?*Pending { + return switch (self.*) { + .pending => return &self.pending, + .unload_pending => return &self.unload_pending, + + .replace => return &self.replace.pending, + .unload_replace => return &self.unload_replace.pending, + + else => null, + }; + } +}; diff --git a/src/renderer/link.zig b/src/renderer/link.zig index 994190ec8..410fb8632 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -179,7 +179,7 @@ pub const Set = struct { if (current) |*sel| { sel.endPtr().* = cell_pin; } else { - current = terminal.Selection.init( + current = .init( cell_pin, cell_pin, false, diff --git a/src/renderer/metal/Frame.zig b/src/renderer/metal/Frame.zig new file mode 100644 index 000000000..81b38e7b6 --- /dev/null +++ b/src/renderer/metal/Frame.zig @@ -0,0 +1,137 @@ +//! Wrapper for handling render passes. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const builtin = @import("builtin"); +const objc = @import("objc"); + +const mtl = @import("api.zig"); +const Renderer = @import("../generic.zig").Renderer(Metal); +const Metal = @import("../Metal.zig"); +const Target = @import("Target.zig"); +const Pipeline = @import("Pipeline.zig"); +const RenderPass = @import("RenderPass.zig"); +const Buffer = @import("buffer.zig").Buffer; + +const Health = @import("../../renderer.zig").Health; + +const log = std.log.scoped(.metal); + +/// Options for beginning a frame. +pub const Options = struct { + /// MTLCommandQueue + queue: objc.Object, +}; + +/// MTLCommandBuffer +buffer: objc.Object, + +block: CompletionBlock, + +/// Begin encoding a frame. +pub fn begin( + opts: Options, + /// Once the frame has been completed, the `frameCompleted` method + /// on the renderer is called with the health status of the frame. + renderer: *Renderer, + /// The target is presented via the provided renderer's API when completed. + target: *Target, +) !Self { + const buffer = opts.queue.msgSend( + objc.Object, + objc.sel("commandBuffer"), + .{}, + ); + + // Create our block to register for completion updates. + // The block is deallocated by the objC runtime on success. + const block = try CompletionBlock.init( + .{ + .renderer = renderer, + .target = target, + .sync = false, + }, + &bufferCompleted, + ); + errdefer block.deinit(); + + return .{ .buffer = buffer, .block = block }; +} + +/// This is the block type used for the addCompletedHandler callback. +const CompletionBlock = objc.Block(struct { + renderer: *Renderer, + target: *Target, + sync: bool, +}, .{ + objc.c.id, // MTLCommandBuffer +}, void); + +fn bufferCompleted( + block: *const CompletionBlock.Context, + buffer_id: objc.c.id, +) callconv(.c) void { + const buffer = objc.Object.fromId(buffer_id); + + // Get our command buffer status to pass back to the generic renderer. + const status = buffer.getProperty(mtl.MTLCommandBufferStatus, "status"); + const health: Health = switch (status) { + .@"error" => .unhealthy, + else => .healthy, + }; + + // If the frame is healthy, present it. + if (health == .healthy) { + block.renderer.api.present( + block.target.*, + block.sync, + ) catch |err| { + log.err("Failed to present render target: err={}", .{err}); + }; + } + + block.renderer.frameCompleted(health); +} + +/// Add a render pass to this frame with the provided attachments. +/// Returns a RenderPass which allows render steps to be added. +pub inline fn renderPass( + self: *const Self, + attachments: []const RenderPass.Options.Attachment, +) RenderPass { + return RenderPass.begin(.{ + .attachments = attachments, + .command_buffer = self.buffer, + }); +} + +/// Complete this frame and present the target. +/// +/// If `sync` is true, this will block until the frame is presented. +pub inline fn complete(self: *Self, sync: bool) void { + // If we don't need to complete synchronously, + // we add our block as a completion handler. + // + // It will be deallocated by the objc runtime on success. + if (!sync) { + self.buffer.msgSend( + void, + objc.sel("addCompletedHandler:"), + .{self.block.context}, + ); + } + + self.buffer.msgSend(void, objc.sel("commit"), .{}); + + // If we need to complete synchronously, we wait until + // the buffer is completed and call the callback directly, + // deiniting the block after we're done. + if (sync) { + self.buffer.msgSend(void, "waitUntilCompleted", .{}); + self.block.context.sync = true; + bufferCompleted(self.block.context, self.buffer.value); + self.block.deinit(); + } +} diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig new file mode 100644 index 000000000..9212bd5e1 --- /dev/null +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -0,0 +1,190 @@ +//! A wrapper around a CALayer with a utility method +//! for settings its `contents` to an IOSurface. +const IOSurfaceLayer = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const builtin = @import("builtin"); +const objc = @import("objc"); +const macos = @import("macos"); + +const IOSurface = macos.iosurface.IOSurface; + +const log = std.log.scoped(.IOSurfaceLayer); + +/// We subclass CALayer with a custom display handler, we only need +/// to make the subclass once, and then we can use it as a singleton. +var Subclass: ?objc.Class = null; + +/// The underlying CALayer +layer: objc.Object, + +pub fn init() !IOSurfaceLayer { + // The layer returned by `[CALayer layer]` is autoreleased, which means + // that at the end of the current autorelease pool it will be deallocated + // if it isn't retained, so we retain it here manually an extra time. + const layer = (try getSubclass()).msgSend( + objc.Object, + objc.sel("layer"), + .{}, + ).retain(); + errdefer layer.release(); + + // The layer gravity is set to top-left so that the contents aren't + // stretched during resize operations before a new frame has been drawn. + layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft); + + layer.setInstanceVariable("display_cb", .{ .value = null }); + layer.setInstanceVariable("display_ctx", .{ .value = null }); + + return .{ .layer = layer }; +} + +pub fn release(self: *IOSurfaceLayer) void { + self.layer.release(); +} + +/// Sets the layer's `contents` to the provided IOSurface. +/// +/// Makes sure to do so on the main thread to avoid visual artifacts. +pub inline fn setSurface(self: *IOSurfaceLayer, surface: *IOSurface) !void { + // We retain the surface to make sure it's not GC'd + // before we can set it as the contents of the layer. + // + // We release in the callback after setting the contents. + surface.retain(); + // We also need to retain the layer itself to make sure it + // isn't destroyed before the callback completes, since if + // that happens it will try to interact with a deallocated + // object. + _ = self.layer.retain(); + + var block = try SetSurfaceBlock.init(.{ + .layer = self.layer.value, + .surface = surface, + }, &setSurfaceCallback); + + // We check if we're on the main thread and run the block directly if so. + const NSThread = objc.getClass("NSThread").?; + if (NSThread.msgSend(bool, "isMainThread", .{})) { + setSurfaceCallback(block.context); + block.deinit(); + } else { + // NOTE: The block will automatically be deallocated by the objc + // runtime once it's executed, so there's no need to deinit it. + + macos.dispatch.dispatch_async( + @ptrCast(macos.dispatch.queue.getMain()), + @ptrCast(block.context), + ); + } +} + +/// Sets the layer's `contents` to the provided IOSurface. +/// +/// Does not ensure this happens on the main thread. +pub inline fn setSurfaceSync(self: *IOSurfaceLayer, surface: *IOSurface) void { + self.layer.setProperty("contents", surface); +} + +const SetSurfaceBlock = objc.Block(struct { + layer: objc.c.id, + surface: *IOSurface, +}, .{}, void); + +fn setSurfaceCallback( + block: *const SetSurfaceBlock.Context, +) callconv(.c) void { + const layer = objc.Object.fromId(block.layer); + const surface: *IOSurface = block.surface; + + // See explanation of why we retain and release in `setSurface`. + defer { + surface.release(); + layer.release(); + } + + // We check to see if the surface is the appropriate size for + // the layer, if it's not then we discard it. This is because + // asynchronously drawn frames can sometimes finish just after + // a synchronously drawn frame during a resize, and if we don't + // discard the improperly sized surface it creates jank. + const bounds = layer.getProperty(macos.graphics.Rect, "bounds"); + const scale = layer.getProperty(f64, "contentsScale"); + const width: usize = @intFromFloat(bounds.size.width * scale); + const height: usize = @intFromFloat(bounds.size.height * scale); + if (width != surface.getWidth() or height != surface.getHeight()) { + log.debug( + "setSurfaceCallback(): surface is wrong size for layer, discarding. surface = {d}x{d}, layer = {d}x{d}", + .{ surface.getWidth(), surface.getHeight(), width, height }, + ); + return; + } + + layer.setProperty("contents", surface); +} + +pub const DisplayCallback = ?*align(8) const fn (?*anyopaque) void; + +pub fn setDisplayCallback( + self: *IOSurfaceLayer, + display_cb: DisplayCallback, + display_ctx: ?*anyopaque, +) void { + self.layer.setInstanceVariable( + "display_cb", + objc.Object.fromId(@constCast(display_cb)), + ); + self.layer.setInstanceVariable( + "display_ctx", + objc.Object.fromId(display_ctx), + ); +} + +fn getSubclass() error{ObjCFailed}!objc.Class { + if (Subclass) |c| return c; + + const CALayer = + objc.getClass("CALayer") orelse return error.ObjCFailed; + + var subclass = + objc.allocateClassPair(CALayer, "IOSurfaceLayer") orelse return error.ObjCFailed; + errdefer objc.disposeClassPair(subclass); + + if (!subclass.addIvar("display_cb")) return error.ObjCFailed; + if (!subclass.addIvar("display_ctx")) return error.ObjCFailed; + + subclass.replaceMethod("display", struct { + fn display(target: objc.c.id, sel: objc.c.SEL) callconv(.c) void { + _ = sel; + const self = objc.Object.fromId(target); + const display_cb: DisplayCallback = @ptrFromInt(@intFromPtr( + self.getInstanceVariable("display_cb").value, + )); + if (display_cb) |cb| cb( + @ptrCast(self.getInstanceVariable("display_ctx").value), + ); + } + }.display); + + // Disable all animations for this layer by returning null for all actions. + subclass.replaceMethod("actionForKey:", struct { + fn actionForKey( + target: objc.c.id, + sel: objc.c.SEL, + key: objc.c.id, + ) callconv(.c) objc.c.id { + _ = target; + _ = sel; + _ = key; + return objc.getClass("NSNull").?.msgSend(objc.c.id, "null", .{}); + } + }.actionForKey); + + objc.registerClassPair(subclass); + + Subclass = subclass; + + return subclass; +} diff --git a/src/renderer/metal/Pipeline.zig b/src/renderer/metal/Pipeline.zig new file mode 100644 index 000000000..0b8e99159 --- /dev/null +++ b/src/renderer/metal/Pipeline.zig @@ -0,0 +1,208 @@ +//! Wrapper for handling render pipelines. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const builtin = @import("builtin"); +const macos = @import("macos"); +const objc = @import("objc"); + +const mtl = @import("api.zig"); +const Texture = @import("Texture.zig"); +const Metal = @import("../Metal.zig"); + +const log = std.log.scoped(.metal); + +/// Options for initializing a render pipeline. +pub const Options = struct { + /// MTLDevice + device: objc.Object, + + /// Name of the vertex function + vertex_fn: []const u8, + /// Name of the fragment function + fragment_fn: []const u8, + + /// MTLLibrary to get the vertex function from + vertex_library: objc.Object, + /// MTLLibrary to get the fragment function from + fragment_library: objc.Object, + + /// Vertex step function + step_fn: mtl.MTLVertexStepFunction = .per_vertex, + + /// Info about the color attachments used by this render pipeline. + attachments: []const Attachment, + + /// Describes a color attachment. + pub const Attachment = struct { + pixel_format: mtl.MTLPixelFormat, + blending_enabled: bool = true, + }; +}; + +/// MTLRenderPipelineState +state: objc.Object, + +pub fn init(comptime VertexAttributes: ?type, opts: Options) !Self { + // Create our descriptor + const desc = init: { + const Class = objc.getClass("MTLRenderPipelineDescriptor").?; + const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); + const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); + break :init id_init; + }; + defer desc.msgSend(void, objc.sel("release"), .{}); + + // Get our vertex and fragment functions and add them to the descriptor. + { + const str = try macos.foundation.String.createWithBytes( + opts.vertex_fn, + .utf8, + false, + ); + defer str.release(); + + const ptr = opts.vertex_library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); + const func_vert = objc.Object.fromId(ptr.?); + defer func_vert.msgSend(void, objc.sel("release"), .{}); + + desc.setProperty("vertexFunction", func_vert); + } + { + const str = try macos.foundation.String.createWithBytes( + opts.fragment_fn, + .utf8, + false, + ); + defer str.release(); + + const ptr = opts.fragment_library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); + const func_frag = objc.Object.fromId(ptr.?); + defer func_frag.msgSend(void, objc.sel("release"), .{}); + + desc.setProperty("fragmentFunction", func_frag); + } + + // If we have vertex attributes, create and add a vertex descriptor. + if (VertexAttributes) |V| { + const vertex_desc = init: { + const Class = objc.getClass("MTLVertexDescriptor").?; + const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); + const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); + break :init id_init; + }; + defer vertex_desc.msgSend(void, objc.sel("release"), .{}); + + // Our attributes are the fields of the input + const attrs = objc.Object.fromId(vertex_desc.getProperty(?*anyopaque, "attributes")); + autoAttribute(V, attrs); + + // The layout describes how and when we fetch the next vertex input. + const layouts = objc.Object.fromId(vertex_desc.getProperty(?*anyopaque, "layouts")); + { + const layout = layouts.msgSend( + objc.Object, + objc.sel("objectAtIndexedSubscript:"), + .{@as(c_ulong, 0)}, + ); + + layout.setProperty("stepFunction", @intFromEnum(opts.step_fn)); + layout.setProperty("stride", @as(c_ulong, @sizeOf(V))); + } + + desc.setProperty("vertexDescriptor", vertex_desc); + } + + // Set our color attachment + const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments")); + for (opts.attachments, 0..) |at, i| { + const attachment = attachments.msgSend( + objc.Object, + objc.sel("objectAtIndexedSubscript:"), + .{@as(c_ulong, i)}, + ); + + attachment.setProperty("pixelFormat", @intFromEnum(at.pixel_format)); + + attachment.setProperty("blendingEnabled", at.blending_enabled); + // We always use premultiplied alpha blending for now. + if (at.blending_enabled) { + attachment.setProperty("rgbBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add)); + attachment.setProperty("alphaBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add)); + attachment.setProperty("sourceRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one)); + attachment.setProperty("sourceAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one)); + attachment.setProperty("destinationRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha)); + attachment.setProperty("destinationAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha)); + } + } + + // Make our state + var err: ?*anyopaque = null; + const pipeline_state = opts.device.msgSend( + objc.Object, + objc.sel("newRenderPipelineStateWithDescriptor:error:"), + .{ desc, &err }, + ); + try checkError(err); + errdefer pipeline_state.release(); + + return .{ .state = pipeline_state }; +} + +pub fn deinit(self: *const Self) void { + self.state.release(); +} + +fn autoAttribute(T: type, attrs: objc.Object) void { + inline for (@typeInfo(T).@"struct".fields, 0..) |field, i| { + const offset = @offsetOf(T, field.name); + + const FT = switch (@typeInfo(field.type)) { + .@"struct" => |e| e.backing_integer.?, + .@"enum" => |e| e.tag_type, + else => field.type, + }; + + // Very incomplete list, expand as necessary. + const format = switch (FT) { + [4]u8 => mtl.MTLVertexFormat.uchar4, + [2]u16 => mtl.MTLVertexFormat.ushort2, + [2]i16 => mtl.MTLVertexFormat.short2, + f32 => mtl.MTLVertexFormat.float, + [2]f32 => mtl.MTLVertexFormat.float2, + [4]f32 => mtl.MTLVertexFormat.float4, + i32 => mtl.MTLVertexFormat.int, + [2]i32 => mtl.MTLVertexFormat.int2, + [4]i32 => mtl.MTLVertexFormat.int2, + u32 => mtl.MTLVertexFormat.uint, + [2]u32 => mtl.MTLVertexFormat.uint2, + [4]u32 => mtl.MTLVertexFormat.uint4, + u8 => mtl.MTLVertexFormat.uchar, + i8 => mtl.MTLVertexFormat.char, + else => comptime unreachable, + }; + + const attr = attrs.msgSend( + objc.Object, + objc.sel("objectAtIndexedSubscript:"), + .{@as(c_ulong, i)}, + ); + + attr.setProperty("format", @intFromEnum(format)); + attr.setProperty("offset", @as(c_ulong, offset)); + attr.setProperty("bufferIndex", @as(c_ulong, 0)); + } +} + +fn checkError(err_: ?*anyopaque) !void { + const nserr = objc.Object.fromId(err_ orelse return); + const str = @as( + *macos.foundation.String, + @ptrCast(nserr.getProperty(?*anyopaque, "localizedDescription").?), + ); + + log.err("metal error={s}", .{str.cstringPtr(.ascii).?}); + return error.MetalFailed; +} diff --git a/src/renderer/metal/RenderPass.zig b/src/renderer/metal/RenderPass.zig new file mode 100644 index 000000000..e48bc4c00 --- /dev/null +++ b/src/renderer/metal/RenderPass.zig @@ -0,0 +1,220 @@ +//! Wrapper for handling render passes. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const builtin = @import("builtin"); +const objc = @import("objc"); + +const mtl = @import("api.zig"); +const Pipeline = @import("Pipeline.zig"); +const Texture = @import("Texture.zig"); +const Target = @import("Target.zig"); +const Metal = @import("../Metal.zig"); +const Buffer = @import("buffer.zig").Buffer; + +const log = std.log.scoped(.metal); + +/// Options for beginning a render pass. +pub const Options = struct { + /// MTLCommandBuffer + command_buffer: objc.Object, + /// Color attachments for this render pass. + attachments: []const Attachment, + + /// Describes a color attachment. + pub const Attachment = struct { + target: union(enum) { + texture: Texture, + target: Target, + }, + clear_color: ?[4]f64 = null, + }; +}; + +/// Describes a step in a render pass. +pub const Step = struct { + pipeline: Pipeline, + /// MTLBuffer + uniforms: ?objc.Object = null, + /// MTLBuffer + buffers: []const ?objc.Object = &.{}, + textures: []const ?Texture = &.{}, + draw: Draw, + + /// Describes the draw call for this step. + pub const Draw = struct { + type: mtl.MTLPrimitiveType, + vertex_count: usize, + instance_count: usize = 1, + }; +}; + +/// MTLRenderCommandEncoder +encoder: objc.Object, + +/// Begin a render pass. +pub fn begin( + opts: Options, +) Self { + // Create a pass descriptor + const desc = desc: { + const MTLRenderPassDescriptor = objc.getClass("MTLRenderPassDescriptor").?; + const desc = MTLRenderPassDescriptor.msgSend( + objc.Object, + objc.sel("renderPassDescriptor"), + .{}, + ); + + // Set our color attachment to be our drawable surface. + const attachments = objc.Object.fromId( + desc.getProperty(?*anyopaque, "colorAttachments"), + ); + for (opts.attachments, 0..) |at, i| { + const attachment = attachments.msgSend( + objc.Object, + objc.sel("objectAtIndexedSubscript:"), + .{@as(c_ulong, i)}, + ); + + attachment.setProperty( + "loadAction", + @intFromEnum(@as( + mtl.MTLLoadAction, + if (at.clear_color != null) + .clear + else + .load, + )), + ); + attachment.setProperty( + "storeAction", + @intFromEnum(mtl.MTLStoreAction.store), + ); + attachment.setProperty("texture", switch (at.target) { + .texture => |t| t.texture.value, + .target => |t| t.texture.value, + }); + if (at.clear_color) |c| attachment.setProperty( + "clearColor", + mtl.MTLClearColor{ + .red = c[0], + .green = c[1], + .blue = c[2], + .alpha = c[3], + }, + ); + } + + break :desc desc; + }; + + // MTLRenderCommandEncoder + const encoder = opts.command_buffer.msgSend( + objc.Object, + objc.sel("renderCommandEncoderWithDescriptor:"), + .{desc.value}, + ); + + return .{ .encoder = encoder }; +} + +/// Add a step to this render pass. +pub fn step(self: *const Self, s: Step) void { + if (s.draw.instance_count == 0) return; + + // Set pipeline state + self.encoder.msgSend( + void, + objc.sel("setRenderPipelineState:"), + .{s.pipeline.state.value}, + ); + + if (s.buffers.len > 0) { + // We reserve index 0 for the vertex buffer, this isn't very + // flexible but it lines up with the API we have for OpenGL. + if (s.buffers[0]) |buf| { + self.encoder.msgSend( + void, + objc.sel("setVertexBuffer:offset:atIndex:"), + .{ buf.value, @as(c_ulong, 0), @as(c_ulong, 0) }, + ); + self.encoder.msgSend( + void, + objc.sel("setFragmentBuffer:offset:atIndex:"), + .{ buf.value, @as(c_ulong, 0), @as(c_ulong, 0) }, + ); + } + + // Set the rest of the buffers starting at index 2, this is + // so that we can use index 1 for the uniforms if present. + // + // Also, we set buffers (and textures) for both stages. + // + // Again, not very flexible, but it's consistent and predictable, + // and we need to treat the uniforms as special because of OpenGL. + // + // TODO: Maybe in the future add info to the pipeline struct which + // allows it to define a mapping between provided buffers and + // what index they get set at for the vertex / fragment stage. + for (s.buffers[1..], 2..) |b, i| if (b) |buf| { + self.encoder.msgSend( + void, + objc.sel("setVertexBuffer:offset:atIndex:"), + .{ buf.value, @as(c_ulong, 0), @as(c_ulong, i) }, + ); + self.encoder.msgSend( + void, + objc.sel("setFragmentBuffer:offset:atIndex:"), + .{ buf.value, @as(c_ulong, 0), @as(c_ulong, i) }, + ); + }; + } + + // Set the uniforms as buffer index 1 if present. + if (s.uniforms) |buf| { + self.encoder.msgSend( + void, + objc.sel("setVertexBuffer:offset:atIndex:"), + .{ buf.value, @as(c_ulong, 0), @as(c_ulong, 1) }, + ); + self.encoder.msgSend( + void, + objc.sel("setFragmentBuffer:offset:atIndex:"), + .{ buf.value, @as(c_ulong, 0), @as(c_ulong, 1) }, + ); + } + + // Set textures. + for (s.textures, 0..) |t, i| if (t) |tex| { + self.encoder.msgSend( + void, + objc.sel("setVertexTexture:atIndex:"), + .{ tex.texture.value, @as(c_ulong, i) }, + ); + self.encoder.msgSend( + void, + objc.sel("setFragmentTexture:atIndex:"), + .{ tex.texture.value, @as(c_ulong, i) }, + ); + }; + + // Draw! + self.encoder.msgSend( + void, + objc.sel("drawPrimitives:vertexStart:vertexCount:instanceCount:"), + .{ + @intFromEnum(s.draw.type), + @as(c_ulong, 0), + @as(c_ulong, s.draw.vertex_count), + @as(c_ulong, s.draw.instance_count), + }, + ); +} + +/// Complete this render pass. +/// This struct can no longer be used after calling this. +pub fn complete(self: *const Self) void { + self.encoder.msgSend(void, objc.sel("endEncoding"), .{}); +} diff --git a/src/renderer/metal/Target.zig b/src/renderer/metal/Target.zig new file mode 100644 index 000000000..fa62d3014 --- /dev/null +++ b/src/renderer/metal/Target.zig @@ -0,0 +1,110 @@ +//! Represents a render target. +//! +//! In this case, an IOSurface-backed MTLTexture. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const builtin = @import("builtin"); +const objc = @import("objc"); +const macos = @import("macos"); +const graphics = macos.graphics; +const IOSurface = macos.iosurface.IOSurface; + +const mtl = @import("api.zig"); + +const log = std.log.scoped(.metal); + +/// Options for initializing a Target +pub const Options = struct { + /// MTLDevice + device: objc.Object, + + /// Desired width + width: usize, + /// Desired height + height: usize, + + /// Pixel format for the MTLTexture + pixel_format: mtl.MTLPixelFormat, + /// Storage mode for the MTLTexture + storage_mode: mtl.MTLResourceOptions.StorageMode, +}; + +/// The underlying IOSurface. +surface: *IOSurface, + +/// The underlying MTLTexture. +texture: objc.Object, + +/// Current width of this target. +width: usize, +/// Current height of this target. +height: usize, + +pub fn init(opts: Options) !Self { + // We set our surface's color space to Display P3. + // This allows us to have "Apple-style" alpha blending, + // since it seems to be the case that Apple apps like + // Terminal and TextEdit render text in the display's + // color space using converted colors, which reduces, + // but does not fully eliminate blending artifacts. + const colorspace = try graphics.ColorSpace.createNamed(.displayP3); + defer colorspace.release(); + + const surface = try IOSurface.init(.{ + .width = @intCast(opts.width), + .height = @intCast(opts.height), + .pixel_format = .@"32BGRA", + .bytes_per_element = 4, + .colorspace = colorspace, + }); + + // Create our descriptor + const desc = init: { + const Class = objc.getClass("MTLTextureDescriptor").?; + const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); + const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); + break :init id_init; + }; + errdefer desc.msgSend(void, objc.sel("release"), .{}); + + // Set our properties + desc.setProperty("width", @as(c_ulong, @intCast(opts.width))); + desc.setProperty("height", @as(c_ulong, @intCast(opts.height))); + desc.setProperty("pixelFormat", @intFromEnum(opts.pixel_format)); + desc.setProperty("usage", mtl.MTLTextureUsage{ .render_target = true }); + desc.setProperty( + "resourceOptions", + mtl.MTLResourceOptions{ + // Indicate that the CPU writes to this resource but never reads it. + .cpu_cache_mode = .write_combined, + .storage_mode = opts.storage_mode, + }, + ); + + const id = opts.device.msgSend( + ?*anyopaque, + objc.sel("newTextureWithDescriptor:iosurface:plane:"), + .{ + desc, + surface, + @as(c_ulong, 0), + }, + ) orelse return error.MetalFailed; + + const texture = objc.Object.fromId(id); + + return .{ + .surface = surface, + .texture = texture, + .width = opts.width, + .height = opts.height, + }; +} + +pub fn deinit(self: *Self) void { + self.surface.deinit(); + self.texture.release(); +} diff --git a/src/renderer/metal/Texture.zig b/src/renderer/metal/Texture.zig new file mode 100644 index 000000000..32820f8fc --- /dev/null +++ b/src/renderer/metal/Texture.zig @@ -0,0 +1,201 @@ +//! Wrapper for handling textures. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const builtin = @import("builtin"); +const objc = @import("objc"); + +const mtl = @import("api.zig"); +const Metal = @import("../Metal.zig"); + +const log = std.log.scoped(.metal); + +/// Options for initializing a texture. +pub const Options = struct { + /// MTLDevice + device: objc.Object, + pixel_format: mtl.MTLPixelFormat, + resource_options: mtl.MTLResourceOptions, +}; + +/// The underlying MTLTexture Object. +texture: objc.Object, + +/// The width of this texture. +width: usize, +/// The height of this texture. +height: usize, + +/// Bytes per pixel for this texture. +bpp: usize, + +pub const Error = error{ + /// A Metal API call failed. + MetalFailed, +}; + +/// Initialize a texture +pub fn init( + opts: Options, + width: usize, + height: usize, + data: ?[]const u8, +) Error!Self { + // Create our descriptor + const desc = init: { + const Class = objc.getClass("MTLTextureDescriptor").?; + const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); + const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); + break :init id_init; + }; + errdefer desc.msgSend(void, objc.sel("release"), .{}); + + // Set our properties + desc.setProperty("pixelFormat", @intFromEnum(opts.pixel_format)); + desc.setProperty("width", @as(c_ulong, width)); + desc.setProperty("height", @as(c_ulong, height)); + desc.setProperty("resourceOptions", opts.resource_options); + + // Initialize + const id = opts.device.msgSend( + ?*anyopaque, + objc.sel("newTextureWithDescriptor:"), + .{desc}, + ) orelse return error.MetalFailed; + + const self: Self = .{ + .texture = objc.Object.fromId(id), + .width = width, + .height = height, + .bpp = bppOf(opts.pixel_format), + }; + + // If we have data, we set it here. + if (data) |d| { + assert(d.len == width * height * self.bpp); + try self.replaceRegion(0, 0, width, height, d); + } + + return self; +} + +pub fn deinit(self: Self) void { + self.texture.release(); +} + +/// Replace a region of the texture with the provided data. +/// +/// Does NOT check the dimensions of the data to ensure correctness. +pub fn replaceRegion( + self: Self, + x: usize, + y: usize, + width: usize, + height: usize, + data: []const u8, +) error{}!void { + self.texture.msgSend( + void, + objc.sel("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"), + .{ + mtl.MTLRegion{ + .origin = .{ .x = x, .y = y, .z = 0 }, + .size = .{ + .width = @intCast(width), + .height = @intCast(height), + .depth = 1, + }, + }, + @as(c_ulong, 0), + @as(*const anyopaque, data.ptr), + @as(c_ulong, self.bpp * width), + }, + ); +} + +/// Returns the bytes per pixel for the provided pixel format +fn bppOf(pixel_format: mtl.MTLPixelFormat) usize { + return switch (pixel_format) { + // Invalid + .invalid => @panic("invalid pixel format"), + + // Weird formats I was too lazy to get the sizes of + else => @panic("pixel format size unknown (unlikely that this format was actually used, could be memory corruption)"), + + // 8-bit pixel formats + .a8unorm, + .r8unorm, + .r8unorm_srgb, + .r8snorm, + .r8uint, + .r8sint, + .rg8unorm, + .rg8unorm_srgb, + .rg8snorm, + .rg8uint, + .rg8sint, + .stencil8, + => 1, + + // 16-bit pixel formats + .r16unorm, + .r16snorm, + .r16uint, + .r16sint, + .r16float, + .rg16unorm, + .rg16snorm, + .rg16uint, + .rg16sint, + .rg16float, + .b5g6r5unorm, + .a1bgr5unorm, + .abgr4unorm, + .bgr5a1unorm, + .depth16unorm, + => 2, + + // 32-bit pixel formats + .rgba8unorm, + .rgba8unorm_srgb, + .rgba8snorm, + .rgba8uint, + .rgba8sint, + .bgra8unorm, + .bgra8unorm_srgb, + .rgb10a2unorm, + .rgb10a2uint, + .rg11b10float, + .rgb9e5float, + .bgr10a2unorm, + .bgr10_xr, + .bgr10_xr_srgb, + .r32uint, + .r32sint, + .r32float, + .depth32float, + .depth24unorm_stencil8, + => 4, + + // 64-bit pixel formats + .rg32uint, + .rg32sint, + .rg32float, + .rgba16unorm, + .rgba16snorm, + .rgba16uint, + .rgba16sint, + .rgba16float, + .bgra10_xr, + .bgra10_xr_srgb, + => 8, + + // 128-bit pixel formats, + .rgba32uint, + .rgba32sint, + .rgba32float, + => 128, + }; +} diff --git a/src/renderer/metal/api.zig b/src/renderer/metal/api.zig index 46cb4f6bc..e1daa6848 100644 --- a/src/renderer/metal/api.zig +++ b/src/renderer/metal/api.zig @@ -1,4 +1,10 @@ //! This file contains the definitions of the Metal API that we use. +//! +//! Because the online Apple developer docs have recently (as of January 2025) +//! been changed to hide enum values, `Metal-cpp` has been used as a reference +//! source instead. +//! +//! Ref: https://developer.apple.com/metal/cpp/ /// https://developer.apple.com/documentation/metal/mtlcommandbufferstatus?language=objc pub const MTLCommandBufferStatus = enum(c_ulong) { @@ -22,6 +28,10 @@ pub const MTLLoadAction = enum(c_ulong) { pub const MTLStoreAction = enum(c_ulong) { dont_care = 0, store = 1, + multisample_resolve = 2, + store_and_multisample_resolve = 3, + unknown = 4, + custom_sample_depth_store = 5, }; /// https://developer.apple.com/documentation/metal/mtlresourceoptions?language=objc @@ -73,16 +83,60 @@ pub const MTLIndexType = enum(c_ulong) { /// https://developer.apple.com/documentation/metal/mtlvertexformat?language=objc pub const MTLVertexFormat = enum(c_ulong) { + invalid = 0, + uchar2 = 1, + uchar3 = 2, uchar4 = 3, + char2 = 4, + char3 = 5, + char4 = 6, + uchar2normalized = 7, + uchar3normalized = 8, + uchar4normalized = 9, + char2normalized = 10, + char3normalized = 11, + char4normalized = 12, ushort2 = 13, + ushort3 = 14, + ushort4 = 15, short2 = 16, + short3 = 17, + short4 = 18, + ushort2normalized = 19, + ushort3normalized = 20, + ushort4normalized = 21, + short2normalized = 22, + short3normalized = 23, + short4normalized = 24, + half2 = 25, + half3 = 26, + half4 = 27, + float = 28, float2 = 29, + float3 = 30, float4 = 31, + int = 32, int2 = 33, + int3 = 34, + int4 = 35, uint = 36, uint2 = 37, + uint3 = 38, uint4 = 39, + int1010102normalized = 40, + uint1010102normalized = 41, + uchar4normalized_bgra = 42, uchar = 45, + char = 46, + ucharnormalized = 47, + charnormalized = 48, + ushort = 49, + short = 50, + ushortnormalized = 51, + shortnormalized = 52, + half = 53, + floatrg11b10 = 54, + floatrgb9e5 = 55, }; /// https://developer.apple.com/documentation/metal/mtlvertexstepfunction?language=objc @@ -90,20 +144,158 @@ pub const MTLVertexStepFunction = enum(c_ulong) { constant = 0, per_vertex = 1, per_instance = 2, + per_patch = 3, + per_patch_control_point = 4, }; /// https://developer.apple.com/documentation/metal/mtlpixelformat?language=objc pub const MTLPixelFormat = enum(c_ulong) { + invalid = 0, + a8unorm = 1, r8unorm = 10, + r8unorm_srgb = 11, + r8snorm = 12, + r8uint = 13, + r8sint = 14, + r16unorm = 20, + r16snorm = 22, + r16uint = 23, + r16sint = 24, + r16float = 25, + rg8unorm = 30, + rg8unorm_srgb = 31, + rg8snorm = 32, + rg8uint = 33, + rg8sint = 34, + b5g6r5unorm = 40, + a1bgr5unorm = 41, + abgr4unorm = 42, + bgr5a1unorm = 43, + r32uint = 53, + r32sint = 54, + r32float = 55, + rg16unorm = 60, + rg16snorm = 62, + rg16uint = 63, + rg16sint = 64, + rg16float = 65, rgba8unorm = 70, rgba8unorm_srgb = 71, + rgba8snorm = 72, rgba8uint = 73, + rgba8sint = 74, bgra8unorm = 80, bgra8unorm_srgb = 81, + rgb10a2unorm = 90, + rgb10a2uint = 91, + rg11b10float = 92, + rgb9e5float = 93, + bgr10a2unorm = 94, + bgr10_xr = 554, + bgr10_xr_srgb = 555, + rg32uint = 103, + rg32sint = 104, + rg32float = 105, + rgba16unorm = 110, + rgba16snorm = 112, + rgba16uint = 113, + rgba16sint = 114, + rgba16float = 115, + bgra10_xr = 552, + bgra10_xr_srgb = 553, + rgba32uint = 123, + rgba32sint = 124, + rgba32float = 125, + bc1_rgba = 130, + bc1_rgba_srgb = 131, + bc2_rgba = 132, + bc2_rgba_srgb = 133, + bc3_rgba = 134, + bc3_rgba_srgb = 135, + bc4_runorm = 140, + bc4_rsnorm = 141, + bc5_rgunorm = 142, + bc5_rgsnorm = 143, + bc6h_rgbfloat = 150, + bc6h_rgbufloat = 151, + bc7_rgbaunorm = 152, + bc7_rgbaunorm_srgb = 153, + pvrtc_rgb_2bpp = 160, + pvrtc_rgb_2bpp_srgb = 161, + pvrtc_rgb_4bpp = 162, + pvrtc_rgb_4bpp_srgb = 163, + pvrtc_rgba_2bpp = 164, + pvrtc_rgba_2bpp_srgb = 165, + pvrtc_rgba_4bpp = 166, + pvrtc_rgba_4bpp_srgb = 167, + eac_r11unorm = 170, + eac_r11snorm = 172, + eac_rg11unorm = 174, + eac_rg11snorm = 176, + eac_rgba8 = 178, + eac_rgba8_srgb = 179, + etc2_rgb8 = 180, + etc2_rgb8_srgb = 181, + etc2_rgb8a1 = 182, + etc2_rgb8a1_srgb = 183, + astc_4x4_srgb = 186, + astc_5x4_srgb = 187, + astc_5x5_srgb = 188, + astc_6x5_srgb = 189, + astc_6x6_srgb = 190, + astc_8x5_srgb = 192, + astc_8x6_srgb = 193, + astc_8x8_srgb = 194, + astc_10x5_srgb = 195, + astc_10x6_srgb = 196, + astc_10x8_srgb = 197, + astc_10x10_srgb = 198, + astc_12x10_srgb = 199, + astc_12x12_srgb = 200, + astc_4x4_ldr = 204, + astc_5x4_ldr = 205, + astc_5x5_ldr = 206, + astc_6x5_ldr = 207, + astc_6x6_ldr = 208, + astc_8x5_ldr = 210, + astc_8x6_ldr = 211, + astc_8x8_ldr = 212, + astc_10x5_ldr = 213, + astc_10x6_ldr = 214, + astc_10x8_ldr = 215, + astc_10x10_ldr = 216, + astc_12x10_ldr = 217, + astc_12x12_ldr = 218, + astc_4x4_hdr = 222, + astc_5x4_hdr = 223, + astc_5x5_hdr = 224, + astc_6x5_hdr = 225, + astc_6x6_hdr = 226, + astc_8x5_hdr = 228, + astc_8x6_hdr = 229, + astc_8x8_hdr = 230, + astc_10x5_hdr = 231, + astc_10x6_hdr = 232, + astc_10x8_hdr = 233, + astc_10x10_hdr = 234, + astc_12x10_hdr = 235, + astc_12x12_hdr = 236, + gbgr422 = 240, + bgrg422 = 241, + depth16unorm = 250, + depth32float = 252, + stencil8 = 253, + depth24unorm_stencil8 = 255, + depth32float_stencil8 = 260, + x32_stencil8 = 261, + x24_stencil8 = 262, }; /// https://developer.apple.com/documentation/metal/mtlpurgeablestate?language=objc pub const MTLPurgeableState = enum(c_ulong) { + keep_current = 1, + non_volatile = 2, + @"volatile" = 3, empty = 4, }; @@ -155,13 +347,48 @@ pub const MTLBlendOperation = enum(c_ulong) { max = 4, }; -/// https://developer.apple.com/documentation/metal/mtltextureusage?language=objc -pub const MTLTextureUsage = enum(c_ulong) { - unknown = 0, - shader_read = 1, - shader_write = 2, - render_target = 4, - pixel_format_view = 8, +/// https://developer.apple.com/documentation/metal/mtltextureusage?language=objc +pub const MTLTextureUsage = packed struct(c_ulong) { + /// https://developer.apple.com/documentation/metal/mtltextureusage/shaderread?language=objc + shader_read: bool = false, // TextureUsageShaderRead = 1, + + /// https://developer.apple.com/documentation/metal/mtltextureusage/shaderwrite?language=objc + shader_write: bool = false, // TextureUsageShaderWrite = 2, + + /// https://developer.apple.com/documentation/metal/mtltextureusage/rendertarget?language=objc + render_target: bool = false, // TextureUsageRenderTarget = 4, + + _reserved: u1 = 0, // The enum skips from 4 to 16, 8 has no documented use. + + /// https://developer.apple.com/documentation/metal/mtltextureusage/pixelformatview?language=objc + pixel_format_view: bool = false, // TextureUsagePixelFormatView = 16, + + /// https://developer.apple.com/documentation/metal/mtltextureusage/shaderatomic?language=objc + shader_atomic: bool = false, // TextureUsageShaderAtomic = 32, + + __reserved: @Type(.{ .int = .{ + .signedness = .unsigned, + .bits = @bitSizeOf(c_ulong) - 6, + } }) = 0, + + /// https://developer.apple.com/documentation/metal/mtltextureusage/unknown?language=objc + const unknown: MTLTextureUsage = @bitCast(0); // TextureUsageUnknown = 0, +}; + +/// https://developer.apple.com/documentation/metal/mtlbarrierscope?language=objc +pub const MTLBarrierScope = enum(c_ulong) { + buffers = 1, + textures = 2, + render_targets = 4, +}; + +/// https://developer.apple.com/documentation/metal/mtlrenderstages?language=objc +pub const MTLRenderStage = enum(c_ulong) { + vertex = 1, + fragment = 2, + tile = 4, + object = 8, + mesh = 16, }; pub const MTLClearColor = extern struct { diff --git a/src/renderer/metal/buffer.zig b/src/renderer/metal/buffer.zig index 4128e297b..43320a60b 100644 --- a/src/renderer/metal/buffer.zig +++ b/src/renderer/metal/buffer.zig @@ -5,9 +5,17 @@ const objc = @import("objc"); const macos = @import("macos"); const mtl = @import("api.zig"); +const Metal = @import("../Metal.zig"); const log = std.log.scoped(.metal); +/// Options for initializing a buffer. +pub const Options = struct { + /// MTLDevice + device: objc.Object, + resource_options: mtl.MTLResourceOptions, +}; + /// Metal data storage for a certain set of equal types. This is usually /// used for vertex buffers, etc. This helpful wrapper makes it easy to /// prealloc, shrink, grow, sync, buffers with Metal. @@ -15,74 +23,57 @@ pub fn Buffer(comptime T: type) type { return struct { const Self = @This(); - /// The resource options for this buffer. - options: mtl.MTLResourceOptions, + /// The options this buffer was initialized with. + opts: Options, - buffer: objc.Object, // MTLBuffer + /// The underlying MTLBuffer object. + buffer: objc.Object, + + /// The allocated length of the buffer. + /// Note that this is the number + /// of `T`s not the size in bytes. + len: usize, /// Initialize a buffer with the given length pre-allocated. - pub fn init( - device: objc.Object, - len: usize, - options: mtl.MTLResourceOptions, - ) !Self { - const buffer = device.msgSend( + pub fn init(opts: Options, len: usize) !Self { + const buffer = opts.device.msgSend( objc.Object, objc.sel("newBufferWithLength:options:"), .{ @as(c_ulong, @intCast(len * @sizeOf(T))), - options, + opts.resource_options, }, ); - return .{ .buffer = buffer, .options = options }; + return .{ .buffer = buffer, .opts = opts, .len = len }; } /// Init the buffer filled with the given data. - pub fn initFill( - device: objc.Object, - data: []const T, - options: mtl.MTLResourceOptions, - ) !Self { - const buffer = device.msgSend( + pub fn initFill(opts: Options, data: []const T) !Self { + const buffer = opts.device.msgSend( objc.Object, objc.sel("newBufferWithBytes:length:options:"), .{ @as(*const anyopaque, @ptrCast(data.ptr)), @as(c_ulong, @intCast(data.len * @sizeOf(T))), - options, + opts.resource_options, }, ); - return .{ .buffer = buffer, .options = options }; + return .{ .buffer = buffer, .opts = opts, .len = data.len }; } - pub fn deinit(self: *Self) void { + pub fn deinit(self: *const Self) void { self.buffer.msgSend(void, objc.sel("release"), .{}); } - /// Get the buffer contents as a slice of T. The contents are - /// mutable. The contents may or may not be automatically synced - /// depending on the buffer storage mode. See the Metal docs. - pub fn contents(self: *Self) ![]T { - const len_bytes = self.buffer.getProperty(c_ulong, "length"); - assert(@mod(len_bytes, @sizeOf(T)) == 0); - const len = @divExact(len_bytes, @sizeOf(T)); - const ptr = self.buffer.msgSend( - ?[*]T, - objc.sel("contents"), - .{}, - ).?; - return ptr[0..len]; - } - /// Sync new contents to the buffer. The data is expected to be the /// complete contents of the buffer. If the amount of data is larger /// than the buffer length, the buffer will be reallocated. /// /// If the amount of data is smaller than the buffer length, the /// remaining data in the buffer is left untouched. - pub fn sync(self: *Self, device: objc.Object, data: []const T) !void { + pub fn sync(self: *Self, data: []const T) !void { // If we need more bytes than our buffer has, we need to reallocate. const req_bytes = data.len * @sizeOf(T); const avail_bytes = self.buffer.getProperty(c_ulong, "length"); @@ -92,12 +83,12 @@ pub fn Buffer(comptime T: type) type { // Allocate a new buffer with enough to hold double what we require. const size = req_bytes * 2; - self.buffer = device.msgSend( + self.buffer = self.opts.device.msgSend( objc.Object, objc.sel("newBufferWithLength:options:"), .{ @as(c_ulong, @intCast(size * @sizeOf(T))), - self.options, + self.opts.resource_options, }, ); } @@ -123,7 +114,7 @@ pub fn Buffer(comptime T: type) type { // we need to signal Metal to synchronize the buffer data. // // Ref: https://developer.apple.com/documentation/metal/synchronizing-a-managed-resource-in-macos?language=objc - if (self.options.storage_mode == .managed) { + if (self.opts.resource_options.storage_mode == .managed) { self.buffer.msgSend( void, "didModifyRange:", @@ -134,7 +125,7 @@ pub fn Buffer(comptime T: type) type { /// Like Buffer.sync but takes data from an array of ArrayLists, /// rather than a single array. Returns the number of items synced. - pub fn syncFromArrayLists(self: *Self, device: objc.Object, lists: []std.ArrayListUnmanaged(T)) !usize { + pub fn syncFromArrayLists(self: *Self, lists: []const std.ArrayListUnmanaged(T)) !usize { var total_len: usize = 0; for (lists) |list| { total_len += list.items.len; @@ -149,12 +140,12 @@ pub fn Buffer(comptime T: type) type { // Allocate a new buffer with enough to hold double what we require. const size = req_bytes * 2; - self.buffer = device.msgSend( + self.buffer = self.opts.device.msgSend( objc.Object, objc.sel("newBufferWithLength:options:"), .{ @as(c_ulong, @intCast(size * @sizeOf(T))), - self.options, + self.opts.resource_options, }, ); } @@ -181,7 +172,7 @@ pub fn Buffer(comptime T: type) type { // we need to signal Metal to synchronize the buffer data. // // Ref: https://developer.apple.com/documentation/metal/synchronizing-a-managed-resource-in-macos?language=objc - if (self.options.storage_mode == .managed) { + if (self.opts.resource_options.storage_mode == .managed) { self.buffer.msgSend( void, "didModifyRange:", diff --git a/src/renderer/metal/cell.zig b/src/renderer/metal/cell.zig deleted file mode 100644 index 61b8887fd..000000000 --- a/src/renderer/metal/cell.zig +++ /dev/null @@ -1,358 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; - -const renderer = @import("../../renderer.zig"); -const terminal = @import("../../terminal/main.zig"); -const mtl_shaders = @import("shaders.zig"); - -/// The possible cell content keys that exist. -pub const Key = enum { - bg, - text, - underline, - strikethrough, - overline, - - /// Returns the GPU vertex type for this key. - pub fn CellType(self: Key) type { - return switch (self) { - .bg => mtl_shaders.CellBg, - - .text, - .underline, - .strikethrough, - .overline, - => mtl_shaders.CellText, - }; - } -}; - -/// A pool of ArrayLists with methods for bulk operations. -fn ArrayListPool(comptime T: type) type { - return struct { - const Self = ArrayListPool(T); - const ArrayListT = std.ArrayListUnmanaged(T); - - // An array containing the lists that belong to this pool. - lists: []ArrayListT = &[_]ArrayListT{}, - - // The pool will be initialized with empty ArrayLists. - pub fn init(alloc: Allocator, list_count: usize, initial_capacity: usize) !Self { - const self: Self = .{ - .lists = try alloc.alloc(ArrayListT, list_count), - }; - - for (self.lists) |*list| { - list.* = try ArrayListT.initCapacity(alloc, initial_capacity); - } - - return self; - } - - pub fn deinit(self: *Self, alloc: Allocator) void { - for (self.lists) |*list| { - list.deinit(alloc); - } - alloc.free(self.lists); - } - - /// Clear all lists in the pool. - pub fn reset(self: *Self) void { - for (self.lists) |*list| { - list.clearRetainingCapacity(); - } - } - }; -} - -/// The contents of all the cells in the terminal. -/// -/// The goal of this data structure is to allow for efficient row-wise -/// clearing of data from the GPU buffers, to allow for row-wise dirty -/// tracking to eliminate the overhead of rebuilding the GPU buffers -/// each frame. -/// -/// Must be initialized by resizing before calling any operations. -pub const Contents = struct { - size: renderer.GridSize = .{ .rows = 0, .columns = 0 }, - - /// Flat array containing cell background colors for the terminal grid. - /// - /// Indexed as `bg_cells[row * size.columns + col]`. - /// - /// Prefer accessing with `Contents.bgCell(row, col).*` instead - /// of directly indexing in order to avoid integer size bugs. - bg_cells: []mtl_shaders.CellBg = undefined, - - /// The ArrayListPool which holds all of the foreground cells. When sized - /// with Contents.resize the individual ArrayLists are given enough room - /// that they can hold a single row with #cols glyphs, underlines, and - /// strikethroughs; however, appendAssumeCapacity MUST NOT be used since - /// it is possible to exceed this with combining glyphs that add a glyph - /// but take up no column since they combine with the previous one, as - /// well as with fonts that perform multi-substitutions for glyphs, which - /// can result in a similar situation where multiple glyphs reside in the - /// same column. - /// - /// Allocations should nevertheless be exceedingly rare since hitting the - /// initial capacity of a list would require a row filled with underlined - /// struck through characters, at least one of which is a multi-glyph - /// composite. - /// - /// Rows are indexed as Contents.fg_rows[y + 1], because the first list in - /// the pool is reserved for the cursor, which must be the first item in - /// the buffer. - /// - /// Must be initialized by calling resize on the Contents struct before - /// calling any operations. - fg_rows: ArrayListPool(mtl_shaders.CellText) = .{}, - - pub fn deinit(self: *Contents, alloc: Allocator) void { - alloc.free(self.bg_cells); - self.fg_rows.deinit(alloc); - } - - /// Resize the cell contents for the given grid size. This will - /// always invalidate the entire cell contents. - pub fn resize( - self: *Contents, - alloc: Allocator, - size: renderer.GridSize, - ) !void { - self.size = size; - - const cell_count = @as(usize, size.columns) * @as(usize, size.rows); - - const bg_cells = try alloc.alloc(mtl_shaders.CellBg, cell_count); - errdefer alloc.free(bg_cells); - - @memset(bg_cells, .{ 0, 0, 0, 0 }); - - // The foreground lists can hold 3 types of items: - // - Glyphs - // - Underlines - // - Strikethroughs - // So we give them an initial capacity of size.columns * 3, which will - // avoid any further allocations in the vast majority of cases. Sadly - // we can not assume capacity though, since with combining glyphs that - // form a single grapheme, and multi-substitutions in fonts, the number - // of glyphs in a row is theoretically unlimited. - // - // We have size.rows + 1 lists because index 0 is used for a special - // list containing the cursor cell which needs to be first in the buffer. - var fg_rows = try ArrayListPool(mtl_shaders.CellText).init(alloc, size.rows + 1, size.columns * 3); - errdefer fg_rows.deinit(alloc); - - alloc.free(self.bg_cells); - self.fg_rows.deinit(alloc); - - self.bg_cells = bg_cells; - self.fg_rows = fg_rows; - - // We don't need 3*cols worth of cells for the cursor list, so we can - // replace it with a smaller list. This is technically a tiny bit of - // extra work but resize is not a hot function so it's worth it to not - // waste the memory. - self.fg_rows.lists[0].deinit(alloc); - self.fg_rows.lists[0] = try std.ArrayListUnmanaged(mtl_shaders.CellText).initCapacity(alloc, 1); - } - - /// Reset the cell contents to an empty state without resizing. - pub fn reset(self: *Contents) void { - @memset(self.bg_cells, .{ 0, 0, 0, 0 }); - self.fg_rows.reset(); - } - - /// Set the cursor value. If the value is null then the cursor is hidden. - pub fn setCursor(self: *Contents, v: ?mtl_shaders.CellText) void { - self.fg_rows.lists[0].clearRetainingCapacity(); - - if (v) |cell| { - self.fg_rows.lists[0].appendAssumeCapacity(cell); - } - } - - /// Access a background cell. Prefer this function over direct indexing - /// of `bg_cells` in order to avoid integer size bugs causing overflows. - pub inline fn bgCell(self: *Contents, row: usize, col: usize) *mtl_shaders.CellBg { - return &self.bg_cells[row * self.size.columns + col]; - } - - /// Add a cell to the appropriate list. Adding the same cell twice will - /// result in duplication in the vertex buffer. The caller should clear - /// the corresponding row with Contents.clear to remove old cells first. - pub fn add( - self: *Contents, - alloc: Allocator, - comptime key: Key, - cell: key.CellType(), - ) !void { - const y = cell.grid_pos[1]; - - assert(y < self.size.rows); - - switch (key) { - .bg => comptime unreachable, - - .text, - .underline, - .strikethrough, - .overline, - // We have a special list containing the cursor cell at the start - // of our fg row pool, so we need to add 1 to the y to get the - // correct index. - => try self.fg_rows.lists[y + 1].append(alloc, cell), - } - } - - /// Clear all of the cell contents for a given row. - pub fn clear(self: *Contents, y: terminal.size.CellCountInt) void { - assert(y < self.size.rows); - - @memset(self.bg_cells[@as(usize, y) * self.size.columns ..][0..self.size.columns], .{ 0, 0, 0, 0 }); - - // We have a special list containing the cursor cell at the start - // of our fg row pool, so we need to add 1 to the y to get the - // correct index. - self.fg_rows.lists[y + 1].clearRetainingCapacity(); - } -}; - -test Contents { - const testing = std.testing; - const alloc = testing.allocator; - - const rows = 10; - const cols = 10; - - var c: Contents = .{}; - try c.resize(alloc, .{ .rows = rows, .columns = cols }); - defer c.deinit(alloc); - - // We should start off empty after resizing. - for (0..rows) |y| { - try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); - for (0..cols) |x| { - try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); - } - } - // And the cursor row should have a capacity of 1 and also be empty. - try testing.expect(c.fg_rows.lists[0].capacity == 1); - try testing.expect(c.fg_rows.lists[0].items.len == 0); - - // Add some contents. - const bg_cell: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 1 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(1, 4).* = bg_cell; - try c.add(alloc, .text, fg_cell); - try testing.expectEqual(bg_cell, c.bgCell(1, 4).*); - // The fg row index is offset by 1 because of the cursor list. - try testing.expectEqual(fg_cell, c.fg_rows.lists[2].items[0]); - - // And we should be able to clear it. - c.clear(1); - for (0..rows) |y| { - try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); - for (0..cols) |x| { - try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); - } - } - - // Add a cursor. - const cursor_cell: mtl_shaders.CellText = .{ - .mode = .cursor, - .grid_pos = .{ 2, 3 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.setCursor(cursor_cell); - try testing.expectEqual(cursor_cell, c.fg_rows.lists[0].items[0]); - - // And remove it. - c.setCursor(null); - try testing.expectEqual(0, c.fg_rows.lists[0].items.len); -} - -test "Contents clear retains other content" { - const testing = std.testing; - const alloc = testing.allocator; - - const rows = 10; - const cols = 10; - - var c: Contents = .{}; - try c.resize(alloc, .{ .rows = rows, .columns = cols }); - defer c.deinit(alloc); - - // Set some contents - // bg and fg cells in row 1 - const bg_cell_1: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell_1: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 1 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(1, 4).* = bg_cell_1; - try c.add(alloc, .text, fg_cell_1); - // bg and fg cells in row 2 - const bg_cell_2: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell_2: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 2 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(2, 4).* = bg_cell_2; - try c.add(alloc, .text, fg_cell_2); - - // Clear row 1, this should leave row 2 untouched - c.clear(1); - - // Row 2 should still contain its cells. - try testing.expectEqual(bg_cell_2, c.bgCell(2, 4).*); - // Fg row index is +1 because of cursor list at start - try testing.expectEqual(fg_cell_2, c.fg_rows.lists[3].items[0]); -} - -test "Contents clear last added content" { - const testing = std.testing; - const alloc = testing.allocator; - - const rows = 10; - const cols = 10; - - var c: Contents = .{}; - try c.resize(alloc, .{ .rows = rows, .columns = cols }); - defer c.deinit(alloc); - - // Set some contents - // bg and fg cells in row 1 - const bg_cell_1: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell_1: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 1 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(1, 4).* = bg_cell_1; - try c.add(alloc, .text, fg_cell_1); - // bg and fg cells in row 2 - const bg_cell_2: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell_2: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 2 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(2, 4).* = bg_cell_2; - try c.add(alloc, .text, fg_cell_2); - - // Clear row 2, this should leave row 1 untouched - c.clear(2); - - // Row 1 should still contain its cells. - try testing.expectEqual(bg_cell_1, c.bgCell(1, 4).*); - // Fg row index is +1 because of cursor list at start - try testing.expectEqual(fg_cell_1, c.fg_rows.lists[2].items[0]); -} diff --git a/src/renderer/metal/image.zig b/src/renderer/metal/image.zig deleted file mode 100644 index 7d2599308..000000000 --- a/src/renderer/metal/image.zig +++ /dev/null @@ -1,466 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const objc = @import("objc"); -const wuffs = @import("wuffs"); - -const mtl = @import("api.zig"); - -/// Represents a single image placement on the grid. A placement is a -/// request to render an instance of an image. -pub const Placement = struct { - /// The image being rendered. This MUST be in the image map. - image_id: u32, - - /// The grid x/y where this placement is located. - x: i32, - y: i32, - z: i32, - - /// The width/height of the placed image. - width: u32, - height: u32, - - /// The offset in pixels from the top left of the cell. - /// This is clamped to the size of a cell. - cell_offset_x: u32, - cell_offset_y: u32, - - /// The source rectangle of the placement. - source_x: u32, - source_y: u32, - source_width: u32, - source_height: u32, -}; - -/// The map used for storing images. -pub const ImageMap = std.AutoHashMapUnmanaged(u32, struct { - image: Image, - transmit_time: std.time.Instant, -}); - -/// The state for a single image that is to be rendered. The image can be -/// pending upload or ready to use with a texture. -pub const Image = union(enum) { - /// The image is pending upload to the GPU. The different keys are - /// different formats since some formats aren't accepted by the GPU - /// and require conversion. - /// - /// This data is owned by this union so it must be freed once the - /// image is uploaded. - pending_gray: Pending, - pending_gray_alpha: Pending, - pending_rgb: Pending, - pending_rgba: Pending, - - /// This is the same as the pending states but there is a texture - /// already allocated that we want to replace. - replace_gray: Replace, - replace_gray_alpha: Replace, - replace_rgb: Replace, - replace_rgba: Replace, - - /// The image is uploaded and ready to be used. - ready: objc.Object, // MTLTexture - - /// The image is uploaded but is scheduled to be unloaded. - unload_pending: []u8, - unload_ready: objc.Object, // MTLTexture - unload_replace: struct { []u8, objc.Object }, - - pub const Replace = struct { - texture: objc.Object, - pending: Pending, - }; - - /// Pending image data that needs to be uploaded to the GPU. - pub const Pending = struct { - height: u32, - width: u32, - - /// Data is always expected to be (width * height * depth). Depth - /// is based on the union key. - data: [*]u8, - - pub fn dataSlice(self: Pending, d: u32) []u8 { - return self.data[0..self.len(d)]; - } - - pub fn len(self: Pending, d: u32) u32 { - return self.width * self.height * d; - } - }; - - pub fn deinit(self: Image, alloc: Allocator) void { - switch (self) { - .pending_gray => |p| alloc.free(p.dataSlice(1)), - .pending_gray_alpha => |p| alloc.free(p.dataSlice(2)), - .pending_rgb => |p| alloc.free(p.dataSlice(3)), - .pending_rgba => |p| alloc.free(p.dataSlice(4)), - .unload_pending => |data| alloc.free(data), - - .replace_gray => |r| { - alloc.free(r.pending.dataSlice(1)); - r.texture.msgSend(void, objc.sel("release"), .{}); - }, - - .replace_gray_alpha => |r| { - alloc.free(r.pending.dataSlice(2)); - r.texture.msgSend(void, objc.sel("release"), .{}); - }, - - .replace_rgb => |r| { - alloc.free(r.pending.dataSlice(3)); - r.texture.msgSend(void, objc.sel("release"), .{}); - }, - - .replace_rgba => |r| { - alloc.free(r.pending.dataSlice(4)); - r.texture.msgSend(void, objc.sel("release"), .{}); - }, - - .unload_replace => |r| { - alloc.free(r[0]); - r[1].msgSend(void, objc.sel("release"), .{}); - }, - - .ready, - .unload_ready, - => |obj| obj.msgSend(void, objc.sel("release"), .{}), - } - } - - /// Mark this image for unload whatever state it is in. - pub fn markForUnload(self: *Image) void { - self.* = switch (self.*) { - .unload_pending, - .unload_replace, - .unload_ready, - => return, - - .ready => |obj| .{ .unload_ready = obj }, - .pending_gray => |p| .{ .unload_pending = p.dataSlice(1) }, - .pending_gray_alpha => |p| .{ .unload_pending = p.dataSlice(2) }, - .pending_rgb => |p| .{ .unload_pending = p.dataSlice(3) }, - .pending_rgba => |p| .{ .unload_pending = p.dataSlice(4) }, - .replace_gray => |r| .{ .unload_replace = .{ - r.pending.dataSlice(1), r.texture, - } }, - .replace_gray_alpha => |r| .{ .unload_replace = .{ - r.pending.dataSlice(2), r.texture, - } }, - .replace_rgb => |r| .{ .unload_replace = .{ - r.pending.dataSlice(3), r.texture, - } }, - .replace_rgba => |r| .{ .unload_replace = .{ - r.pending.dataSlice(4), r.texture, - } }, - }; - } - - /// Replace the currently pending image with a new one. This will - /// attempt to update the existing texture if it is already allocated. - /// If the texture is not allocated, this will act like a new upload. - /// - /// This function only marks the image for replace. The actual logic - /// to replace is done later. - pub fn markForReplace(self: *Image, alloc: Allocator, img: Image) !void { - assert(img.pending() != null); - - // Get our existing texture. This switch statement will also handle - // scenarios where there is no existing texture and we can modify - // the self pointer directly. - const existing: objc.Object = switch (self.*) { - // For pending, we can free the old data and become pending - // ourselves. - .pending_gray => |p| { - alloc.free(p.dataSlice(1)); - self.* = img; - return; - }, - - .pending_gray_alpha => |p| { - alloc.free(p.dataSlice(2)); - self.* = img; - return; - }, - - .pending_rgb => |p| { - alloc.free(p.dataSlice(3)); - self.* = img; - return; - }, - - .pending_rgba => |p| { - alloc.free(p.dataSlice(4)); - self.* = img; - return; - }, - - // If we're marked for unload but we just have pending data, - // this behaves the same as a normal "pending": free the data, - // become new pending. - .unload_pending => |data| { - alloc.free(data); - self.* = img; - return; - }, - - .unload_replace => |r| existing: { - alloc.free(r[0]); - break :existing r[1]; - }, - - // If we were already pending a replacement, then we free our - // existing pending data and use the same texture. - .replace_gray => |r| existing: { - alloc.free(r.pending.dataSlice(1)); - break :existing r.texture; - }, - - .replace_gray_alpha => |r| existing: { - alloc.free(r.pending.dataSlice(2)); - break :existing r.texture; - }, - - .replace_rgb => |r| existing: { - alloc.free(r.pending.dataSlice(3)); - break :existing r.texture; - }, - - .replace_rgba => |r| existing: { - alloc.free(r.pending.dataSlice(4)); - break :existing r.texture; - }, - - // For both ready and unload_ready, we need to replace the - // texture. We can't do that here, so we just mark ourselves - // for replacement. - .ready, .unload_ready => |tex| tex, - }; - - // We now have an existing texture, so set the proper replace key. - self.* = switch (img) { - .pending_gray => |p| .{ .replace_gray = .{ - .texture = existing, - .pending = p, - } }, - - .pending_gray_alpha => |p| .{ .replace_gray_alpha = .{ - .texture = existing, - .pending = p, - } }, - - .pending_rgb => |p| .{ .replace_rgb = .{ - .texture = existing, - .pending = p, - } }, - - .pending_rgba => |p| .{ .replace_rgba = .{ - .texture = existing, - .pending = p, - } }, - - else => unreachable, - }; - } - - /// Returns true if this image is pending upload. - pub fn isPending(self: Image) bool { - return self.pending() != null; - } - - /// Returns true if this image is pending an unload. - pub fn isUnloading(self: Image) bool { - return switch (self) { - .unload_pending, - .unload_ready, - => true, - - .ready, - .pending_rgb, - .pending_rgba, - => false, - }; - } - - /// Converts the image data to a format that can be uploaded to the GPU. - /// If the data is already in a format that can be uploaded, this is a - /// no-op. - pub fn convert(self: *Image, alloc: Allocator) !void { - switch (self.*) { - .ready, - .unload_pending, - .unload_replace, - .unload_ready, - => unreachable, // invalid - - .pending_rgba, - .replace_rgba, - => {}, // ready - - // RGB needs to be converted to RGBA because Metal textures - // don't support RGB. - .pending_rgb => |*p| { - const data = p.dataSlice(3); - const rgba = try wuffs.swizzle.rgbToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_rgb => |*r| { - const data = r.pending.dataSlice(3); - const rgba = try wuffs.swizzle.rgbToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - - // Gray and Gray+Alpha need to be converted to RGBA, too. - .pending_gray => |*p| { - const data = p.dataSlice(1); - const rgba = try wuffs.swizzle.gToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_gray => |*r| { - const data = r.pending.dataSlice(2); - const rgba = try wuffs.swizzle.gToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - - .pending_gray_alpha => |*p| { - const data = p.dataSlice(2); - const rgba = try wuffs.swizzle.gaToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_gray_alpha => |*r| { - const data = r.pending.dataSlice(2); - const rgba = try wuffs.swizzle.gaToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - } - } - - /// Upload the pending image to the GPU and change the state of this - /// image to ready. - pub fn upload( - self: *Image, - alloc: Allocator, - device: objc.Object, - /// Storage mode for the MTLTexture object - storage_mode: mtl.MTLResourceOptions.StorageMode, - ) !void { - // Convert our data if we have to - try self.convert(alloc); - - // Get our pending info - const p = self.pending().?; - - // Create our texture - const texture = try initTexture(p, device, storage_mode); - errdefer texture.msgSend(void, objc.sel("release"), .{}); - - // Upload our data - const d = self.depth(); - texture.msgSend( - void, - objc.sel("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"), - .{ - mtl.MTLRegion{ - .origin = .{ .x = 0, .y = 0, .z = 0 }, - .size = .{ - .width = @intCast(p.width), - .height = @intCast(p.height), - .depth = 1, - }, - }, - @as(c_ulong, 0), - @as(*const anyopaque, p.data), - @as(c_ulong, d * p.width), - }, - ); - - // Uploaded. We can now clear our data and change our state. - // - // NOTE: For "replace_*" states, this will free the old texture. - // We don't currently actually replace the existing texture in-place - // but that is an optimization we can do later. - self.deinit(alloc); - self.* = .{ .ready = texture }; - } - - /// Our pixel depth - fn depth(self: Image) u32 { - return switch (self) { - .pending_rgb => 3, - .pending_rgba => 4, - .replace_rgb => 3, - .replace_rgba => 4, - else => unreachable, - }; - } - - /// Returns true if this image is in a pending state and requires upload. - fn pending(self: Image) ?Pending { - return switch (self) { - .pending_rgb, - .pending_rgba, - => |p| p, - - .replace_rgb, - .replace_rgba, - => |r| r.pending, - - else => null, - }; - } - - fn initTexture( - p: Pending, - device: objc.Object, - /// Storage mode for the MTLTexture object - storage_mode: mtl.MTLResourceOptions.StorageMode, - ) !objc.Object { - // Create our descriptor - const desc = init: { - const Class = objc.getClass("MTLTextureDescriptor").?; - const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); - const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); - break :init id_init; - }; - - // Set our properties - desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.rgba8unorm_srgb)); - desc.setProperty("width", @as(c_ulong, @intCast(p.width))); - desc.setProperty("height", @as(c_ulong, @intCast(p.height))); - - desc.setProperty( - "resourceOptions", - mtl.MTLResourceOptions{ - // Indicate that the CPU writes to this resource but never reads it. - .cpu_cache_mode = .write_combined, - .storage_mode = storage_mode, - }, - ); - - // Initialize - const id = device.msgSend( - ?*anyopaque, - objc.sel("newTextureWithDescriptor:"), - .{desc}, - ) orelse return error.MetalFailed; - - return objc.Object.fromId(id); - } -}; diff --git a/src/renderer/metal/sampler.zig b/src/renderer/metal/sampler.zig deleted file mode 100644 index c7a04df3a..000000000 --- a/src/renderer/metal/sampler.zig +++ /dev/null @@ -1,38 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const objc = @import("objc"); - -const mtl = @import("api.zig"); - -pub const Sampler = struct { - sampler: objc.Object, - - pub fn init(device: objc.Object) !Sampler { - const desc = init: { - const Class = objc.getClass("MTLSamplerDescriptor").?; - const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); - const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); - break :init id_init; - }; - defer desc.msgSend(void, objc.sel("release"), .{}); - desc.setProperty("rAddressMode", @intFromEnum(mtl.MTLSamplerAddressMode.clamp_to_edge)); - desc.setProperty("sAddressMode", @intFromEnum(mtl.MTLSamplerAddressMode.clamp_to_edge)); - desc.setProperty("tAddressMode", @intFromEnum(mtl.MTLSamplerAddressMode.clamp_to_edge)); - desc.setProperty("minFilter", @intFromEnum(mtl.MTLSamplerMinMagFilter.linear)); - desc.setProperty("magFilter", @intFromEnum(mtl.MTLSamplerMinMagFilter.linear)); - - const sampler = device.msgSend( - objc.Object, - objc.sel("newSamplerStateWithDescriptor:"), - .{desc}, - ); - errdefer sampler.msgSend(void, objc.sel("release"), .{}); - - return .{ .sampler = sampler }; - } - - pub fn deinit(self: *Sampler) void { - self.sampler.msgSend(void, objc.sel("release"), .{}); - } -}; diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index 8fa170bf2..9fe0862ed 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -6,28 +6,110 @@ const objc = @import("objc"); const math = @import("../../math.zig"); const mtl = @import("api.zig"); +const Pipeline = @import("Pipeline.zig"); const log = std.log.scoped(.metal); +const pipeline_descs: []const struct { [:0]const u8, PipelineDescription } = + &.{ + .{ "bg_color", .{ + .vertex_fn = "full_screen_vertex", + .fragment_fn = "bg_color_fragment", + .blending_enabled = false, + } }, + .{ "cell_bg", .{ + .vertex_fn = "full_screen_vertex", + .fragment_fn = "cell_bg_fragment", + .blending_enabled = true, + } }, + .{ "cell_text", .{ + .vertex_attributes = CellText, + .vertex_fn = "cell_text_vertex", + .fragment_fn = "cell_text_fragment", + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "image", .{ + .vertex_attributes = Image, + .vertex_fn = "image_vertex", + .fragment_fn = "image_fragment", + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "bg_image", .{ + .vertex_attributes = BgImage, + .vertex_fn = "bg_image_vertex", + .fragment_fn = "bg_image_fragment", + .step_fn = .per_instance, + .blending_enabled = true, + } }, + }; + +/// All the comptime-known info about a pipeline, so that +/// we can define them ahead-of-time in an ergonomic way. +const PipelineDescription = struct { + vertex_attributes: ?type = null, + vertex_fn: []const u8, + fragment_fn: []const u8, + step_fn: mtl.MTLVertexStepFunction = .per_vertex, + blending_enabled: bool, + + fn initPipeline( + self: PipelineDescription, + device: objc.Object, + library: objc.Object, + pixel_format: mtl.MTLPixelFormat, + ) !Pipeline { + return try .init(self.vertex_attributes, .{ + .device = device, + .vertex_fn = self.vertex_fn, + .fragment_fn = self.fragment_fn, + .vertex_library = library, + .fragment_library = library, + .step_fn = self.step_fn, + .attachments = &.{.{ + .pixel_format = pixel_format, + .blending_enabled = self.blending_enabled, + }}, + }); + } +}; + +/// We create a type for the pipeline collection based on our desc array. +const PipelineCollection = t: { + var fields: [pipeline_descs.len]std.builtin.Type.StructField = undefined; + for (pipeline_descs, 0..) |pipeline, i| { + fields[i] = .{ + .name = pipeline[0], + .type = Pipeline, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(Pipeline), + }; + } + break :t @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +}; + /// This contains the state for the shaders used by the Metal renderer. pub const Shaders = struct { library: objc.Object, - /// Renders cell foreground elements (text, decorations). - cell_text_pipeline: objc.Object, - - /// The cell background shader is the shader used to render the - /// background of terminal cells. - cell_bg_pipeline: objc.Object, - - /// The image shader is the shader used to render images for things - /// like the Kitty image protocol. - image_pipeline: objc.Object, + /// Collection of available render pipelines. + pipelines: PipelineCollection, /// Custom shaders to run against the final drawable texture. This /// can be used to apply a lot of effects. Each shader is run in sequence /// against the output of the previous shader. - post_pipelines: []const objc.Object, + post_pipelines: []const Pipeline, + + /// Set to true when deinited, if you try to deinit a defunct set + /// of shaders it will just be ignored, to prevent double-free. + defunct: bool = false, /// Initialize our shader set. /// @@ -43,16 +125,26 @@ pub const Shaders = struct { const library = try initLibrary(device); errdefer library.msgSend(void, objc.sel("release"), .{}); - const cell_text_pipeline = try initCellTextPipeline(device, library, pixel_format); - errdefer cell_text_pipeline.msgSend(void, objc.sel("release"), .{}); + var pipelines: PipelineCollection = undefined; - const cell_bg_pipeline = try initCellBgPipeline(device, library, pixel_format); - errdefer cell_bg_pipeline.msgSend(void, objc.sel("release"), .{}); + var initialized_pipelines: usize = 0; - const image_pipeline = try initImagePipeline(device, library, pixel_format); - errdefer image_pipeline.msgSend(void, objc.sel("release"), .{}); + errdefer inline for (pipeline_descs, 0..) |pipeline, i| { + if (i < initialized_pipelines) { + @field(pipelines, pipeline[0]).deinit(); + } + }; - const post_pipelines: []const objc.Object = initPostPipelines( + inline for (pipeline_descs) |pipeline| { + @field(pipelines, pipeline[0]) = try pipeline[1].initPipeline( + device, + library, + pixel_format, + ); + initialized_pipelines += 1; + } + + const post_pipelines: []const Pipeline = initPostPipelines( alloc, device, library, @@ -66,47 +158,40 @@ pub const Shaders = struct { break :err &.{}; }; errdefer if (post_pipelines.len > 0) { - for (post_pipelines) |pipeline| pipeline.msgSend(void, objc.sel("release"), .{}); + for (post_pipelines) |pipeline| pipeline.deinit(); alloc.free(post_pipelines); }; return .{ .library = library, - .cell_text_pipeline = cell_text_pipeline, - .cell_bg_pipeline = cell_bg_pipeline, - .image_pipeline = image_pipeline, + .pipelines = pipelines, .post_pipelines = post_pipelines, }; } pub fn deinit(self: *Shaders, alloc: Allocator) void { + if (self.defunct) return; + self.defunct = true; + // Release our primary shaders - self.cell_text_pipeline.msgSend(void, objc.sel("release"), .{}); - self.cell_bg_pipeline.msgSend(void, objc.sel("release"), .{}); - self.image_pipeline.msgSend(void, objc.sel("release"), .{}); + inline for (pipeline_descs) |pipeline| { + @field(self.pipelines, pipeline[0]).deinit(); + } self.library.msgSend(void, objc.sel("release"), .{}); // Release our postprocess shaders if (self.post_pipelines.len > 0) { for (self.post_pipelines) |pipeline| { - pipeline.msgSend(void, objc.sel("release"), .{}); + pipeline.deinit(); } alloc.free(self.post_pipelines); } } }; -/// Single parameter for the image shader. See shader for field details. -pub const Image = extern struct { - grid_pos: [2]f32, - cell_offset: [2]f32, - source_rect: [4]f32, - dest_size: [2]f32, -}; - -/// The uniforms that are passed to the terminal cell shader. +/// The uniforms that are passed to our shaders. pub const Uniforms = extern struct { - // Note: all of the explicit aligmnments are copied from the + // Note: all of the explicit alignments are copied from the // MSL developer reference just so that we can be sure that we got // it all exactly right. @@ -114,6 +199,9 @@ pub const Uniforms = extern struct { /// This is calculated based on the size of the screen. projection_matrix: math.Mat align(16), + /// Size of the screen (render target) in pixels. + screen_size: [2]f32 align(8), + /// Size of a single cell in pixels, unscaled. cell_size: [2]f32 align(8), @@ -140,25 +228,30 @@ pub const Uniforms = extern struct { /// The background color for the whole surface. bg_color: [4]u8 align(4), - /// Whether the cursor is 2 cells wide. - cursor_wide: bool align(1), + /// Various booleans. + /// + /// TODO: Maybe put these in a packed struct, like for OpenGL. + bools: extern struct { + /// Whether the cursor is 2 cells wide. + cursor_wide: bool align(1), - /// Indicates that colors provided to the shader are already in - /// the P3 color space, so they don't need to be converted from - /// sRGB. - use_display_p3: bool align(1), + /// Indicates that colors provided to the shader are already in + /// the P3 color space, so they don't need to be converted from + /// sRGB. + use_display_p3: bool align(1), - /// Indicates that the color attachments for the shaders have - /// an `*_srgb` pixel format, which means the shaders need to - /// output linear RGB colors rather than gamma encoded colors, - /// since blending will be performed in linear space and then - /// Metal itself will re-encode the colors for storage. - use_linear_blending: bool align(1), + /// Indicates that the color attachments for the shaders have + /// an `*_srgb` pixel format, which means the shaders need to + /// output linear RGB colors rather than gamma encoded colors, + /// since blending will be performed in linear space and then + /// Metal itself will re-encode the colors for storage. + use_linear_blending: bool align(1), - /// Enables a weight correction step that makes text rendered - /// with linear alpha blending have a similar apparent weight - /// (thickness) to gamma-incorrect blending. - use_linear_correction: bool align(1) = false, + /// Enables a weight correction step that makes text rendered + /// with linear alpha blending have a similar apparent weight + /// (thickness) to gamma-incorrect blending. + use_linear_correction: bool align(1) = false, + }, const PaddingExtend = packed struct(u8) { left: bool = false, @@ -169,21 +262,72 @@ pub const Uniforms = extern struct { }; }; -/// The uniforms used for custom postprocess shaders. -pub const PostUniforms = extern struct { - // Note: all of the explicit aligmnments are copied from the - // MSL developer reference just so that we can be sure that we got - // it all exactly right. - resolution: [3]f32 align(16), - time: f32 align(4), - time_delta: f32 align(4), - frame_rate: f32 align(4), - frame: i32 align(4), - channel_time: [4][4]f32 align(16), - channel_resolution: [4][4]f32 align(16), - mouse: [4]f32 align(16), - date: [4]f32 align(16), - sample_rate: f32 align(4), +/// This is a single parameter for the terminal cell shader. +pub const CellText = extern struct { + glyph_pos: [2]u32 align(8) = .{ 0, 0 }, + glyph_size: [2]u32 align(8) = .{ 0, 0 }, + bearings: [2]i16 align(4) = .{ 0, 0 }, + grid_pos: [2]u16 align(4), + color: [4]u8 align(4), + mode: Mode align(1), + constraint_width: u8 align(1) = 0, + + pub const Mode = enum(u8) { + fg = 1, + fg_constrained = 2, + fg_color = 3, + cursor = 4, + fg_powerline = 5, + }; + + test { + // Minimizing the size of this struct is important, + // so we test it in order to be aware of any changes. + try std.testing.expectEqual(32, @sizeOf(CellText)); + } +}; + +/// This is a single parameter for the cell bg shader. +pub const CellBg = [4]u8; + +/// Single parameter for the image shader. See shader for field details. +pub const Image = extern struct { + grid_pos: [2]f32, + cell_offset: [2]f32, + source_rect: [4]f32, + dest_size: [2]f32, +}; + +/// Single parameter for the bg image shader. +pub const BgImage = extern struct { + opacity: f32 align(4), + info: Info align(1), + + pub const Info = packed struct(u8) { + position: Position, + fit: Fit, + repeat: bool, + _padding: u1 = 0, + + pub const Position = enum(u4) { + tl = 0, + tc = 1, + tr = 2, + ml = 3, + mc = 4, + mr = 5, + bl = 6, + bc = 7, + br = 8, + }; + + pub const Fit = enum(u2) { + contain = 0, + cover = 1, + stretch = 2, + none = 3, + }; + }; }; /// Initialize the MTLLibrary. A MTLLibrary is a collection of shaders. @@ -214,15 +358,16 @@ fn initLibrary(device: objc.Object) !objc.Object { return library; } -/// Initialize our custom shader pipelines. The shaders argument is a -/// set of shader source code, not file paths. +/// Initialize our custom shader pipelines. +/// +/// The shaders argument is a set of shader source code, not file paths. fn initPostPipelines( alloc: Allocator, device: objc.Object, library: objc.Object, shaders: []const [:0]const u8, pixel_format: mtl.MTLPixelFormat, -) ![]const objc.Object { +) ![]const Pipeline { // If we have no shaders, do nothing. if (shaders.len == 0) return &.{}; @@ -230,10 +375,10 @@ fn initPostPipelines( var i: usize = 0; // Initialize our result set. If any error happens, we undo everything. - var pipelines = try alloc.alloc(objc.Object, shaders.len); + var pipelines = try alloc.alloc(Pipeline, shaders.len); errdefer { for (pipelines[0..i]) |pipeline| { - pipeline.msgSend(void, objc.sel("release"), .{}); + pipeline.deinit(); } alloc.free(pipelines); } @@ -259,7 +404,7 @@ fn initPostPipeline( library: objc.Object, data: [:0]const u8, pixel_format: mtl.MTLPixelFormat, -) !objc.Object { +) !Pipeline { // Create our library which has the shader source const post_library = library: { const source = try macos.foundation.String.createWithBytes( @@ -282,437 +427,19 @@ fn initPostPipeline( }; defer post_library.msgSend(void, objc.sel("release"), .{}); - // Get our vertex and fragment functions - const func_vert = func_vert: { - const str = try macos.foundation.String.createWithBytes( - "full_screen_vertex", - .utf8, - false, - ); - defer str.release(); - - const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); - break :func_vert objc.Object.fromId(ptr.?); - }; - const func_frag = func_frag: { - const str = try macos.foundation.String.createWithBytes( - "main0", - .utf8, - false, - ); - defer str.release(); - - const ptr = post_library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); - break :func_frag objc.Object.fromId(ptr.?); - }; - defer func_vert.msgSend(void, objc.sel("release"), .{}); - defer func_frag.msgSend(void, objc.sel("release"), .{}); - - // Create our descriptor - const desc = init: { - const Class = objc.getClass("MTLRenderPipelineDescriptor").?; - const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); - const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); - break :init id_init; - }; - defer desc.msgSend(void, objc.sel("release"), .{}); - desc.setProperty("vertexFunction", func_vert); - desc.setProperty("fragmentFunction", func_frag); - - // Set our color attachment - const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments")); - { - const attachment = attachments.msgSend( - objc.Object, - objc.sel("objectAtIndexedSubscript:"), - .{@as(c_ulong, 0)}, - ); - - attachment.setProperty("pixelFormat", @intFromEnum(pixel_format)); - } - - // Make our state - var err: ?*anyopaque = null; - const pipeline_state = device.msgSend( - objc.Object, - objc.sel("newRenderPipelineStateWithDescriptor:error:"), - .{ desc, &err }, - ); - try checkError(err); - - return pipeline_state; -} - -/// This is a single parameter for the terminal cell shader. -pub const CellText = extern struct { - glyph_pos: [2]u32 align(8) = .{ 0, 0 }, - glyph_size: [2]u32 align(8) = .{ 0, 0 }, - bearings: [2]i16 align(4) = .{ 0, 0 }, - grid_pos: [2]u16 align(4), - color: [4]u8 align(4), - mode: Mode align(1), - constraint_width: u8 align(1) = 0, - - pub const Mode = enum(u8) { - fg = 1, - fg_constrained = 2, - fg_color = 3, - cursor = 4, - fg_powerline = 5, - }; - - test { - // Minimizing the size of this struct is important, - // so we test it in order to be aware of any changes. - try std.testing.expectEqual(32, @sizeOf(CellText)); - } -}; - -/// Initialize the cell render pipeline for our shader library. -fn initCellTextPipeline( - device: objc.Object, - library: objc.Object, - pixel_format: mtl.MTLPixelFormat, -) !objc.Object { - // Get our vertex and fragment functions - const func_vert = func_vert: { - const str = try macos.foundation.String.createWithBytes( - "cell_text_vertex", - .utf8, - false, - ); - defer str.release(); - - const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); - break :func_vert objc.Object.fromId(ptr.?); - }; - const func_frag = func_frag: { - const str = try macos.foundation.String.createWithBytes( - "cell_text_fragment", - .utf8, - false, - ); - defer str.release(); - - const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); - break :func_frag objc.Object.fromId(ptr.?); - }; - defer func_vert.msgSend(void, objc.sel("release"), .{}); - defer func_frag.msgSend(void, objc.sel("release"), .{}); - - // Create the vertex descriptor. The vertex descriptor describes the - // data layout of the vertex inputs. We use indexed (or "instanced") - // rendering, so this makes it so that each instance gets a single - // Cell as input. - const vertex_desc = vertex_desc: { - const desc = init: { - const Class = objc.getClass("MTLVertexDescriptor").?; - const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); - const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); - break :init id_init; - }; - - // Our attributes are the fields of the input - const attrs = objc.Object.fromId(desc.getProperty(?*anyopaque, "attributes")); - autoAttribute(CellText, attrs); - - // The layout describes how and when we fetch the next vertex input. - const layouts = objc.Object.fromId(desc.getProperty(?*anyopaque, "layouts")); - { - const layout = layouts.msgSend( - objc.Object, - objc.sel("objectAtIndexedSubscript:"), - .{@as(c_ulong, 0)}, - ); - - // Access each Cell per instance, not per vertex. - layout.setProperty("stepFunction", @intFromEnum(mtl.MTLVertexStepFunction.per_instance)); - layout.setProperty("stride", @as(c_ulong, @sizeOf(CellText))); - } - - break :vertex_desc desc; - }; - defer vertex_desc.msgSend(void, objc.sel("release"), .{}); - - // Create our descriptor - const desc = init: { - const Class = objc.getClass("MTLRenderPipelineDescriptor").?; - const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); - const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); - break :init id_init; - }; - defer desc.msgSend(void, objc.sel("release"), .{}); - - // Set our properties - desc.setProperty("vertexFunction", func_vert); - desc.setProperty("fragmentFunction", func_frag); - desc.setProperty("vertexDescriptor", vertex_desc); - - // Set our color attachment - const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments")); - { - const attachment = attachments.msgSend( - objc.Object, - objc.sel("objectAtIndexedSubscript:"), - .{@as(c_ulong, 0)}, - ); - - attachment.setProperty("pixelFormat", @intFromEnum(pixel_format)); - - // Blending. This is required so that our text we render on top - // of our drawable properly blends into the bg. - attachment.setProperty("blendingEnabled", true); - attachment.setProperty("rgbBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add)); - attachment.setProperty("alphaBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add)); - attachment.setProperty("sourceRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one)); - attachment.setProperty("sourceAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one)); - attachment.setProperty("destinationRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha)); - attachment.setProperty("destinationAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha)); - } - - // Make our state - var err: ?*anyopaque = null; - const pipeline_state = device.msgSend( - objc.Object, - objc.sel("newRenderPipelineStateWithDescriptor:error:"), - .{ desc, &err }, - ); - try checkError(err); - errdefer pipeline_state.msgSend(void, objc.sel("release"), .{}); - - return pipeline_state; -} - -/// This is a single parameter for the cell bg shader. -pub const CellBg = [4]u8; - -/// Initialize the cell background render pipeline for our shader library. -fn initCellBgPipeline( - device: objc.Object, - library: objc.Object, - pixel_format: mtl.MTLPixelFormat, -) !objc.Object { - // Get our vertex and fragment functions - const func_vert = func_vert: { - const str = try macos.foundation.String.createWithBytes( - "cell_bg_vertex", - .utf8, - false, - ); - defer str.release(); - - const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); - break :func_vert objc.Object.fromId(ptr.?); - }; - defer func_vert.msgSend(void, objc.sel("release"), .{}); - const func_frag = func_frag: { - const str = try macos.foundation.String.createWithBytes( - "cell_bg_fragment", - .utf8, - false, - ); - defer str.release(); - - const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); - break :func_frag objc.Object.fromId(ptr.?); - }; - defer func_frag.msgSend(void, objc.sel("release"), .{}); - - // Create our descriptor - const desc = init: { - const Class = objc.getClass("MTLRenderPipelineDescriptor").?; - const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); - const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); - break :init id_init; - }; - defer desc.msgSend(void, objc.sel("release"), .{}); - - // Set our properties - desc.setProperty("vertexFunction", func_vert); - desc.setProperty("fragmentFunction", func_frag); - - // Set our color attachment - const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments")); - { - const attachment = attachments.msgSend( - objc.Object, - objc.sel("objectAtIndexedSubscript:"), - .{@as(c_ulong, 0)}, - ); - - attachment.setProperty("pixelFormat", @intFromEnum(pixel_format)); - - // Blending. This is required so that our text we render on top - // of our drawable properly blends into the bg. - attachment.setProperty("blendingEnabled", true); - attachment.setProperty("rgbBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add)); - attachment.setProperty("alphaBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add)); - attachment.setProperty("sourceRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one)); - attachment.setProperty("sourceAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one)); - attachment.setProperty("destinationRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha)); - attachment.setProperty("destinationAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha)); - } - - // Make our state - var err: ?*anyopaque = null; - const pipeline_state = device.msgSend( - objc.Object, - objc.sel("newRenderPipelineStateWithDescriptor:error:"), - .{ desc, &err }, - ); - try checkError(err); - errdefer pipeline_state.msgSend(void, objc.sel("release"), .{}); - - return pipeline_state; -} - -/// Initialize the image render pipeline for our shader library. -fn initImagePipeline( - device: objc.Object, - library: objc.Object, - pixel_format: mtl.MTLPixelFormat, -) !objc.Object { - // Get our vertex and fragment functions - const func_vert = func_vert: { - const str = try macos.foundation.String.createWithBytes( - "image_vertex", - .utf8, - false, - ); - defer str.release(); - - const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); - break :func_vert objc.Object.fromId(ptr.?); - }; - const func_frag = func_frag: { - const str = try macos.foundation.String.createWithBytes( - "image_fragment", - .utf8, - false, - ); - defer str.release(); - - const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); - break :func_frag objc.Object.fromId(ptr.?); - }; - defer func_vert.msgSend(void, objc.sel("release"), .{}); - defer func_frag.msgSend(void, objc.sel("release"), .{}); - - // Create the vertex descriptor. The vertex descriptor describes the - // data layout of the vertex inputs. We use indexed (or "instanced") - // rendering, so this makes it so that each instance gets a single - // Image as input. - const vertex_desc = vertex_desc: { - const desc = init: { - const Class = objc.getClass("MTLVertexDescriptor").?; - const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); - const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); - break :init id_init; - }; - - // Our attributes are the fields of the input - const attrs = objc.Object.fromId(desc.getProperty(?*anyopaque, "attributes")); - autoAttribute(Image, attrs); - - // The layout describes how and when we fetch the next vertex input. - const layouts = objc.Object.fromId(desc.getProperty(?*anyopaque, "layouts")); - { - const layout = layouts.msgSend( - objc.Object, - objc.sel("objectAtIndexedSubscript:"), - .{@as(c_ulong, 0)}, - ); - - // Access each Image per instance, not per vertex. - layout.setProperty("stepFunction", @intFromEnum(mtl.MTLVertexStepFunction.per_instance)); - layout.setProperty("stride", @as(c_ulong, @sizeOf(Image))); - } - - break :vertex_desc desc; - }; - defer vertex_desc.msgSend(void, objc.sel("release"), .{}); - - // Create our descriptor - const desc = init: { - const Class = objc.getClass("MTLRenderPipelineDescriptor").?; - const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); - const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); - break :init id_init; - }; - defer desc.msgSend(void, objc.sel("release"), .{}); - - // Set our properties - desc.setProperty("vertexFunction", func_vert); - desc.setProperty("fragmentFunction", func_frag); - desc.setProperty("vertexDescriptor", vertex_desc); - - // Set our color attachment - const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments")); - { - const attachment = attachments.msgSend( - objc.Object, - objc.sel("objectAtIndexedSubscript:"), - .{@as(c_ulong, 0)}, - ); - - attachment.setProperty("pixelFormat", @intFromEnum(pixel_format)); - - // Blending. This is required so that our text we render on top - // of our drawable properly blends into the bg. - attachment.setProperty("blendingEnabled", true); - attachment.setProperty("rgbBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add)); - attachment.setProperty("alphaBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add)); - attachment.setProperty("sourceRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one)); - attachment.setProperty("sourceAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one)); - attachment.setProperty("destinationRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha)); - attachment.setProperty("destinationAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha)); - } - - // Make our state - var err: ?*anyopaque = null; - const pipeline_state = device.msgSend( - objc.Object, - objc.sel("newRenderPipelineStateWithDescriptor:error:"), - .{ desc, &err }, - ); - try checkError(err); - - return pipeline_state; -} - -fn autoAttribute(T: type, attrs: objc.Object) void { - inline for (@typeInfo(T).@"struct".fields, 0..) |field, i| { - const offset = @offsetOf(T, field.name); - - const FT = switch (@typeInfo(field.type)) { - .@"enum" => |e| e.tag_type, - else => field.type, - }; - - const format = switch (FT) { - [4]u8 => mtl.MTLVertexFormat.uchar4, - [2]u16 => mtl.MTLVertexFormat.ushort2, - [2]i16 => mtl.MTLVertexFormat.short2, - [2]f32 => mtl.MTLVertexFormat.float2, - [4]f32 => mtl.MTLVertexFormat.float4, - [2]i32 => mtl.MTLVertexFormat.int2, - u32 => mtl.MTLVertexFormat.uint, - [2]u32 => mtl.MTLVertexFormat.uint2, - [4]u32 => mtl.MTLVertexFormat.uint4, - u8 => mtl.MTLVertexFormat.uchar, - else => comptime unreachable, - }; - - const attr = attrs.msgSend( - objc.Object, - objc.sel("objectAtIndexedSubscript:"), - .{@as(c_ulong, i)}, - ); - - attr.setProperty("format", @intFromEnum(format)); - attr.setProperty("offset", @as(c_ulong, offset)); - attr.setProperty("bufferIndex", @as(c_ulong, 0)); - } + return try Pipeline.init(null, .{ + .device = device, + .vertex_fn = "full_screen_vertex", + .fragment_fn = "main0", + .vertex_library = library, + .fragment_library = post_library, + .attachments = &.{ + .{ + .pixel_format = pixel_format, + .blending_enabled = false, + }, + }, + }); } fn checkError(err_: ?*anyopaque) !void { diff --git a/src/renderer/opengl/CellProgram.zig b/src/renderer/opengl/CellProgram.zig deleted file mode 100644 index c4da8e233..000000000 --- a/src/renderer/opengl/CellProgram.zig +++ /dev/null @@ -1,196 +0,0 @@ -/// The OpenGL program for rendering terminal cells. -const CellProgram = @This(); - -const std = @import("std"); -const gl = @import("opengl"); - -program: gl.Program, -vao: gl.VertexArray, -ebo: gl.Buffer, -vbo: gl.Buffer, - -/// The raw structure that maps directly to the buffer sent to the vertex shader. -/// This must be "extern" so that the field order is not reordered by the -/// Zig compiler. -pub const Cell = extern struct { - /// vec2 grid_coord - grid_col: u16, - grid_row: u16, - - /// vec2 glyph_pos - glyph_x: u32 = 0, - glyph_y: u32 = 0, - - /// vec2 glyph_size - glyph_width: u32 = 0, - glyph_height: u32 = 0, - - /// vec2 glyph_offset - glyph_offset_x: i32 = 0, - glyph_offset_y: i32 = 0, - - /// vec4 color_in - r: u8, - g: u8, - b: u8, - a: u8, - - /// vec4 bg_color_in - bg_r: u8, - bg_g: u8, - bg_b: u8, - bg_a: u8, - - /// uint mode - mode: CellMode, - - /// The width in grid cells that a rendering takes. - grid_width: u8, -}; - -pub const CellMode = enum(u8) { - bg = 1, - fg = 2, - fg_constrained = 3, - fg_color = 7, - fg_powerline = 15, - - // Non-exhaustive because masks change it - _, - - /// Apply a mask to the mode. - pub fn mask(self: CellMode, m: CellMode) CellMode { - return @enumFromInt(@intFromEnum(self) | @intFromEnum(m)); - } - - pub fn isFg(self: CellMode) bool { - // Since we use bit tricks below, we want to ensure the enum - // doesn't change without us looking at this logic again. - comptime { - const info = @typeInfo(CellMode).@"enum"; - std.debug.assert(info.fields.len == 5); - } - - return @intFromEnum(self) & @intFromEnum(@as(CellMode, .fg)) != 0; - } -}; - -pub fn init() !CellProgram { - // Load and compile our shaders. - const program = try gl.Program.createVF( - @embedFile("../shaders/cell.v.glsl"), - @embedFile("../shaders/cell.f.glsl"), - ); - errdefer program.destroy(); - - // Set our cell dimensions - const pbind = try program.use(); - defer pbind.unbind(); - - // Set all of our texture indexes - try program.setUniform("text", 0); - try program.setUniform("text_color", 1); - - // Setup our VAO - const vao = try gl.VertexArray.create(); - errdefer vao.destroy(); - const vaobind = try vao.bind(); - defer vaobind.unbind(); - - // Element buffer (EBO) - const ebo = try gl.Buffer.create(); - errdefer ebo.destroy(); - var ebobind = try ebo.bind(.element_array); - defer ebobind.unbind(); - try ebobind.setData([6]u8{ - 0, 1, 3, // Top-left triangle - 1, 2, 3, // Bottom-right triangle - }, .static_draw); - - // Vertex buffer (VBO) - const vbo = try gl.Buffer.create(); - errdefer vbo.destroy(); - var vbobind = try vbo.bind(.array); - defer vbobind.unbind(); - var offset: usize = 0; - try vbobind.attributeAdvanced(0, 2, gl.c.GL_UNSIGNED_SHORT, false, @sizeOf(Cell), offset); - offset += 2 * @sizeOf(u16); - try vbobind.attributeAdvanced(1, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Cell), offset); - offset += 2 * @sizeOf(u32); - try vbobind.attributeAdvanced(2, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Cell), offset); - offset += 2 * @sizeOf(u32); - try vbobind.attributeAdvanced(3, 2, gl.c.GL_INT, false, @sizeOf(Cell), offset); - offset += 2 * @sizeOf(i32); - try vbobind.attributeAdvanced(4, 4, gl.c.GL_UNSIGNED_BYTE, false, @sizeOf(Cell), offset); - offset += 4 * @sizeOf(u8); - try vbobind.attributeAdvanced(5, 4, gl.c.GL_UNSIGNED_BYTE, false, @sizeOf(Cell), offset); - offset += 4 * @sizeOf(u8); - try vbobind.attributeIAdvanced(6, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(Cell), offset); - offset += 1 * @sizeOf(u8); - try vbobind.attributeIAdvanced(7, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(Cell), offset); - try vbobind.enableAttribArray(0); - try vbobind.enableAttribArray(1); - try vbobind.enableAttribArray(2); - try vbobind.enableAttribArray(3); - try vbobind.enableAttribArray(4); - try vbobind.enableAttribArray(5); - try vbobind.enableAttribArray(6); - try vbobind.enableAttribArray(7); - try vbobind.attributeDivisor(0, 1); - try vbobind.attributeDivisor(1, 1); - try vbobind.attributeDivisor(2, 1); - try vbobind.attributeDivisor(3, 1); - try vbobind.attributeDivisor(4, 1); - try vbobind.attributeDivisor(5, 1); - try vbobind.attributeDivisor(6, 1); - try vbobind.attributeDivisor(7, 1); - - return .{ - .program = program, - .vao = vao, - .ebo = ebo, - .vbo = vbo, - }; -} - -pub fn bind(self: CellProgram) !Binding { - const program = try self.program.use(); - errdefer program.unbind(); - - const vao = try self.vao.bind(); - errdefer vao.unbind(); - - const ebo = try self.ebo.bind(.element_array); - errdefer ebo.unbind(); - - const vbo = try self.vbo.bind(.array); - errdefer vbo.unbind(); - - return .{ - .program = program, - .vao = vao, - .ebo = ebo, - .vbo = vbo, - }; -} - -pub fn deinit(self: CellProgram) void { - self.vbo.destroy(); - self.ebo.destroy(); - self.vao.destroy(); - self.program.destroy(); -} - -pub const Binding = struct { - program: gl.Program.Binding, - vao: gl.VertexArray.Binding, - ebo: gl.Buffer.Binding, - vbo: gl.Buffer.Binding, - - pub fn unbind(self: Binding) void { - self.vbo.unbind(); - self.ebo.unbind(); - self.vao.unbind(); - self.program.unbind(); - } -}; diff --git a/src/renderer/opengl/Frame.zig b/src/renderer/opengl/Frame.zig new file mode 100644 index 000000000..4c23fe106 --- /dev/null +++ b/src/renderer/opengl/Frame.zig @@ -0,0 +1,75 @@ +//! Wrapper for handling render passes. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const builtin = @import("builtin"); +const gl = @import("opengl"); + +const Renderer = @import("../generic.zig").Renderer(OpenGL); +const OpenGL = @import("../OpenGL.zig"); +const Target = @import("Target.zig"); +const Pipeline = @import("Pipeline.zig"); +const RenderPass = @import("RenderPass.zig"); +const Buffer = @import("buffer.zig").Buffer; + +const Health = @import("../../renderer.zig").Health; + +const log = std.log.scoped(.opengl); + +/// Options for beginning a frame. +pub const Options = struct {}; + +renderer: *Renderer, +target: *Target, + +/// Begin encoding a frame. +pub fn begin( + opts: Options, + /// Once the frame has been completed, the `frameCompleted` method + /// on the renderer is called with the health status of the frame. + renderer: *Renderer, + /// The target is presented via the provided renderer's API when completed. + target: *Target, +) !Self { + _ = opts; + + return .{ + .renderer = renderer, + .target = target, + }; +} + +/// Add a render pass to this frame with the provided attachments. +/// Returns a RenderPass which allows render steps to be added. +pub inline fn renderPass( + self: *const Self, + attachments: []const RenderPass.Options.Attachment, +) RenderPass { + _ = self; + return RenderPass.begin(.{ .attachments = attachments }); +} + +/// Complete this frame and present the target. +/// +/// If `sync` is true, this will block until the frame is presented. +/// +/// NOTE: For OpenGL, `sync` is ignored and we always block. +pub fn complete(self: *const Self, sync: bool) void { + _ = sync; + gl.finish(); + + // If there are any GL errors, consider the frame unhealthy. + const health: Health = if (gl.errors.getError()) .healthy else |_| .unhealthy; + + // If the frame is healthy, present it. + if (health == .healthy) { + self.renderer.api.present(self.target.*) catch |err| { + log.err("Failed to present render target: err={}", .{err}); + }; + } + + // Report the health to the renderer. + self.renderer.frameCompleted(health); +} diff --git a/src/renderer/opengl/ImageProgram.zig b/src/renderer/opengl/ImageProgram.zig deleted file mode 100644 index ff6794085..000000000 --- a/src/renderer/opengl/ImageProgram.zig +++ /dev/null @@ -1,134 +0,0 @@ -/// The OpenGL program for rendering terminal cells. -const ImageProgram = @This(); - -const std = @import("std"); -const gl = @import("opengl"); - -program: gl.Program, -vao: gl.VertexArray, -ebo: gl.Buffer, -vbo: gl.Buffer, - -pub const Input = extern struct { - /// vec2 grid_coord - grid_col: i32, - grid_row: i32, - - /// vec2 cell_offset - cell_offset_x: u32 = 0, - cell_offset_y: u32 = 0, - - /// vec4 source_rect - source_x: u32 = 0, - source_y: u32 = 0, - source_width: u32 = 0, - source_height: u32 = 0, - - /// vec2 dest_size - dest_width: u32 = 0, - dest_height: u32 = 0, -}; - -pub fn init() !ImageProgram { - // Load and compile our shaders. - const program = try gl.Program.createVF( - @embedFile("../shaders/image.v.glsl"), - @embedFile("../shaders/image.f.glsl"), - ); - errdefer program.destroy(); - - // Set our program uniforms - const pbind = try program.use(); - defer pbind.unbind(); - - // Set all of our texture indexes - try program.setUniform("image", 0); - - // Setup our VAO - const vao = try gl.VertexArray.create(); - errdefer vao.destroy(); - const vaobind = try vao.bind(); - defer vaobind.unbind(); - - // Element buffer (EBO) - const ebo = try gl.Buffer.create(); - errdefer ebo.destroy(); - var ebobind = try ebo.bind(.element_array); - defer ebobind.unbind(); - try ebobind.setData([6]u8{ - 0, 1, 3, // Top-left triangle - 1, 2, 3, // Bottom-right triangle - }, .static_draw); - - // Vertex buffer (VBO) - const vbo = try gl.Buffer.create(); - errdefer vbo.destroy(); - var vbobind = try vbo.bind(.array); - defer vbobind.unbind(); - var offset: usize = 0; - try vbobind.attributeAdvanced(0, 2, gl.c.GL_INT, false, @sizeOf(Input), offset); - offset += 2 * @sizeOf(i32); - try vbobind.attributeAdvanced(1, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Input), offset); - offset += 2 * @sizeOf(u32); - try vbobind.attributeAdvanced(2, 4, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Input), offset); - offset += 4 * @sizeOf(u32); - try vbobind.attributeAdvanced(3, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Input), offset); - offset += 2 * @sizeOf(u32); - try vbobind.enableAttribArray(0); - try vbobind.enableAttribArray(1); - try vbobind.enableAttribArray(2); - try vbobind.enableAttribArray(3); - try vbobind.attributeDivisor(0, 1); - try vbobind.attributeDivisor(1, 1); - try vbobind.attributeDivisor(2, 1); - try vbobind.attributeDivisor(3, 1); - - return .{ - .program = program, - .vao = vao, - .ebo = ebo, - .vbo = vbo, - }; -} - -pub fn bind(self: ImageProgram) !Binding { - const program = try self.program.use(); - errdefer program.unbind(); - - const vao = try self.vao.bind(); - errdefer vao.unbind(); - - const ebo = try self.ebo.bind(.element_array); - errdefer ebo.unbind(); - - const vbo = try self.vbo.bind(.array); - errdefer vbo.unbind(); - - return .{ - .program = program, - .vao = vao, - .ebo = ebo, - .vbo = vbo, - }; -} - -pub fn deinit(self: ImageProgram) void { - self.vbo.destroy(); - self.ebo.destroy(); - self.vao.destroy(); - self.program.destroy(); -} - -pub const Binding = struct { - program: gl.Program.Binding, - vao: gl.VertexArray.Binding, - ebo: gl.Buffer.Binding, - vbo: gl.Buffer.Binding, - - pub fn unbind(self: Binding) void { - self.vbo.unbind(); - self.ebo.unbind(); - self.vao.unbind(); - self.program.unbind(); - } -}; diff --git a/src/renderer/opengl/Pipeline.zig b/src/renderer/opengl/Pipeline.zig new file mode 100644 index 000000000..c3d414ff2 --- /dev/null +++ b/src/renderer/opengl/Pipeline.zig @@ -0,0 +1,170 @@ +//! Wrapper for handling render pipelines. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const builtin = @import("builtin"); +const gl = @import("opengl"); + +const OpenGL = @import("../OpenGL.zig"); +const Texture = @import("Texture.zig"); +const Buffer = @import("buffer.zig").Buffer; + +const log = std.log.scoped(.opengl); + +/// Options for initializing a render pipeline. +pub const Options = struct { + /// GLSL source of the vertex function + vertex_fn: [:0]const u8, + /// GLSL source of the fragment function + fragment_fn: [:0]const u8, + + /// Vertex step function + step_fn: StepFunction = .per_vertex, + + /// Whether to enable blending. + blending_enabled: bool = true, + + pub const StepFunction = enum { + constant, + per_vertex, + per_instance, + }; +}; + +program: gl.Program, + +fbo: gl.Framebuffer, + +vao: gl.VertexArray, + +stride: usize, + +blending_enabled: bool, + +pub fn init(comptime VertexAttributes: ?type, opts: Options) !Self { + // Load and compile our shaders. + const program = try gl.Program.createVF( + opts.vertex_fn, + opts.fragment_fn, + ); + errdefer program.destroy(); + + const pbind = try program.use(); + defer pbind.unbind(); + + const fbo = try gl.Framebuffer.create(); + errdefer fbo.destroy(); + const fbobind = try fbo.bind(.framebuffer); + defer fbobind.unbind(); + + const vao = try gl.VertexArray.create(); + errdefer vao.destroy(); + const vaobind = try vao.bind(); + defer vaobind.unbind(); + + if (VertexAttributes) |VA| try autoAttribute(VA, vaobind, opts.step_fn); + + return .{ + .program = program, + .fbo = fbo, + .vao = vao, + .stride = if (VertexAttributes) |VA| @sizeOf(VA) else 0, + .blending_enabled = opts.blending_enabled, + }; +} + +pub fn deinit(self: *const Self) void { + self.program.destroy(); +} + +fn autoAttribute( + T: type, + vaobind: gl.VertexArray.Binding, + step_fn: Options.StepFunction, +) !void { + const divisor: gl.c.GLuint = switch (step_fn) { + .per_vertex => 0, + .per_instance => 1, + .constant => std.math.maxInt(gl.c.GLuint), + }; + + inline for (@typeInfo(T).@"struct".fields, 0..) |field, i| { + try vaobind.enableAttribArray(i); + try vaobind.attributeBinding(i, 0); + try vaobind.bindingDivisor(i, divisor); + + const offset = @offsetOf(T, field.name); + + const FT = switch (@typeInfo(field.type)) { + .@"struct" => |s| s.backing_integer.?, + .@"enum" => |e| e.tag_type, + else => field.type, + }; + + const size, const IT = switch (@typeInfo(FT)) { + .array => |a| .{ a.len, a.child }, + else => .{ 1, FT }, + }; + + try switch (IT) { + u8 => vaobind.attributeIFormat( + i, + size, + gl.c.GL_UNSIGNED_BYTE, + offset, + ), + u16 => vaobind.attributeIFormat( + i, + size, + gl.c.GL_UNSIGNED_SHORT, + offset, + ), + u32 => vaobind.attributeIFormat( + i, + size, + gl.c.GL_UNSIGNED_INT, + offset, + ), + i8 => vaobind.attributeIFormat( + i, + size, + gl.c.GL_BYTE, + offset, + ), + i16 => vaobind.attributeIFormat( + i, + size, + gl.c.GL_SHORT, + offset, + ), + i32 => vaobind.attributeIFormat( + i, + size, + gl.c.GL_INT, + offset, + ), + f16 => vaobind.attributeFormat( + i, + size, + gl.c.GL_HALF_FLOAT, + false, + offset, + ), + f32 => vaobind.attributeFormat( + i, + size, + gl.c.GL_FLOAT, + false, + offset, + ), + f64 => vaobind.attributeLFormat( + i, + size, + offset, + ), + else => unreachable, + }; + } +} diff --git a/src/renderer/opengl/RenderPass.zig b/src/renderer/opengl/RenderPass.zig new file mode 100644 index 000000000..0f5bd89e7 --- /dev/null +++ b/src/renderer/opengl/RenderPass.zig @@ -0,0 +1,141 @@ +//! Wrapper for handling render passes. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const builtin = @import("builtin"); +const gl = @import("opengl"); + +const OpenGL = @import("../OpenGL.zig"); +const Target = @import("Target.zig"); +const Texture = @import("Texture.zig"); +const Pipeline = @import("Pipeline.zig"); +const RenderPass = @import("RenderPass.zig"); +const Buffer = @import("buffer.zig").Buffer; + +/// Options for beginning a render pass. +pub const Options = struct { + /// Color attachments for this render pass. + attachments: []const Attachment, + + /// Describes a color attachment. + pub const Attachment = struct { + target: union(enum) { + texture: Texture, + target: Target, + }, + clear_color: ?[4]f32 = null, + }; +}; + +/// Describes a step in a render pass. +pub const Step = struct { + pipeline: Pipeline, + uniforms: ?gl.Buffer = null, + buffers: []const ?gl.Buffer = &.{}, + textures: []const ?Texture = &.{}, + draw: Draw, + + /// Describes the draw call for this step. + pub const Draw = struct { + type: gl.Primitive, + vertex_count: usize, + instance_count: usize = 1, + }; +}; + +attachments: []const Options.Attachment, + +step_number: usize = 0, + +/// Begin a render pass. +pub fn begin( + opts: Options, +) Self { + return .{ + .attachments = opts.attachments, + }; +} + +/// Add a step to this render pass. +/// +/// TODO: Errors are silently ignored in this function, maybe they shouldn't be? +pub fn step(self: *Self, s: Step) void { + if (s.draw.instance_count == 0) return; + + const pbind = s.pipeline.program.use() catch return; + defer pbind.unbind(); + + const vaobind = s.pipeline.vao.bind() catch return; + defer vaobind.unbind(); + + const fbobind = switch (self.attachments[0].target) { + .target => |t| t.framebuffer.bind(.framebuffer) catch return, + .texture => |t| bind: { + const fbobind = s.pipeline.fbo.bind(.framebuffer) catch return; + fbobind.texture2D(.color0, t.target, t.texture, 0) catch { + fbobind.unbind(); + return; + }; + break :bind fbobind; + }, + }; + defer fbobind.unbind(); + + defer self.step_number += 1; + + // If we have a clear color and this is the + // first step in the pass, go ahead and clear. + if (self.step_number == 0) if (self.attachments[0].clear_color) |c| { + gl.clearColor(c[0], c[1], c[2], c[3]); + gl.clear(gl.c.GL_COLOR_BUFFER_BIT); + }; + + // Bind the uniform buffer we bind at index 1 to align with Metal. + if (s.uniforms) |ubo| { + _ = ubo.bindBase(.uniform, 1) catch return; + } + + // Bind relevant texture units. + for (s.textures, 0..) |t, i| if (t) |tex| { + gl.Texture.active(@intCast(i)) catch return; + _ = tex.texture.bind(tex.target) catch return; + }; + + // Bind 0th buffer as the vertex buffer, + // and bind the rest as storage buffers. + if (s.buffers.len > 0) { + if (s.buffers[0]) |vbo| vaobind.bindVertexBuffer( + 0, + vbo.id, + 0, + @intCast(s.pipeline.stride), + ) catch return; + + for (s.buffers[1..], 1..) |b, i| if (b) |buf| { + _ = buf.bindBase(.storage, @intCast(i)) catch return; + }; + } + + if (s.pipeline.blending_enabled) { + gl.enable(gl.c.GL_BLEND) catch return; + gl.blendFunc(gl.c.GL_ONE, gl.c.GL_ONE_MINUS_SRC_ALPHA) catch return; + } else { + gl.disable(gl.c.GL_BLEND) catch return; + } + + gl.drawArraysInstanced( + s.draw.type, + 0, + @intCast(s.draw.vertex_count), + @intCast(s.draw.instance_count), + ) catch return; +} + +/// Complete this render pass. +/// This struct can no longer be used after calling this. +pub fn complete(self: *const Self) void { + _ = self; + gl.flush(); +} diff --git a/src/renderer/opengl/Target.zig b/src/renderer/opengl/Target.zig new file mode 100644 index 000000000..1b3a13ed0 --- /dev/null +++ b/src/renderer/opengl/Target.zig @@ -0,0 +1,62 @@ +//! Represents a render target. +//! +//! In this case, an OpenGL renderbuffer-backed framebuffer. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const builtin = @import("builtin"); +const gl = @import("opengl"); + +const log = std.log.scoped(.opengl); + +/// Options for initializing a Target +pub const Options = struct { + /// Desired width + width: usize, + /// Desired height + height: usize, + + /// Internal format for the renderbuffer. + internal_format: gl.Texture.InternalFormat, +}; + +/// The underlying `gl.Framebuffer` instance. +framebuffer: gl.Framebuffer, + +/// The underlying `gl.Renderbuffer` instance. +renderbuffer: gl.Renderbuffer, + +/// Current width of this target. +width: usize, +/// Current height of this target. +height: usize, + +pub fn init(opts: Options) !Self { + const rbo = try gl.Renderbuffer.create(); + const bound_rbo = try rbo.bind(); + defer bound_rbo.unbind(); + try bound_rbo.storage( + opts.internal_format, + @intCast(opts.width), + @intCast(opts.height), + ); + + const fbo = try gl.Framebuffer.create(); + const bound_fbo = try fbo.bind(.framebuffer); + defer bound_fbo.unbind(); + try bound_fbo.renderbuffer(.color0, rbo); + + return .{ + .framebuffer = fbo, + .renderbuffer = rbo, + .width = opts.width, + .height = opts.height, + }; +} + +pub fn deinit(self: *Self) void { + self.framebuffer.destroy(); + self.renderbuffer.destroy(); +} diff --git a/src/renderer/opengl/Texture.zig b/src/renderer/opengl/Texture.zig new file mode 100644 index 000000000..9be2b7078 --- /dev/null +++ b/src/renderer/opengl/Texture.zig @@ -0,0 +1,102 @@ +//! Wrapper for handling textures. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const builtin = @import("builtin"); +const gl = @import("opengl"); + +const OpenGL = @import("../OpenGL.zig"); + +const log = std.log.scoped(.opengl); + +/// Options for initializing a texture. +pub const Options = struct { + format: gl.Texture.Format, + internal_format: gl.Texture.InternalFormat, + target: gl.Texture.Target, +}; + +texture: gl.Texture, + +/// The width of this texture. +width: usize, +/// The height of this texture. +height: usize, + +/// Format for this texture. +format: gl.Texture.Format, + +/// Target for this texture. +target: gl.Texture.Target, + +pub const Error = error{ + /// An OpenGL API call failed. + OpenGLFailed, +}; + +/// Initialize a texture +pub fn init( + opts: Options, + width: usize, + height: usize, + data: ?[]const u8, +) Error!Self { + const tex = gl.Texture.create() catch return error.OpenGLFailed; + errdefer tex.destroy(); + { + const texbind = tex.bind(opts.target) catch return error.OpenGLFailed; + defer texbind.unbind(); + texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE) catch return error.OpenGLFailed; + texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE) catch return error.OpenGLFailed; + texbind.parameter(.MinFilter, gl.c.GL_LINEAR) catch return error.OpenGLFailed; + texbind.parameter(.MagFilter, gl.c.GL_LINEAR) catch return error.OpenGLFailed; + texbind.image2D( + 0, + opts.internal_format, + @intCast(width), + @intCast(height), + opts.format, + .UnsignedByte, + if (data) |d| @ptrCast(d.ptr) else null, + ) catch return error.OpenGLFailed; + } + + return .{ + .texture = tex, + .width = width, + .height = height, + .format = opts.format, + .target = opts.target, + }; +} + +pub fn deinit(self: Self) void { + self.texture.destroy(); +} + +/// Replace a region of the texture with the provided data. +/// +/// Does NOT check the dimensions of the data to ensure correctness. +pub fn replaceRegion( + self: Self, + x: usize, + y: usize, + width: usize, + height: usize, + data: []const u8, +) Error!void { + const texbind = self.texture.bind(self.target) catch return error.OpenGLFailed; + defer texbind.unbind(); + texbind.subImage2D( + 0, + @intCast(x), + @intCast(y), + @intCast(width), + @intCast(height), + self.format, + .UnsignedByte, + data.ptr, + ) catch return error.OpenGLFailed; +} diff --git a/src/renderer/opengl/buffer.zig b/src/renderer/opengl/buffer.zig new file mode 100644 index 000000000..48b6f410e --- /dev/null +++ b/src/renderer/opengl/buffer.zig @@ -0,0 +1,127 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const gl = @import("opengl"); + +const OpenGL = @import("../OpenGL.zig"); + +const log = std.log.scoped(.opengl); + +/// Options for initializing a buffer. +pub const Options = struct { + target: gl.Buffer.Target = .array, + usage: gl.Buffer.Usage = .dynamic_draw, +}; + +/// OpenGL data storage for a certain set of equal types. This is usually +/// used for vertex buffers, etc. This helpful wrapper makes it easy to +/// prealloc, shrink, grow, sync, buffers with OpenGL. +pub fn Buffer(comptime T: type) type { + return struct { + const Self = @This(); + + /// Underlying `gl.Buffer` instance. + buffer: gl.Buffer, + + /// Options this buffer was allocated with. + opts: Options, + + /// Current allocated length of the data store. + /// Note this is the number of `T`s, not the size in bytes. + len: usize, + + /// Initialize a buffer with the given length pre-allocated. + pub fn init(opts: Options, len: usize) !Self { + const buffer = try gl.Buffer.create(); + errdefer buffer.destroy(); + + const binding = try buffer.bind(opts.target); + defer binding.unbind(); + + try binding.setDataNullManual(len * @sizeOf(T), opts.usage); + + return .{ + .buffer = buffer, + .opts = opts, + .len = len, + }; + } + + /// Init the buffer filled with the given data. + pub fn initFill(opts: Options, data: []const T) !Self { + const buffer = try gl.Buffer.create(); + errdefer buffer.destroy(); + + const binding = try buffer.bind(opts.target); + defer binding.unbind(); + + try binding.setData(data, opts.usage); + + return .{ + .buffer = buffer, + .opts = opts, + .len = data.len * @sizeOf(T), + }; + } + + pub fn deinit(self: Self) void { + self.buffer.destroy(); + } + + /// Sync new contents to the buffer. The data is expected to be the + /// complete contents of the buffer. If the amount of data is larger + /// than the buffer length, the buffer will be reallocated. + /// + /// If the amount of data is smaller than the buffer length, the + /// remaining data in the buffer is left untouched. + pub fn sync(self: *Self, data: []const T) !void { + const binding = try self.buffer.bind(self.opts.target); + defer binding.unbind(); + + // If we need more space than our buffer has, we need to reallocate. + if (data.len > self.len) { + // Reallocate the buffer to hold double what we require. + self.len = data.len * 2; + try binding.setDataNullManual( + self.len * @sizeOf(T), + self.opts.usage, + ); + } + + // We can fit within the buffer so we can just replace bytes. + try binding.setSubData(0, data); + } + + /// Like Buffer.sync but takes data from an array of ArrayLists, + /// rather than a single array. Returns the number of items synced. + pub fn syncFromArrayLists(self: *Self, lists: []const std.ArrayListUnmanaged(T)) !usize { + const binding = try self.buffer.bind(self.opts.target); + defer binding.unbind(); + + var total_len: usize = 0; + for (lists) |list| { + total_len += list.items.len; + } + + // If we need more space than our buffer has, we need to reallocate. + if (total_len > self.len) { + // Reallocate the buffer to hold double what we require. + self.len = total_len * 2; + try binding.setDataNullManual( + self.len * @sizeOf(T), + self.opts.usage, + ); + } + + // We can fit within the buffer so we can just replace bytes. + var i: usize = 0; + + for (lists) |list| { + try binding.setSubData(i, list.items); + i += list.items.len * @sizeOf(T); + } + + return total_len; + } + }; +} diff --git a/src/renderer/opengl/custom.zig b/src/renderer/opengl/custom.zig deleted file mode 100644 index 859277ce5..000000000 --- a/src/renderer/opengl/custom.zig +++ /dev/null @@ -1,310 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const gl = @import("opengl"); -const Size = @import("../size.zig").Size; - -const log = std.log.scoped(.opengl_custom); - -/// The "INDEX" is the index into the global GL state and the -/// "BINDING" is the binding location in the shader. -const UNIFORM_INDEX: gl.c.GLuint = 0; -const UNIFORM_BINDING: gl.c.GLuint = 0; - -/// Global uniforms for custom shaders. -pub const Uniforms = extern struct { - resolution: [3]f32 align(16) = .{ 0, 0, 0 }, - time: f32 align(4) = 1, - time_delta: f32 align(4) = 1, - frame_rate: f32 align(4) = 1, - frame: i32 align(4) = 1, - channel_time: [4][4]f32 align(16) = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4, - channel_resolution: [4][4]f32 align(16) = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4, - mouse: [4]f32 align(16) = .{ 0, 0, 0, 0 }, - date: [4]f32 align(16) = .{ 0, 0, 0, 0 }, - sample_rate: f32 align(4) = 1, -}; - -/// The state associated with custom shaders. This should only be initialized -/// if there is at least one custom shader. -/// -/// To use this, the main terminal shader should render to the framebuffer -/// specified by "fbo". The resulting "fb_texture" will contain the color -/// attachment. This is then used as the iChannel0 input to the custom -/// shader. -pub const State = struct { - /// The uniform data - uniforms: Uniforms, - - /// The OpenGL buffers - fbo: gl.Framebuffer, - ubo: gl.Buffer, - vao: gl.VertexArray, - ebo: gl.Buffer, - fb_texture: gl.Texture, - - /// The set of programs for the custom shaders. - programs: []const Program, - - /// The first time a frame was drawn. This is used to update - /// the time uniform. - first_frame_time: std.time.Instant, - - /// The last time a frame was drawn. This is used to update - /// the time uniform. - last_frame_time: std.time.Instant, - - pub fn init( - alloc: Allocator, - srcs: []const [:0]const u8, - ) !State { - if (srcs.len == 0) return error.OneCustomShaderRequired; - - // Create our programs - var programs = std.ArrayList(Program).init(alloc); - defer programs.deinit(); - errdefer for (programs.items) |p| p.deinit(); - for (srcs) |src| { - try programs.append(try Program.init(src)); - } - - // Create the texture for the framebuffer - const fb_tex = try gl.Texture.create(); - errdefer fb_tex.destroy(); - { - const texbind = try fb_tex.bind(.@"2D"); - try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.MinFilter, gl.c.GL_LINEAR); - try texbind.parameter(.MagFilter, gl.c.GL_LINEAR); - try texbind.image2D( - 0, - .rgb, - 1, - 1, - 0, - .rgb, - .UnsignedByte, - null, - ); - } - - // Create our framebuffer for rendering off screen. - // The shader prior to custom shaders should use this - // framebuffer. - const fbo = try gl.Framebuffer.create(); - errdefer fbo.destroy(); - const fbbind = try fbo.bind(.framebuffer); - defer fbbind.unbind(); - try fbbind.texture2D(.color0, .@"2D", fb_tex, 0); - const fbstatus = fbbind.checkStatus(); - if (fbstatus != .complete) { - log.warn( - "framebuffer is not complete state={}", - .{fbstatus}, - ); - return error.InvalidFramebuffer; - } - - // Create our uniform buffer that is shared across all - // custom shaders - const ubo = try gl.Buffer.create(); - errdefer ubo.destroy(); - { - var ubobind = try ubo.bind(.uniform); - defer ubobind.unbind(); - try ubobind.setDataNull(Uniforms, .static_draw); - } - - // Setup our VAO for the custom shader. - const vao = try gl.VertexArray.create(); - errdefer vao.destroy(); - const vaobind = try vao.bind(); - defer vaobind.unbind(); - - // Element buffer (EBO) - const ebo = try gl.Buffer.create(); - errdefer ebo.destroy(); - var ebobind = try ebo.bind(.element_array); - defer ebobind.unbind(); - try ebobind.setData([6]u8{ - 0, 1, 3, // Top-left triangle - 1, 2, 3, // Bottom-right triangle - }, .static_draw); - - return .{ - .programs = try programs.toOwnedSlice(), - .uniforms = .{}, - .fbo = fbo, - .ubo = ubo, - .vao = vao, - .ebo = ebo, - .fb_texture = fb_tex, - .first_frame_time = try std.time.Instant.now(), - .last_frame_time = try std.time.Instant.now(), - }; - } - - pub fn deinit(self: *const State, alloc: Allocator) void { - for (self.programs) |p| p.deinit(); - alloc.free(self.programs); - self.ubo.destroy(); - self.ebo.destroy(); - self.vao.destroy(); - self.fb_texture.destroy(); - self.fbo.destroy(); - } - - pub fn setScreenSize(self: *State, size: Size) !void { - // Update our uniforms - self.uniforms.resolution = .{ - @floatFromInt(size.screen.width), - @floatFromInt(size.screen.height), - 1, - }; - try self.syncUniforms(); - - // Update our texture - const texbind = try self.fb_texture.bind(.@"2D"); - try texbind.image2D( - 0, - .rgb, - @intCast(size.screen.width), - @intCast(size.screen.height), - 0, - .rgb, - .UnsignedByte, - null, - ); - } - - /// Call this prior to drawing a frame to update the time - /// and synchronize the uniforms. This synchronizes uniforms - /// so you should make changes to uniforms prior to calling - /// this. - pub fn newFrame(self: *State) !void { - // Update our frame time - const now = std.time.Instant.now() catch self.first_frame_time; - const since_ns: f32 = @floatFromInt(now.since(self.first_frame_time)); - const delta_ns: f32 = @floatFromInt(now.since(self.last_frame_time)); - self.uniforms.time = since_ns / std.time.ns_per_s; - self.uniforms.time_delta = delta_ns / std.time.ns_per_s; - self.last_frame_time = now; - - // Sync our uniform changes - try self.syncUniforms(); - } - - fn syncUniforms(self: *State) !void { - var ubobind = try self.ubo.bind(.uniform); - defer ubobind.unbind(); - try ubobind.setData(self.uniforms, .static_draw); - } - - /// Call this to bind all the necessary OpenGL resources for - /// all custom shaders. Each individual shader needs to be bound - /// one at a time too. - pub fn bind(self: *const State) !Binding { - // Move our uniform buffer into proper global index. Note that - // in theory we can do this globally once and never worry about - // it again. I don't think we're high-performance enough at all - // to worry about that and this makes it so you can just move - // around CustomProgram usage without worrying about clobbering - // the global state. - try self.ubo.bindBase(.uniform, UNIFORM_INDEX); - - // Bind our texture that is shared amongst all - try gl.Texture.active(gl.c.GL_TEXTURE0); - var texbind = try self.fb_texture.bind(.@"2D"); - errdefer texbind.unbind(); - - const vao = try self.vao.bind(); - errdefer vao.unbind(); - - const ebo = try self.ebo.bind(.element_array); - errdefer ebo.unbind(); - - return .{ - .vao = vao, - .ebo = ebo, - .fb_texture = texbind, - }; - } - - /// Copy the fbo's attached texture to the backbuffer. - pub fn copyFramebuffer(self: *State) !void { - const texbind = try self.fb_texture.bind(.@"2D"); - errdefer texbind.unbind(); - try texbind.copySubImage2D( - 0, - 0, - 0, - 0, - 0, - @intFromFloat(self.uniforms.resolution[0]), - @intFromFloat(self.uniforms.resolution[1]), - ); - } - - pub const Binding = struct { - vao: gl.VertexArray.Binding, - ebo: gl.Buffer.Binding, - fb_texture: gl.Texture.Binding, - - pub fn unbind(self: Binding) void { - self.ebo.unbind(); - self.vao.unbind(); - self.fb_texture.unbind(); - } - }; -}; - -/// A single OpenGL program (combined shaders) for custom shaders. -pub const Program = struct { - program: gl.Program, - - pub fn init(src: [:0]const u8) !Program { - const program = try gl.Program.createVF( - @embedFile("../shaders/custom.v.glsl"), - src, - ); - errdefer program.destroy(); - - // Map our uniform buffer to the global GL state - try program.uniformBlockBinding(UNIFORM_INDEX, UNIFORM_BINDING); - - return .{ .program = program }; - } - - pub fn deinit(self: *const Program) void { - self.program.destroy(); - } - - /// Bind the program for use. This should be called so that draw can - /// be called. - pub fn bind(self: *const Program) !Binding { - const program = try self.program.use(); - errdefer program.unbind(); - - return .{ - .program = program, - }; - } - - pub const Binding = struct { - program: gl.Program.Binding, - - pub fn unbind(self: Binding) void { - self.program.unbind(); - } - - pub fn draw(self: Binding) !void { - _ = self; - try gl.drawElementsInstanced( - gl.c.GL_TRIANGLES, - 6, - gl.c.GL_UNSIGNED_BYTE, - 1, - ); - } - }; -}; diff --git a/src/renderer/opengl/image.zig b/src/renderer/opengl/image.zig deleted file mode 100644 index 26cd90736..000000000 --- a/src/renderer/opengl/image.zig +++ /dev/null @@ -1,426 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const gl = @import("opengl"); -const wuffs = @import("wuffs"); - -/// Represents a single image placement on the grid. A placement is a -/// request to render an instance of an image. -pub const Placement = struct { - /// The image being rendered. This MUST be in the image map. - image_id: u32, - - /// The grid x/y where this placement is located. - x: i32, - y: i32, - z: i32, - - /// The width/height of the placed image. - width: u32, - height: u32, - - /// The offset in pixels from the top left of the cell. This is - /// clamped to the size of a cell. - cell_offset_x: u32, - cell_offset_y: u32, - - /// The source rectangle of the placement. - source_x: u32, - source_y: u32, - source_width: u32, - source_height: u32, -}; - -/// The map used for storing images. -pub const ImageMap = std.AutoHashMapUnmanaged(u32, struct { - image: Image, - transmit_time: std.time.Instant, -}); - -/// The state for a single image that is to be rendered. The image can be -/// pending upload or ready to use with a texture. -pub const Image = union(enum) { - /// The image is pending upload to the GPU. The different keys are - /// different formats since some formats aren't accepted by the GPU - /// and require conversion. - /// - /// This data is owned by this union so it must be freed once the - /// image is uploaded. - pending_gray: Pending, - pending_gray_alpha: Pending, - pending_rgb: Pending, - pending_rgba: Pending, - - /// This is the same as the pending states but there is a texture - /// already allocated that we want to replace. - replace_gray: Replace, - replace_gray_alpha: Replace, - replace_rgb: Replace, - replace_rgba: Replace, - - /// The image is uploaded and ready to be used. - ready: gl.Texture, - - /// The image is uploaded but is scheduled to be unloaded. - unload_pending: []u8, - unload_ready: gl.Texture, - unload_replace: struct { []u8, gl.Texture }, - - pub const Replace = struct { - texture: gl.Texture, - pending: Pending, - }; - - /// Pending image data that needs to be uploaded to the GPU. - pub const Pending = struct { - height: u32, - width: u32, - - /// Data is always expected to be (width * height * depth). Depth - /// is based on the union key. - data: [*]u8, - - pub fn dataSlice(self: Pending, d: u32) []u8 { - return self.data[0..self.len(d)]; - } - - pub fn len(self: Pending, d: u32) u32 { - return self.width * self.height * d; - } - }; - - pub fn deinit(self: Image, alloc: Allocator) void { - switch (self) { - .pending_gray => |p| alloc.free(p.dataSlice(1)), - .pending_gray_alpha => |p| alloc.free(p.dataSlice(2)), - .pending_rgb => |p| alloc.free(p.dataSlice(3)), - .pending_rgba => |p| alloc.free(p.dataSlice(4)), - .unload_pending => |data| alloc.free(data), - - .replace_gray => |r| { - alloc.free(r.pending.dataSlice(1)); - r.texture.destroy(); - }, - - .replace_gray_alpha => |r| { - alloc.free(r.pending.dataSlice(2)); - r.texture.destroy(); - }, - - .replace_rgb => |r| { - alloc.free(r.pending.dataSlice(3)); - r.texture.destroy(); - }, - - .replace_rgba => |r| { - alloc.free(r.pending.dataSlice(4)); - r.texture.destroy(); - }, - - .unload_replace => |r| { - alloc.free(r[0]); - r[1].destroy(); - }, - - .ready, - .unload_ready, - => |tex| tex.destroy(), - } - } - - /// Mark this image for unload whatever state it is in. - pub fn markForUnload(self: *Image) void { - self.* = switch (self.*) { - .unload_pending, - .unload_replace, - .unload_ready, - => return, - - .ready => |obj| .{ .unload_ready = obj }, - .pending_gray => |p| .{ .unload_pending = p.dataSlice(1) }, - .pending_gray_alpha => |p| .{ .unload_pending = p.dataSlice(2) }, - .pending_rgb => |p| .{ .unload_pending = p.dataSlice(3) }, - .pending_rgba => |p| .{ .unload_pending = p.dataSlice(4) }, - .replace_gray => |r| .{ .unload_replace = .{ - r.pending.dataSlice(1), r.texture, - } }, - .replace_gray_alpha => |r| .{ .unload_replace = .{ - r.pending.dataSlice(2), r.texture, - } }, - .replace_rgb => |r| .{ .unload_replace = .{ - r.pending.dataSlice(3), r.texture, - } }, - .replace_rgba => |r| .{ .unload_replace = .{ - r.pending.dataSlice(4), r.texture, - } }, - }; - } - - /// Replace the currently pending image with a new one. This will - /// attempt to update the existing texture if it is already allocated. - /// If the texture is not allocated, this will act like a new upload. - /// - /// This function only marks the image for replace. The actual logic - /// to replace is done later. - pub fn markForReplace(self: *Image, alloc: Allocator, img: Image) !void { - assert(img.pending() != null); - - // Get our existing texture. This switch statement will also handle - // scenarios where there is no existing texture and we can modify - // the self pointer directly. - const existing: gl.Texture = switch (self.*) { - // For pending, we can free the old data and become pending ourselves. - .pending_gray => |p| { - alloc.free(p.dataSlice(1)); - self.* = img; - return; - }, - - .pending_gray_alpha => |p| { - alloc.free(p.dataSlice(2)); - self.* = img; - return; - }, - - .pending_rgb => |p| { - alloc.free(p.dataSlice(3)); - self.* = img; - return; - }, - - .pending_rgba => |p| { - alloc.free(p.dataSlice(4)); - self.* = img; - return; - }, - - // If we're marked for unload but we just have pending data, - // this behaves the same as a normal "pending": free the data, - // become new pending. - .unload_pending => |data| { - alloc.free(data); - self.* = img; - return; - }, - - .unload_replace => |r| existing: { - alloc.free(r[0]); - break :existing r[1]; - }, - - // If we were already pending a replacement, then we free our - // existing pending data and use the same texture. - .replace_gray => |r| existing: { - alloc.free(r.pending.dataSlice(1)); - break :existing r.texture; - }, - - .replace_gray_alpha => |r| existing: { - alloc.free(r.pending.dataSlice(2)); - break :existing r.texture; - }, - - .replace_rgb => |r| existing: { - alloc.free(r.pending.dataSlice(3)); - break :existing r.texture; - }, - - .replace_rgba => |r| existing: { - alloc.free(r.pending.dataSlice(4)); - break :existing r.texture; - }, - - // For both ready and unload_ready, we need to replace the - // texture. We can't do that here, so we just mark ourselves - // for replacement. - .ready, .unload_ready => |tex| tex, - }; - - // We now have an existing texture, so set the proper replace key. - self.* = switch (img) { - .pending_gray => |p| .{ .replace_gray = .{ - .texture = existing, - .pending = p, - } }, - - .pending_gray_alpha => |p| .{ .replace_gray_alpha = .{ - .texture = existing, - .pending = p, - } }, - - .pending_rgb => |p| .{ .replace_rgb = .{ - .texture = existing, - .pending = p, - } }, - - .pending_rgba => |p| .{ .replace_rgba = .{ - .texture = existing, - .pending = p, - } }, - - else => unreachable, - }; - } - - /// Returns true if this image is pending upload. - pub fn isPending(self: Image) bool { - return self.pending() != null; - } - - /// Returns true if this image is pending an unload. - pub fn isUnloading(self: Image) bool { - return switch (self) { - .unload_pending, - .unload_ready, - => true, - - .ready, - .pending_gray, - .pending_gray_alpha, - .pending_rgb, - .pending_rgba, - => false, - }; - } - - /// Converts the image data to a format that can be uploaded to the GPU. - /// If the data is already in a format that can be uploaded, this is a - /// no-op. - pub fn convert(self: *Image, alloc: Allocator) !void { - switch (self.*) { - .ready, - .unload_pending, - .unload_replace, - .unload_ready, - => unreachable, // invalid - - .pending_rgba, - .replace_rgba, - => {}, // ready - - // RGB needs to be converted to RGBA because Metal textures - // don't support RGB. - .pending_rgb => |*p| { - const data = p.dataSlice(3); - const rgba = try wuffs.swizzle.rgbToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_rgb => |*r| { - const data = r.pending.dataSlice(3); - const rgba = try wuffs.swizzle.rgbToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - - // Gray and Gray+Alpha need to be converted to RGBA, too. - .pending_gray => |*p| { - const data = p.dataSlice(1); - const rgba = try wuffs.swizzle.gToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_gray => |*r| { - const data = r.pending.dataSlice(2); - const rgba = try wuffs.swizzle.gToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - - .pending_gray_alpha => |*p| { - const data = p.dataSlice(2); - const rgba = try wuffs.swizzle.gaToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_gray_alpha => |*r| { - const data = r.pending.dataSlice(2); - const rgba = try wuffs.swizzle.gaToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - } - } - - /// Upload the pending image to the GPU and change the state of this - /// image to ready. - pub fn upload( - self: *Image, - alloc: Allocator, - ) !void { - // Convert our data if we have to - try self.convert(alloc); - - // Get our pending info - const p = self.pending().?; - - // Get our format - const formats: struct { - internal: gl.Texture.InternalFormat, - format: gl.Texture.Format, - } = switch (self.*) { - .pending_rgb, .replace_rgb => .{ .internal = .srgb, .format = .rgb }, - .pending_rgba, .replace_rgba => .{ .internal = .srgba, .format = .rgba }, - else => unreachable, - }; - - // Create our texture - const tex = try gl.Texture.create(); - errdefer tex.destroy(); - - const texbind = try tex.bind(.@"2D"); - try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.MinFilter, gl.c.GL_LINEAR); - try texbind.parameter(.MagFilter, gl.c.GL_LINEAR); - try texbind.image2D( - 0, - formats.internal, - @intCast(p.width), - @intCast(p.height), - 0, - formats.format, - .UnsignedByte, - p.data, - ); - - // Uploaded. We can now clear our data and change our state. - self.deinit(alloc); - self.* = .{ .ready = tex }; - } - - /// Our pixel depth - fn depth(self: Image) u32 { - return switch (self) { - .pending_rgb => 3, - .pending_rgba => 4, - .replace_rgb => 3, - .replace_rgba => 4, - else => unreachable, - }; - } - - /// Returns true if this image is in a pending state and requires upload. - fn pending(self: Image) ?Pending { - return switch (self) { - .pending_rgb, - .pending_rgba, - => |p| p, - - .replace_rgb, - .replace_rgba, - => |r| r.pending, - - else => null, - }; - } -}; diff --git a/src/renderer/opengl/shaders.zig b/src/renderer/opengl/shaders.zig new file mode 100644 index 000000000..0b67eaff0 --- /dev/null +++ b/src/renderer/opengl/shaders.zig @@ -0,0 +1,375 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const math = @import("../../math.zig"); + +const Pipeline = @import("Pipeline.zig"); + +const log = std.log.scoped(.opengl); + +const pipeline_descs: []const struct { [:0]const u8, PipelineDescription } = + &.{ + .{ "bg_color", .{ + .vertex_fn = loadShaderCode("../shaders/glsl/full_screen.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/bg_color.f.glsl"), + .blending_enabled = false, + } }, + .{ "cell_bg", .{ + .vertex_fn = loadShaderCode("../shaders/glsl/full_screen.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/cell_bg.f.glsl"), + .blending_enabled = true, + } }, + .{ "cell_text", .{ + .vertex_attributes = CellText, + .vertex_fn = loadShaderCode("../shaders/glsl/cell_text.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/cell_text.f.glsl"), + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "image", .{ + .vertex_attributes = Image, + .vertex_fn = loadShaderCode("../shaders/glsl/image.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/image.f.glsl"), + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "bg_image", .{ + .vertex_attributes = BgImage, + .vertex_fn = loadShaderCode("../shaders/glsl/bg_image.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/bg_image.f.glsl"), + .step_fn = .per_instance, + .blending_enabled = true, + } }, + }; + +/// All the comptime-known info about a pipeline, so that +/// we can define them ahead-of-time in an ergonomic way. +const PipelineDescription = struct { + vertex_attributes: ?type = null, + vertex_fn: [:0]const u8, + fragment_fn: [:0]const u8, + step_fn: Pipeline.Options.StepFunction = .per_vertex, + blending_enabled: bool = true, + + fn initPipeline(self: PipelineDescription) !Pipeline { + return try .init(self.vertex_attributes, .{ + .vertex_fn = self.vertex_fn, + .fragment_fn = self.fragment_fn, + .step_fn = self.step_fn, + .blending_enabled = self.blending_enabled, + }); + } +}; + +/// We create a type for the pipeline collection based on our desc array. +const PipelineCollection = t: { + var fields: [pipeline_descs.len]std.builtin.Type.StructField = undefined; + for (pipeline_descs, 0..) |pipeline, i| { + fields[i] = .{ + .name = pipeline[0], + .type = Pipeline, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(Pipeline), + }; + } + break :t @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +}; + +/// This contains the state for the shaders used by the Metal renderer. +pub const Shaders = struct { + /// Collection of available render pipelines. + pipelines: PipelineCollection, + + /// Custom shaders to run against the final drawable texture. This + /// can be used to apply a lot of effects. Each shader is run in sequence + /// against the output of the previous shader. + post_pipelines: []const Pipeline, + + /// Set to true when deinited, if you try to deinit a defunct set + /// of shaders it will just be ignored, to prevent double-free. + defunct: bool = false, + + /// Initialize our shader set. + /// + /// "post_shaders" is an optional list of postprocess shaders to run + /// against the final drawable texture. This is an array of shader source + /// code, not file paths. + pub fn init( + alloc: Allocator, + post_shaders: []const [:0]const u8, + ) !Shaders { + var pipelines: PipelineCollection = undefined; + + var initialized_pipelines: usize = 0; + + errdefer inline for (pipeline_descs, 0..) |pipeline, i| { + if (i < initialized_pipelines) { + @field(pipelines, pipeline[0]).deinit(); + } + }; + + inline for (pipeline_descs) |pipeline| { + @field(pipelines, pipeline[0]) = try pipeline[1].initPipeline(); + initialized_pipelines += 1; + } + + const post_pipelines: []const Pipeline = initPostPipelines( + alloc, + post_shaders, + ) catch |err| err: { + // If an error happens while building postprocess shaders we + // want to just not use any postprocess shaders since we don't + // want to block Ghostty from working. + log.warn("error initializing postprocess shaders err={}", .{err}); + break :err &.{}; + }; + errdefer if (post_pipelines.len > 0) { + for (post_pipelines) |pipeline| pipeline.deinit(); + alloc.free(post_pipelines); + }; + + return .{ + .pipelines = pipelines, + .post_pipelines = post_pipelines, + }; + } + + pub fn deinit(self: *Shaders, alloc: Allocator) void { + if (self.defunct) return; + self.defunct = true; + + // Release our primary shaders + inline for (pipeline_descs) |pipeline| { + @field(self.pipelines, pipeline[0]).deinit(); + } + + // Release our postprocess shaders + if (self.post_pipelines.len > 0) { + for (self.post_pipelines) |pipeline| { + pipeline.deinit(); + } + alloc.free(self.post_pipelines); + } + } +}; + +/// The uniforms that are passed to our shaders. +pub const Uniforms = extern struct { + /// The projection matrix for turning world coordinates to normalized. + /// This is calculated based on the size of the screen. + projection_matrix: math.Mat align(16), + + /// Size of the screen (render target) in pixels. + screen_size: [2]f32 align(8), + + /// Size of a single cell in pixels, unscaled. + cell_size: [2]f32 align(8), + + /// Size of the grid in columns and rows. + grid_size: [2]u16 align(4), + + /// The padding around the terminal grid in pixels. In order: + /// top, right, bottom, left. + grid_padding: [4]f32 align(16), + + /// Bit mask defining which directions to + /// extend cell colors in to the padding. + /// Order, LSB first: left, right, up, down + padding_extend: PaddingExtend align(4), + + /// The minimum contrast ratio for text. The contrast ratio is calculated + /// according to the WCAG 2.0 spec. + min_contrast: f32 align(4), + + /// The cursor position and color. + cursor_pos: [2]u16 align(4), + cursor_color: [4]u8 align(4), + + /// The background color for the whole surface. + bg_color: [4]u8 align(4), + + /// Various booleans, in a packed struct for space efficiency. + bools: Bools align(4), + + const Bools = packed struct(u32) { + /// Whether the cursor is 2 cells wide. + cursor_wide: bool, + + /// Indicates that colors provided to the shader are already in + /// the P3 color space, so they don't need to be converted from + /// sRGB. + use_display_p3: bool, + + /// Indicates that the color attachments for the shaders have + /// an `*_srgb` pixel format, which means the shaders need to + /// output linear RGB colors rather than gamma encoded colors, + /// since blending will be performed in linear space and then + /// Metal itself will re-encode the colors for storage. + use_linear_blending: bool, + + /// Enables a weight correction step that makes text rendered + /// with linear alpha blending have a similar apparent weight + /// (thickness) to gamma-incorrect blending. + use_linear_correction: bool = false, + + _padding: u28 = 0, + }; + + const PaddingExtend = packed struct(u32) { + left: bool = false, + right: bool = false, + up: bool = false, + down: bool = false, + _padding: u28 = 0, + }; +}; + +/// This is a single parameter for the terminal cell shader. +pub const CellText = extern struct { + glyph_pos: [2]u32 align(8) = .{ 0, 0 }, + glyph_size: [2]u32 align(8) = .{ 0, 0 }, + bearings: [2]i16 align(4) = .{ 0, 0 }, + grid_pos: [2]u16 align(4), + color: [4]u8 align(4), + mode: Mode align(4), + constraint_width: u32 align(4) = 0, + + pub const Mode = enum(u32) { + fg = 1, + fg_constrained = 2, + fg_color = 3, + cursor = 4, + fg_powerline = 5, + }; + + // test { + // // Minimizing the size of this struct is important, + // // so we test it in order to be aware of any changes. + // try std.testing.expectEqual(32, @sizeOf(CellText)); + // } +}; + +/// This is a single parameter for the cell bg shader. +pub const CellBg = [4]u8; + +/// Single parameter for the image shader. See shader for field details. +pub const Image = extern struct { + grid_pos: [2]f32 align(8), + cell_offset: [2]f32 align(8), + source_rect: [4]f32 align(16), + dest_size: [2]f32 align(8), +}; + +/// Single parameter for the bg image shader. +pub const BgImage = extern struct { + opacity: f32 align(4), + info: Info align(1), + + pub const Info = packed struct(u8) { + position: Position, + fit: Fit, + repeat: bool, + _padding: u1 = 0, + + pub const Position = enum(u4) { + tl = 0, + tc = 1, + tr = 2, + ml = 3, + mc = 4, + mr = 5, + bl = 6, + bc = 7, + br = 8, + }; + + pub const Fit = enum(u2) { + contain = 0, + cover = 1, + stretch = 2, + none = 3, + }; + }; +}; + +/// Initialize our custom shader pipelines. The shaders argument is a +/// set of shader source code, not file paths. +fn initPostPipelines( + alloc: Allocator, + shaders: []const [:0]const u8, +) ![]const Pipeline { + // If we have no shaders, do nothing. + if (shaders.len == 0) return &.{}; + + // Keeps track of how many shaders we successfully wrote. + var i: usize = 0; + + // Initialize our result set. If any error happens, we undo everything. + var pipelines = try alloc.alloc(Pipeline, shaders.len); + errdefer { + for (pipelines[0..i]) |pipeline| { + pipeline.deinit(); + } + alloc.free(pipelines); + } + + // Build each shader. Note we don't use "0.." to build our index + // because we need to keep track of our length to clean up above. + for (shaders) |source| { + pipelines[i] = try initPostPipeline(source); + i += 1; + } + + return pipelines; +} + +/// Initialize a single custom shader pipeline from shader source. +fn initPostPipeline(data: [:0]const u8) !Pipeline { + return try Pipeline.init(null, .{ + .vertex_fn = loadShaderCode("../shaders/glsl/full_screen.v.glsl"), + .fragment_fn = data, + }); +} + +/// Load shader code from the target path, processing `#include` directives. +/// +/// Comptime only for now, this code is really sloppy and makes a bunch of +/// assumptions about things being well formed and file names not containing +/// quote marks. If we ever want to process `#include`s for custom shaders +/// then we need to write something better than this for it. +fn loadShaderCode(comptime path: []const u8) [:0]const u8 { + return comptime processIncludes(@embedFile(path), std.fs.path.dirname(path).?); +} + +/// Used by loadShaderCode +fn processIncludes(contents: [:0]const u8, basedir: []const u8) [:0]const u8 { + @setEvalBranchQuota(100_000); + var i: usize = 0; + while (i < contents.len) { + if (std.mem.startsWith(u8, contents[i..], "#include")) { + assert(std.mem.startsWith(u8, contents[i..], "#include \"")); + const start = i + "#include \"".len; + const end = std.mem.indexOfScalarPos(u8, contents, start, '"').?; + return std.fmt.comptimePrint( + "{s}{s}{s}", + .{ + contents[0..i], + @embedFile(basedir ++ "/" ++ contents[start..end]), + processIncludes(contents[end + 1 ..], basedir), + }, + ); + } + if (std.mem.indexOfPos(u8, contents, i, "\n#")) |j| { + i = (j + 1); + } else { + break; + } + } + return contents; +} diff --git a/src/renderer/shaders/cell.f.glsl b/src/renderer/shaders/cell.f.glsl deleted file mode 100644 index f9c1ce2b1..000000000 --- a/src/renderer/shaders/cell.f.glsl +++ /dev/null @@ -1,53 +0,0 @@ -#version 330 core - -in vec2 glyph_tex_coords; -flat in uint mode; - -// The color for this cell. If this is a background pass this is the -// background color. Otherwise, this is the foreground color. -flat in vec4 color; - -// The position of the cells top-left corner. -flat in vec2 screen_cell_pos; - -// Position the fragment coordinate to the upper left -layout(origin_upper_left) in vec4 gl_FragCoord; - -// Must declare this output for some versions of OpenGL. -layout(location = 0) out vec4 out_FragColor; - -// Font texture -uniform sampler2D text; -uniform sampler2D text_color; - -// Dimensions of the cell -uniform vec2 cell_size; - -// See vertex shader -const uint MODE_BG = 1u; -const uint MODE_FG = 2u; -const uint MODE_FG_CONSTRAINED = 3u; -const uint MODE_FG_COLOR = 7u; -const uint MODE_FG_POWERLINE = 15u; - -void main() { - float a; - - switch (mode) { - case MODE_BG: - out_FragColor = color; - break; - - case MODE_FG: - case MODE_FG_CONSTRAINED: - case MODE_FG_POWERLINE: - a = texture(text, glyph_tex_coords).r; - vec3 premult = color.rgb * color.a; - out_FragColor = vec4(premult.rgb*a, a); - break; - - case MODE_FG_COLOR: - out_FragColor = texture(text_color, glyph_tex_coords); - break; - } -} diff --git a/src/renderer/shaders/cell.v.glsl b/src/renderer/shaders/cell.v.glsl deleted file mode 100644 index f37e69adc..000000000 --- a/src/renderer/shaders/cell.v.glsl +++ /dev/null @@ -1,258 +0,0 @@ -#version 330 core - -// These are the possible modes that "mode" can be set to. This is -// used to multiplex multiple render modes into a single shader. -// -// NOTE: this must be kept in sync with the fragment shader -const uint MODE_BG = 1u; -const uint MODE_FG = 2u; -const uint MODE_FG_CONSTRAINED = 3u; -const uint MODE_FG_COLOR = 7u; -const uint MODE_FG_POWERLINE = 15u; - -// The grid coordinates (x, y) where x < columns and y < rows -layout (location = 0) in vec2 grid_coord; - -// Position of the glyph in the texture. -layout (location = 1) in vec2 glyph_pos; - -// Width/height of the glyph -layout (location = 2) in vec2 glyph_size; - -// Offset of the top-left corner of the glyph when rendered in a rect. -layout (location = 3) in vec2 glyph_offset; - -// The color for this cell in RGBA (0 to 1.0). Background or foreground -// depends on mode. -layout (location = 4) in vec4 color_in; - -// Only set for MODE_FG, this is the background color of the FG text. -// This is used to detect minimal contrast for the text. -layout (location = 5) in vec4 bg_color_in; - -// The mode of this shader. The mode determines what fields are used, -// what the output will be, etc. This shader is capable of executing in -// multiple "modes" so that we can share some logic and so that we can draw -// the entire terminal grid in a single GPU pass. -layout (location = 6) in uint mode_in; - -// The width in cells of this item. -layout (location = 7) in uint grid_width; - -// The background or foreground color for the fragment, depending on -// whether this is a background or foreground pass. -flat out vec4 color; - -// The x/y coordinate for the glyph representing the font. -out vec2 glyph_tex_coords; - -// The position of the cell top-left corner in screen cords. z and w -// are width and height. -flat out vec2 screen_cell_pos; - -// Pass the mode forward to the fragment shader. -flat out uint mode; - -uniform sampler2D text; -uniform sampler2D text_color; -uniform vec2 cell_size; -uniform vec2 grid_size; -uniform vec4 grid_padding; -uniform bool padding_vertical_top; -uniform bool padding_vertical_bottom; -uniform mat4 projection; -uniform float min_contrast; - -/******************************************************************** - * Modes - * - *------------------------------------------------------------------- - * MODE_BG - * - * In MODE_BG, this shader renders only the background color for the - * cell. This is a simple mode where we generate a simple rectangle - * made up of 4 vertices and then it is filled. In this mode, the output - * "color" is the fill color for the bg. - * - *------------------------------------------------------------------- - * MODE_FG - * - * In MODE_FG, the shader renders the glyph onto this cell and utilizes - * the glyph texture "text". In this mode, the output "color" is the - * fg color to use for the glyph. - * - */ - -//------------------------------------------------------------------- -// Color Functions -//------------------------------------------------------------------- - -// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef -float luminance_component(float c) { - if (c <= 0.03928) { - return c / 12.92; - } else { - return pow((c + 0.055) / 1.055, 2.4); - } -} - -float relative_luminance(vec3 color) { - vec3 color_adjusted = vec3( - luminance_component(color.r), - luminance_component(color.g), - luminance_component(color.b) - ); - - vec3 weights = vec3(0.2126, 0.7152, 0.0722); - return dot(color_adjusted, weights); -} - -// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef -float contrast_ratio(vec3 color1, vec3 color2) { - float luminance1 = relative_luminance(color1) + 0.05; - float luminance2 = relative_luminance(color2) + 0.05; - return max(luminance1, luminance2) / min(luminance1, luminance2); -} - -// Return the fg if the contrast ratio is greater than min, otherwise -// return a color that satisfies the contrast ratio. Currently, the color -// is always white or black, whichever has the highest contrast ratio. -vec4 contrasted_color(float min_ratio, vec4 fg, vec4 bg) { - vec3 fg_premult = fg.rgb * fg.a; - vec3 bg_premult = bg.rgb * bg.a; - float ratio = contrast_ratio(fg_premult, bg_premult); - if (ratio < min_ratio) { - float white_ratio = contrast_ratio(vec3(1.0, 1.0, 1.0), bg_premult); - float black_ratio = contrast_ratio(vec3(0.0, 0.0, 0.0), bg_premult); - if (white_ratio > black_ratio) { - return vec4(1.0, 1.0, 1.0, fg.a); - } else { - return vec4(0.0, 0.0, 0.0, fg.a); - } - } - - return fg; -} - -//------------------------------------------------------------------- -// Main -//------------------------------------------------------------------- - -void main() { - // We always forward our mode unmasked because the fragment - // shader doesn't use any of the masks. - mode = mode_in; - - // Top-left cell coordinates converted to world space - // Example: (1,0) with a 30 wide cell is converted to (30,0) - vec2 cell_pos = cell_size * grid_coord; - - // Our Z value. For now we just use grid_z directly but we pull it - // out here so the variable name is more uniform to our cell_pos and - // in case we want to do any other math later. - float cell_z = 0.0; - - // Turn the cell position into a vertex point depending on the - // gl_VertexID. Since we use instanced drawing, we have 4 vertices - // for each corner of the cell. We can use gl_VertexID to determine - // which one we're looking at. Using this, we can use 1 or 0 to keep - // or discard the value for the vertex. - // - // 0 = top-right - // 1 = bot-right - // 2 = bot-left - // 3 = top-left - vec2 position; - position.x = (gl_VertexID == 0 || gl_VertexID == 1) ? 1. : 0.; - position.y = (gl_VertexID == 0 || gl_VertexID == 3) ? 0. : 1.; - - // Scaled for wide chars - vec2 cell_size_scaled = cell_size; - cell_size_scaled.x = cell_size_scaled.x * grid_width; - - switch (mode) { - case MODE_BG: - // If we're at the edge of the grid, we add our padding to the background - // to extend it. Note: grid_padding is top/right/bottom/left. - if (grid_coord.y == 0 && padding_vertical_top) { - cell_pos.y -= grid_padding.r; - cell_size_scaled.y += grid_padding.r; - } else if (grid_coord.y == grid_size.y - 1 && padding_vertical_bottom) { - cell_size_scaled.y += grid_padding.b; - } - if (grid_coord.x == 0) { - cell_pos.x -= grid_padding.a; - cell_size_scaled.x += grid_padding.a; - } else if (grid_coord.x == grid_size.x - 1) { - cell_size_scaled.x += grid_padding.g; - } - - // Calculate the final position of our cell in world space. - // We have to add our cell size since our vertices are offset - // one cell up and to the left. (Do the math to verify yourself) - cell_pos = cell_pos + cell_size_scaled * position; - - gl_Position = projection * vec4(cell_pos, cell_z, 1.0); - color = color_in / 255.0; - break; - - case MODE_FG: - case MODE_FG_CONSTRAINED: - case MODE_FG_COLOR: - case MODE_FG_POWERLINE: - vec2 glyph_offset_calc = glyph_offset; - - // The glyph_offset.y is the y bearing, a y value that when added - // to the baseline is the offset (+y is up). Our grid goes down. - // So we flip it with `cell_size.y - glyph_offset.y`. - glyph_offset_calc.y = cell_size_scaled.y - glyph_offset_calc.y; - - // If this is a constrained mode, we need to constrain it! - vec2 glyph_size_calc = glyph_size; - if (mode == MODE_FG_CONSTRAINED) { - if (glyph_size.x > cell_size_scaled.x) { - float new_y = glyph_size.y * (cell_size_scaled.x / glyph_size.x); - glyph_offset_calc.y = glyph_offset_calc.y + ((glyph_size.y - new_y) / 2); - glyph_size_calc.y = new_y; - glyph_size_calc.x = cell_size_scaled.x; - } - } - - // Calculate the final position of the cell. - cell_pos = cell_pos + (glyph_size_calc * position) + glyph_offset_calc; - gl_Position = projection * vec4(cell_pos, cell_z, 1.0); - - // We need to convert our texture position and size to normalized - // device coordinates (0 to 1.0) by dividing by the size of the texture. - ivec2 text_size; - switch(mode) { - case MODE_FG_CONSTRAINED: - case MODE_FG_POWERLINE: - case MODE_FG: - text_size = textureSize(text, 0); - break; - - case MODE_FG_COLOR: - text_size = textureSize(text_color, 0); - break; - } - vec2 glyph_tex_pos = glyph_pos / text_size; - vec2 glyph_tex_size = glyph_size / text_size; - glyph_tex_coords = glyph_tex_pos + glyph_tex_size * position; - - // If we have a minimum contrast, we need to check if we need to - // change the color of the text to ensure it has enough contrast - // with the background. - // We only apply this adjustment to "normal" text with MODE_FG, - // since we want color glyphs to appear in their original color - // and Powerline glyphs to be unaffected (else parts of the line would - // have different colors as some parts are displayed via background colors). - vec4 color_final = color_in / 255.0; - if (min_contrast > 1.0 && mode == MODE_FG) { - vec4 bg_color = bg_color_in / 255.0; - color_final = contrasted_color(min_contrast, color_final, bg_color); - } - color = color_final; - break; - } -} diff --git a/src/renderer/shaders/custom.v.glsl b/src/renderer/shaders/custom.v.glsl deleted file mode 100644 index 653e1800e..000000000 --- a/src/renderer/shaders/custom.v.glsl +++ /dev/null @@ -1,8 +0,0 @@ -#version 330 core - -void main(){ - vec2 position; - position.x = (gl_VertexID == 0 || gl_VertexID == 1) ? -1. : 1.; - position.y = (gl_VertexID == 0 || gl_VertexID == 3) ? 1. : -1.; - gl_Position = vec4(position.xy, 0.0f, 1.0f); -} diff --git a/src/renderer/shaders/glsl/bg_color.f.glsl b/src/renderer/shaders/glsl/bg_color.f.glsl new file mode 100644 index 000000000..616c44b89 --- /dev/null +++ b/src/renderer/shaders/glsl/bg_color.f.glsl @@ -0,0 +1,13 @@ +#include "common.glsl" + +// Must declare this output for some versions of OpenGL. +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + out_FragColor = load_color( + unpack4u8(bg_color_packed_4u8), + use_linear_blending + ); +} diff --git a/src/renderer/shaders/glsl/bg_image.f.glsl b/src/renderer/shaders/glsl/bg_image.f.glsl new file mode 100644 index 000000000..ee1195ef5 --- /dev/null +++ b/src/renderer/shaders/glsl/bg_image.f.glsl @@ -0,0 +1,63 @@ +#include "common.glsl" + +// Position the FragCoord origin to the upper left +// so as to align with our texture's directionality. +layout(origin_upper_left) in vec4 gl_FragCoord; + +layout(binding = 0) uniform sampler2D image; + +flat in vec4 bg_color; +flat in vec2 offset; +flat in vec2 scale; +flat in float opacity; +flat in uint repeat; + +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + // Our texture coordinate is based on the screen position, offset by the + // dest rect origin, and scaled by the ratio between the dest rect size + // and the original texture size, which effectively scales the original + // size of the texture to the dest rect size. + vec2 tex_coord = (gl_FragCoord.xy - offset) * scale; + + vec2 tex_size = textureSize(image, 0); + + // If we need to repeat the texture, wrap the coordinates. + if (repeat != 0) { + tex_coord = mod(mod(tex_coord, tex_size) + tex_size, tex_size); + } + + vec4 rgba; + // If we're out of bounds, we have no color, + // otherwise we sample the texture for it. + if (any(lessThan(tex_coord, vec2(0.0))) || + any(greaterThan(tex_coord, tex_size))) + { + rgba = vec4(0.0); + } else { + // We divide by the texture size to normalize for sampling. + rgba = texture(image, tex_coord / tex_size); + + if (!use_linear_blending) { + rgba = unlinearize(rgba); + } + + rgba.rgb *= rgba.a; + } + + // Multiply it by the configured opacity, but cap it at + // the value that will make it fully opaque relative to + // the background color alpha, so it isn't overexposed. + rgba *= min(opacity, 1.0 / bg_color.a); + + // Blend it on to a fully opaque version of the background color. + rgba += max(vec4(0.0), vec4(bg_color.rgb, 1.0) * vec4(1.0 - rgba.a)); + + // Multiply everything by the background color alpha. + rgba *= bg_color.a; + + out_FragColor = rgba; +} diff --git a/src/renderer/shaders/glsl/bg_image.v.glsl b/src/renderer/shaders/glsl/bg_image.v.glsl new file mode 100644 index 000000000..d55aa174a --- /dev/null +++ b/src/renderer/shaders/glsl/bg_image.v.glsl @@ -0,0 +1,145 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2D image; + +layout(location = 0) in float in_opacity; +layout(location = 1) in uint info; + +// 4 bits of info. +const uint BG_IMAGE_POSITION = 15u; +const uint BG_IMAGE_TL = 0u; +const uint BG_IMAGE_TC = 1u; +const uint BG_IMAGE_TR = 2u; +const uint BG_IMAGE_ML = 3u; +const uint BG_IMAGE_MC = 4u; +const uint BG_IMAGE_MR = 5u; +const uint BG_IMAGE_BL = 6u; +const uint BG_IMAGE_BC = 7u; +const uint BG_IMAGE_BR = 8u; + +// 2 bits of info shifted 4. +const uint BG_IMAGE_FIT = 3u << 4; +const uint BG_IMAGE_CONTAIN = 0u << 4; +const uint BG_IMAGE_COVER = 1u << 4; +const uint BG_IMAGE_STRETCH = 2u << 4; +const uint BG_IMAGE_NO_FIT = 3u << 4; + +// 1 bit of info shifted 6. +const uint BG_IMAGE_REPEAT = 1u << 6; + +flat out vec4 bg_color; +flat out vec2 offset; +flat out vec2 scale; +flat out float opacity; +// We use a uint to pass the repeat value because +// bools aren't allowed for vertex outputs in OpenGL. +flat out uint repeat; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + vec4 position; + position.x = (gl_VertexID == 2) ? 3.0 : -1.0; + position.y = (gl_VertexID == 0) ? -3.0 : 1.0; + position.z = 1.0; + position.w = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + + gl_Position = position; + + opacity = in_opacity; + + repeat = info & BG_IMAGE_REPEAT; + + vec2 screen_size = screen_size; + vec2 tex_size = textureSize(image, 0); + + vec2 dest_size = tex_size; + switch (info & BG_IMAGE_FIT) { + // For `contain` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is smaller. + case BG_IMAGE_CONTAIN: { + float scale = min(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `cover` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is larger. + case BG_IMAGE_COVER: { + float scale = max(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `stretch` we stretch the image to the size of + // the screen without worrying about aspect ratio. + case BG_IMAGE_STRETCH: { + dest_size = screen_size; + } break; + + // For `none` we just use the original texture size. + case BG_IMAGE_NO_FIT: { + dest_size = tex_size; + } break; + } + + vec2 start = vec2(0.0); + vec2 mid = (screen_size - dest_size) / vec2(2.0); + vec2 end = screen_size - dest_size; + + vec2 dest_offset = mid; + switch (info & BG_IMAGE_POSITION) { + case BG_IMAGE_TL: { + dest_offset = vec2(start.x, start.y); + } break; + case BG_IMAGE_TC: { + dest_offset = vec2(mid.x, start.y); + } break; + case BG_IMAGE_TR: { + dest_offset = vec2(end.x, start.y); + } break; + case BG_IMAGE_ML: { + dest_offset = vec2(start.x, mid.y); + } break; + case BG_IMAGE_MC: { + dest_offset = vec2(mid.x, mid.y); + } break; + case BG_IMAGE_MR: { + dest_offset = vec2(end.x, mid.y); + } break; + case BG_IMAGE_BL: { + dest_offset = vec2(start.x, end.y); + } break; + case BG_IMAGE_BC: { + dest_offset = vec2(mid.x, end.y); + } break; + case BG_IMAGE_BR: { + dest_offset = vec2(end.x, end.y); + } break; + } + + offset = dest_offset; + scale = tex_size / dest_size; + + // We load a fully opaque version of the bg color and combine it with + // the alpha separately, because we need these as separate values in + // the framgment shader. + uvec4 u_bg_color = unpack4u8(bg_color_packed_4u8); + bg_color = vec4(load_color( + uvec4(u_bg_color.rgb, 255), + use_linear_blending + ).rgb, float(u_bg_color.a) / 255.0); +} diff --git a/src/renderer/shaders/glsl/cell_bg.f.glsl b/src/renderer/shaders/glsl/cell_bg.f.glsl new file mode 100644 index 000000000..7ba6caaa6 --- /dev/null +++ b/src/renderer/shaders/glsl/cell_bg.f.glsl @@ -0,0 +1,61 @@ +#include "common.glsl" + +// Position the origin to the upper left +layout(origin_upper_left, pixel_center_integer) in vec4 gl_FragCoord; + +// Must declare this output for some versions of OpenGL. +layout(location = 0) out vec4 out_FragColor; + +layout(binding = 1, std430) readonly buffer bg_cells { + uint cells[]; +}; + +vec4 cell_bg() { + uvec2 grid_size = unpack2u16(grid_size_packed_2u16); + ivec2 grid_pos = ivec2(floor((gl_FragCoord.xy - grid_padding.wx) / cell_size)); + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + vec4 bg = vec4(0.0); + + // Clamp x position, extends edge bg colors in to padding on sides. + if (grid_pos.x < 0) { + if ((padding_extend & EXTEND_LEFT) != 0) { + grid_pos.x = 0; + } else { + return bg; + } + } else if (grid_pos.x > grid_size.x - 1) { + if ((padding_extend & EXTEND_RIGHT) != 0) { + grid_pos.x = int(grid_size.x) - 1; + } else { + return bg; + } + } + + // Clamp y position if we should extend, otherwise discard if out of bounds. + if (grid_pos.y < 0) { + if ((padding_extend & EXTEND_UP) != 0) { + grid_pos.y = 0; + } else { + return bg; + } + } else if (grid_pos.y > grid_size.y - 1) { + if ((padding_extend & EXTEND_DOWN) != 0) { + grid_pos.y = int(grid_size.y) - 1; + } else { + return bg; + } + } + + // Load the color for the cell. + vec4 cell_color = load_color( + unpack4u8(cells[grid_pos.y * grid_size.x + grid_pos.x]), + use_linear_blending + ); + + return cell_color; +} + +void main() { + out_FragColor = cell_bg(); +} diff --git a/src/renderer/shaders/glsl/cell_text.f.glsl b/src/renderer/shaders/glsl/cell_text.f.glsl new file mode 100644 index 000000000..fda6d8134 --- /dev/null +++ b/src/renderer/shaders/glsl/cell_text.f.glsl @@ -0,0 +1,109 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2DRect atlas_grayscale; +layout(binding = 1) uniform sampler2DRect atlas_color; + +in CellTextVertexOut { + flat uint mode; + flat vec4 color; + flat vec4 bg_color; + vec2 tex_coord; +} in_data; + +// These are the possible modes that "mode" can be set to. This is +// used to multiplex multiple render modes into a single shader. +// +// NOTE: this must be kept in sync with the fragment shader +const uint MODE_TEXT = 1u; +const uint MODE_TEXT_CONSTRAINED = 2u; +const uint MODE_TEXT_COLOR = 3u; +const uint MODE_TEXT_CURSOR = 4u; +const uint MODE_TEXT_POWERLINE = 5u; + +// Must declare this output for some versions of OpenGL. +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + bool use_linear_correction = (bools & USE_LINEAR_CORRECTION) != 0; + + switch (in_data.mode) { + default: + case MODE_TEXT_CURSOR: + case MODE_TEXT_CONSTRAINED: + case MODE_TEXT_POWERLINE: + case MODE_TEXT: + { + // Our input color is always linear. + vec4 color = in_data.color; + + // If we're not doing linear blending, then we need to + // re-apply the gamma encoding to our color manually. + // + // Since the alpha is premultiplied, we need to divide + // it out before unlinearizing and re-multiply it after. + if (!use_linear_blending) { + color.rgb /= vec3(color.a); + color = unlinearize(color); + color.rgb *= vec3(color.a); + } + + // Fetch our alpha mask for this pixel. + float a = texture(atlas_grayscale, in_data.tex_coord).r; + + // Linear blending weight correction corrects the alpha value to + // produce blending results which match gamma-incorrect blending. + if (use_linear_correction) { + // Short explanation of how this works: + // + // We get the luminances of the foreground and background colors, + // and then unlinearize them and perform blending on them. This + // gives us our desired luminance, which we derive our new alpha + // value from by mapping the range [bg_l, fg_l] to [0, 1], since + // our final blend will be a linear interpolation from bg to fg. + // + // This yields virtually identical results for grayscale blending, + // and very similar but non-identical results for color blending. + vec4 bg = in_data.bg_color; + float fg_l = luminance(color.rgb); + float bg_l = luminance(bg.rgb); + // To avoid numbers going haywire, we don't apply correction + // when the bg and fg luminances are within 0.001 of each other. + if (abs(fg_l - bg_l) > 0.001) { + float blend_l = linearize(unlinearize(fg_l) * a + unlinearize(bg_l) * (1.0 - a)); + a = clamp((blend_l - bg_l) / (fg_l - bg_l), 0.0, 1.0); + } + } + + // Multiply our whole color by the alpha mask. + // Since we use premultiplied alpha, this is + // the correct way to apply the mask. + color *= a; + + out_FragColor = color; + return; + } + + case MODE_TEXT_COLOR: + { + // For now, we assume that color glyphs + // are already premultiplied linear colors. + vec4 color = texture(atlas_color, in_data.tex_coord); + + // If we are doing linear blending, we can return this right away. + if (use_linear_blending) { + out_FragColor = color; + return; + } + + // Otherwise we need to unlinearize the color. Since the alpha is + // premultiplied, we need to divide it out before unlinearizing. + color.rgb /= vec3(color.a); + color = unlinearize(color); + color.rgb *= vec3(color.a); + + out_FragColor = color; + return; + } + } +} diff --git a/src/renderer/shaders/glsl/cell_text.v.glsl b/src/renderer/shaders/glsl/cell_text.v.glsl new file mode 100644 index 000000000..10965ddd2 --- /dev/null +++ b/src/renderer/shaders/glsl/cell_text.v.glsl @@ -0,0 +1,168 @@ +#include "common.glsl" + +// The position of the glyph in the texture (x, y) +layout(location = 0) in uvec2 glyph_pos; + +// The size of the glyph in the texture (w, h) +layout(location = 1) in uvec2 glyph_size; + +// The left and top bearings for the glyph (x, y) +layout(location = 2) in ivec2 bearings; + +// The grid coordinates (x, y) where x < columns and y < rows +layout(location = 3) in uvec2 grid_pos; + +// The color of the rendered text glyph. +layout(location = 4) in uvec4 color; + +// The mode for this cell. +layout(location = 5) in uint mode; + +// The width to constrain the glyph to, in cells, or 0 for no constraint. +layout(location = 6) in uint constraint_width; + +// These are the possible modes that "mode" can be set to. This is +// used to multiplex multiple render modes into a single shader. +const uint MODE_TEXT = 1u; +const uint MODE_TEXT_CONSTRAINED = 2u; +const uint MODE_TEXT_COLOR = 3u; +const uint MODE_TEXT_CURSOR = 4u; +const uint MODE_TEXT_POWERLINE = 5u; + +out CellTextVertexOut { + flat uint mode; + flat vec4 color; + flat vec4 bg_color; + vec2 tex_coord; +} out_data; + +layout(binding = 1, std430) readonly buffer bg_cells { + uint bg_colors[]; +}; + +void main() { + uvec2 grid_size = unpack2u16(grid_size_packed_2u16); + uvec2 cursor_pos = unpack2u16(cursor_pos_packed_2u16); + bool cursor_wide = (bools & CURSOR_WIDE) != 0; + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + // Convert the grid x, y into world space x, y by accounting for cell size + vec2 cell_pos = cell_size * vec2(grid_pos); + + int vid = gl_VertexID; + + // We use a triangle strip with 4 vertices to render quads, + // so we determine which corner of the cell this vertex is in + // based on the vertex ID. + // + // 0 --> 1 + // | .'| + // | / | + // | L | + // 2 --> 3 + // + // 0 = top-left (0, 0) + // 1 = top-right (1, 0) + // 2 = bot-left (0, 1) + // 3 = bot-right (1, 1) + vec2 corner; + corner.x = float(vid == 1 || vid == 3); + corner.y = float(vid == 2 || vid == 3); + + out_data.mode = mode; + + // === Grid Cell === + // +X + // 0,0--...-> + // | + // . offset.x = bearings.x + // +Y. .|. + // . | | + // | cell_pos -> +-------+ _. + // v ._| |_. _|- offset.y = cell_size.y - bearings.y + // | | .###. | | + // | | #...# | | + // glyph_size.y -+ | ##### | | + // | | #.... | +- bearings.y + // |_| .#### | | + // | |_| + // +-------+ + // |_._| + // | + // glyph_size.x + // + // In order to get the top left of the glyph, we compute an offset based on + // the bearings. The Y bearing is the distance from the bottom of the cell + // to the top of the glyph, so we subtract it from the cell height to get + // the y offset. The X bearing is the distance from the left of the cell + // to the left of the glyph, so it works as the x offset directly. + + vec2 size = vec2(glyph_size); + vec2 offset = vec2(bearings); + + offset.y = cell_size.y - offset.y; + + // If we're constrained then we need to scale the glyph. + if (mode == MODE_TEXT_CONSTRAINED) { + float max_width = cell_size.x * constraint_width; + // If this glyph is wider than the constraint width, + // fit it to the width and remove its horizontal offset. + if (size.x > max_width) { + float new_y = size.y * (max_width / size.x); + offset.y += (size.y - new_y) / 2.0; + offset.x = 0.0; + size.y = new_y; + size.x = max_width; + } else if (max_width - size.x > offset.x) { + // However, if it does fit in the constraint width, make + // sure the offset is small enough to not push it over the + // right edge of the constraint width. + offset.x = max_width - size.x; + } + } + + // Calculate the final position of the cell which uses our glyph size + // and glyph offset to create the correct bounding box for the glyph. + cell_pos = cell_pos + size * corner + offset; + gl_Position = projection_matrix * vec4(cell_pos.x, cell_pos.y, 0.0f, 1.0f); + + // Calculate the texture coordinate in pixels. This is NOT normalized + // (between 0.0 and 1.0), and does not need to be, since the texture will + // be sampled with pixel coordinate mode. + out_data.tex_coord = vec2(glyph_pos) + vec2(glyph_size) * corner; + + // Get our color. We always fetch a linearized version to + // make it easier to handle minimum contrast calculations. + out_data.color = load_color(color, true); + // Get the BG color + out_data.bg_color = load_color( + unpack4u8(bg_colors[grid_pos.y * grid_size.x + grid_pos.x]), + true + ); + // Blend it with the global bg color + vec4 global_bg = load_color( + unpack4u8(bg_color_packed_4u8), + true + ); + out_data.bg_color += global_bg * vec4(1.0 - out_data.bg_color.a); + + // If we have a minimum contrast, we need to check if we need to + // change the color of the text to ensure it has enough contrast + // with the background. + // We only apply this adjustment to "normal" text with MODE_TEXT, + // since we want color glyphs to appear in their original color + // and Powerline glyphs to be unaffected (else parts of the line would + // have different colors as some parts are displayed via background colors). + if (min_contrast > 1.0f && mode == MODE_TEXT) { + // Ensure our minimum contrast + out_data.color = contrasted_color(min_contrast, out_data.color, out_data.bg_color); + } + + // Check if current position is under cursor (including wide cursor) + bool is_cursor_pos = ((grid_pos.x == cursor_pos.x) || (cursor_wide && (grid_pos.x == (cursor_pos.x + 1)))) && (grid_pos.y == cursor_pos.y); + + // If this cell is the cursor cell, then we need to change the color. + if (mode != MODE_TEXT_CURSOR && is_cursor_pos) { + out_data.color = load_color(unpack4u8(cursor_color_packed_4u8), use_linear_blending); + } +} diff --git a/src/renderer/shaders/glsl/common.glsl b/src/renderer/shaders/glsl/common.glsl new file mode 100644 index 000000000..a0ed9f7b4 --- /dev/null +++ b/src/renderer/shaders/glsl/common.glsl @@ -0,0 +1,156 @@ +#version 430 core + +// These are common definitions to be shared across shaders, the first +// line of any shader that needs these should be `#include "common.glsl"`. +// +// Included in this file are: +// - The interface block for the global uniforms. +// - Functions for unpacking values. +// - Functions for working with colors. + +//----------------------------------------------------------------------------// +// Global Uniforms +//----------------------------------------------------------------------------// +layout(binding = 1, std140) uniform Globals { + uniform mat4 projection_matrix; + uniform vec2 screen_size; + uniform vec2 cell_size; + uniform uint grid_size_packed_2u16; + uniform vec4 grid_padding; + uniform uint padding_extend; + uniform float min_contrast; + uniform uint cursor_pos_packed_2u16; + uniform uint cursor_color_packed_4u8; + uniform uint bg_color_packed_4u8; + uniform uint bools; +}; + +// Bools +const uint CURSOR_WIDE = 1u; +const uint USE_DISPLAY_P3 = 2u; +const uint USE_LINEAR_BLENDING = 4u; +const uint USE_LINEAR_CORRECTION = 8u; + +// Padding extend enum +const uint EXTEND_LEFT = 1u; +const uint EXTEND_RIGHT = 2u; +const uint EXTEND_UP = 4u; +const uint EXTEND_DOWN = 8u; + +//----------------------------------------------------------------------------// +// Functions for Unpacking Values +//----------------------------------------------------------------------------// +// NOTE: These unpack functions assume little-endian. +// If this ever becomes a problem... oh dear! + +uvec4 unpack4u8(uint packed_value) { + return uvec4( + uint(packed_value >> 0) & uint(0xFF), + uint(packed_value >> 8) & uint(0xFF), + uint(packed_value >> 16) & uint(0xFF), + uint(packed_value >> 24) & uint(0xFF) + ); +} + +uvec2 unpack2u16(uint packed_value) { + return uvec2( + uint(packed_value >> 0) & uint(0xFFFF), + uint(packed_value >> 16) & uint(0xFFFF) + ); +} + +ivec2 unpack2i16(int packed_value) { + return ivec2( + (packed_value << 16) >> 16, + (packed_value << 0) >> 16 + ); +} + +//----------------------------------------------------------------------------// +// Color Functions +//----------------------------------------------------------------------------// + +// Compute the luminance of the provided color. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float luminance(vec3 color) { + return dot(color, vec3(0.2126f, 0.7152f, 0.0722f)); +} + +// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float contrast_ratio(vec3 color1, vec3 color2) { + float luminance1 = luminance(color1) + 0.05; + float luminance2 = luminance(color2) + 0.05; + return max(luminance1, luminance2) / min(luminance1, luminance2); +} + +// Return the fg if the contrast ratio is greater than min, otherwise +// return a color that satisfies the contrast ratio. Currently, the color +// is always white or black, whichever has the highest contrast ratio. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +vec4 contrasted_color(float min_ratio, vec4 fg, vec4 bg) { + float ratio = contrast_ratio(fg.rgb, bg.rgb); + if (ratio < min_ratio) { + float white_ratio = contrast_ratio(vec3(1.0, 1.0, 1.0), bg.rgb); + float black_ratio = contrast_ratio(vec3(0.0, 0.0, 0.0), bg.rgb); + if (white_ratio > black_ratio) { + return vec4(1.0); + } else { + return vec4(0.0); + } + } + + return fg; +} + +// Converts a color from sRGB gamma encoding to linear. +vec4 linearize(vec4 srgb) { + bvec3 cutoff = lessThanEqual(srgb.rgb, vec3(0.04045)); + vec3 higher = pow((srgb.rgb + vec3(0.055)) / vec3(1.055), vec3(2.4)); + vec3 lower = srgb.rgb / vec3(12.92); + + return vec4(mix(higher, lower, cutoff), srgb.a); +} +float linearize(float v) { + return v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4); +} + +// Converts a color from linear to sRGB gamma encoding. +vec4 unlinearize(vec4 linear) { + bvec3 cutoff = lessThanEqual(linear.rgb, vec3(0.0031308)); + vec3 higher = pow(linear.rgb, vec3(1.0 / 2.4)) * vec3(1.055) - vec3(0.055); + vec3 lower = linear.rgb * vec3(12.92); + + return vec4(mix(higher, lower, cutoff), linear.a); +} +float unlinearize(float v) { + return v <= 0.0031308 ? v * 12.92 : pow(v, 1.0 / 2.4) * 1.055 - 0.055; +} + +// Load a 4 byte RGBA non-premultiplied color and linearize +// and convert it as necessary depending on the provided info. +// +// `linear` controls whether the returned color is linear or gamma encoded. +vec4 load_color( + uvec4 in_color, + bool linear +) { + // 0 .. 255 -> 0.0 .. 1.0 + vec4 color = vec4(in_color) / vec4(255.0f); + + // Linearize if necessary. + if (linear) color = linearize(color); + + // Premultiply our color by its alpha. + color.rgb *= color.a; + + return color; +} + +//----------------------------------------------------------------------------// diff --git a/src/renderer/shaders/glsl/full_screen.v.glsl b/src/renderer/shaders/glsl/full_screen.v.glsl new file mode 100644 index 000000000..b89cedfa5 --- /dev/null +++ b/src/renderer/shaders/glsl/full_screen.v.glsl @@ -0,0 +1,24 @@ +#version 330 core + +void main() { + vec4 position; + position.x = (gl_VertexID == 2) ? 3.0 : -1.0; + position.y = (gl_VertexID == 0) ? -3.0 : 1.0; + position.z = 1.0; + position.w = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + + gl_Position = position; +} diff --git a/src/renderer/shaders/glsl/image.f.glsl b/src/renderer/shaders/glsl/image.f.glsl new file mode 100644 index 000000000..4f89d7a78 --- /dev/null +++ b/src/renderer/shaders/glsl/image.f.glsl @@ -0,0 +1,21 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2D image; + +in vec2 tex_coord; + +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + vec4 rgba = texture(image, tex_coord); + + if (!use_linear_blending) { + rgba = unlinearize(rgba); + } + + rgba.rgb *= vec3(rgba.a); + + out_FragColor = rgba; +} diff --git a/src/renderer/shaders/glsl/image.v.glsl b/src/renderer/shaders/glsl/image.v.glsl new file mode 100644 index 000000000..779fae32f --- /dev/null +++ b/src/renderer/shaders/glsl/image.v.glsl @@ -0,0 +1,47 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2D image; + +layout(location = 0) in vec2 grid_pos; +layout(location = 1) in vec2 cell_offset; +layout(location = 2) in vec4 source_rect; +layout(location = 3) in vec2 dest_size; + +out vec2 tex_coord; + +void main() { + int vid = gl_VertexID; + + // We use a triangle strip with 4 vertices to render quads, + // so we determine which corner of the cell this vertex is in + // based on the vertex ID. + // + // 0 --> 1 + // | .'| + // | / | + // | L | + // 2 --> 3 + // + // 0 = top-left (0, 0) + // 1 = top-right (1, 0) + // 2 = bot-left (0, 1) + // 3 = bot-right (1, 1) + vec2 corner; + corner.x = float(vid == 1 || vid == 3); + corner.y = float(vid == 2 || vid == 3); + + // The texture coordinates start at our source x/y + // and add the width/height depending on the corner. + tex_coord = source_rect.xy; + tex_coord += source_rect.zw * corner; + + // Normalize the coordinates. + tex_coord /= textureSize(image, 0); + + // The position of our image starts at the top-left of the grid cell and + // adds the source rect width/height components. + vec2 image_pos = (cell_size * grid_pos) + cell_offset; + image_pos += dest_size * corner; + + gl_Position = projection_matrix * vec4(image_pos.xy, 1.0, 1.0); +} diff --git a/src/renderer/shaders/image.f.glsl b/src/renderer/shaders/image.f.glsl deleted file mode 100644 index e4aa9ef8e..000000000 --- a/src/renderer/shaders/image.f.glsl +++ /dev/null @@ -1,29 +0,0 @@ -#version 330 core - -in vec2 tex_coord; - -layout(location = 0) out vec4 out_FragColor; - -uniform sampler2D image; - -// Converts a color from linear to sRGB gamma encoding. -vec4 unlinearize(vec4 linear) { - bvec3 cutoff = lessThan(linear.rgb, vec3(0.0031308)); - vec3 higher = pow(linear.rgb, vec3(1.0/2.4)) * vec3(1.055) - vec3(0.055); - vec3 lower = linear.rgb * vec3(12.92); - - return vec4(mix(higher, lower, cutoff), linear.a); -} - -void main() { - vec4 color = texture(image, tex_coord); - - // Our texture is stored with an sRGB internal format, - // which means that the values are linearized when we - // sample the texture, but for now we actually want to - // output the color with gamma compression, so we do - // that. - color = unlinearize(color); - - out_FragColor = vec4(color.rgb * color.a, color.a); -} diff --git a/src/renderer/shaders/image.v.glsl b/src/renderer/shaders/image.v.glsl deleted file mode 100644 index e3d07ca9e..000000000 --- a/src/renderer/shaders/image.v.glsl +++ /dev/null @@ -1,44 +0,0 @@ -#version 330 core - -layout (location = 0) in vec2 grid_pos; -layout (location = 1) in vec2 cell_offset; -layout (location = 2) in vec4 source_rect; -layout (location = 3) in vec2 dest_size; - -out vec2 tex_coord; - -uniform sampler2D image; -uniform vec2 cell_size; -uniform mat4 projection; - -void main() { - // The size of the image in pixels - vec2 image_size = textureSize(image, 0); - - // Turn the cell position into a vertex point depending on the - // gl_VertexID. Since we use instanced drawing, we have 4 vertices - // for each corner of the cell. We can use gl_VertexID to determine - // which one we're looking at. Using this, we can use 1 or 0 to keep - // or discard the value for the vertex. - // - // 0 = top-right - // 1 = bot-right - // 2 = bot-left - // 3 = top-left - vec2 position; - position.x = (gl_VertexID == 0 || gl_VertexID == 1) ? 1. : 0.; - position.y = (gl_VertexID == 0 || gl_VertexID == 3) ? 0. : 1.; - - // The texture coordinates start at our source x/y, then add the width/height - // as enabled by our instance id, then normalize to [0, 1] - tex_coord = source_rect.xy; - tex_coord += source_rect.zw * position; - tex_coord /= image_size; - - // The position of our image starts at the top-left of the grid cell and - // adds the source rect width/height components. - vec2 image_pos = (cell_size * grid_pos) + cell_offset; - image_pos += dest_size * position; - - gl_Position = projection * vec4(image_pos.xy, 0, 1.0); -} diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/shaders.metal similarity index 72% rename from src/renderer/shaders/cell.metal rename to src/renderer/shaders/shaders.metal index 5b3875221..b62e0c3cf 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/shaders.metal @@ -11,6 +11,7 @@ enum Padding : uint8_t { struct Uniforms { float4x4 projection_matrix; + float2 screen_size; float2 cell_size; ushort2 grid_size; float4 grid_padding; @@ -216,53 +217,245 @@ vertex FullScreenVertexOut full_screen_vertex( } //------------------------------------------------------------------- -// Cell Background Shader +// Background Color Shader //------------------------------------------------------------------- -#pragma mark - Cell BG Shader +#pragma mark - BG Color Shader -struct CellBgVertexOut { - float4 position [[position]]; - float4 bg_color; -}; - -vertex CellBgVertexOut cell_bg_vertex( - uint vid [[vertex_id]], +fragment float4 bg_color_fragment( + FullScreenVertexOut in [[stage_in]], constant Uniforms& uniforms [[buffer(1)]] ) { - CellBgVertexOut out; + return load_color( + uniforms.bg_color, + uniforms.use_display_p3, + uniforms.use_linear_blending + ); +} + +//------------------------------------------------------------------- +// Background Image Shader +//------------------------------------------------------------------- +#pragma mark - BG Image Shader + +struct BgImageVertexIn { + float opacity [[attribute(0)]]; + uint8_t info [[attribute(1)]]; +}; + +enum BgImagePosition : uint8_t { + // 4 bits of info. + BG_IMAGE_POSITION = 15u, + + BG_IMAGE_TL = 0u, + BG_IMAGE_TC = 1u, + BG_IMAGE_TR = 2u, + BG_IMAGE_ML = 3u, + BG_IMAGE_MC = 4u, + BG_IMAGE_MR = 5u, + BG_IMAGE_BL = 6u, + BG_IMAGE_BC = 7u, + BG_IMAGE_BR = 8u, +}; + +enum BgImageFit : uint8_t { + // 2 bits of info shifted 4. + BG_IMAGE_FIT = 3u << 4, + + BG_IMAGE_CONTAIN = 0u << 4, + BG_IMAGE_COVER = 1u << 4, + BG_IMAGE_STRETCH = 2u << 4, + BG_IMAGE_NO_FIT = 3u << 4, +}; + +enum BgImageRepeat : uint8_t { + // 1 bit of info shifted 6. + BG_IMAGE_REPEAT = 1u << 6, +}; + +struct BgImageVertexOut { + float4 position [[position]]; + float4 bg_color [[flat]]; + float2 offset [[flat]]; + float2 scale [[flat]]; + float opacity [[flat]]; + bool repeat [[flat]]; +}; + +vertex BgImageVertexOut bg_image_vertex( + uint vid [[vertex_id]], + BgImageVertexIn in [[stage_in]], + texture2d image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + BgImageVertexOut out; float4 position; position.x = (vid == 2) ? 3.0 : -1.0; position.y = (vid == 0) ? -3.0 : 1.0; position.zw = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + out.position = position; - // Convert the background color to Display P3 - out.bg_color = load_color( - uniforms.bg_color, + out.opacity = in.opacity; + + out.repeat = (in.info & BG_IMAGE_REPEAT) == BG_IMAGE_REPEAT; + + float2 screen_size = uniforms.screen_size; + float2 tex_size = float2(image.get_width(), image.get_height()); + + float2 dest_size = tex_size; + switch (in.info & BG_IMAGE_FIT) { + // For `contain` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is smaller. + case BG_IMAGE_CONTAIN: { + float scale = min(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `cover` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is larger. + case BG_IMAGE_COVER: { + float scale = max(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `stretch` we stretch the image to the size of + // the screen without worrying about aspect ratio. + case BG_IMAGE_STRETCH: { + dest_size = screen_size; + } break; + + // For `none` we just use the original texture size. + case BG_IMAGE_NO_FIT: { + dest_size = tex_size; + } break; + } + + float2 start = float2(0.0); + float2 mid = (screen_size - dest_size) / 2; + float2 end = screen_size - dest_size; + + float2 dest_offset = mid; + switch (in.info & BG_IMAGE_POSITION) { + case BG_IMAGE_TL: { + dest_offset = float2(start.x, start.y); + } break; + case BG_IMAGE_TC: { + dest_offset = float2(mid.x, start.y); + } break; + case BG_IMAGE_TR: { + dest_offset = float2(end.x, start.y); + } break; + case BG_IMAGE_ML: { + dest_offset = float2(start.x, mid.y); + } break; + case BG_IMAGE_MC: { + dest_offset = float2(mid.x, mid.y); + } break; + case BG_IMAGE_MR: { + dest_offset = float2(end.x, mid.y); + } break; + case BG_IMAGE_BL: { + dest_offset = float2(start.x, end.y); + } break; + case BG_IMAGE_BC: { + dest_offset = float2(mid.x, end.y); + } break; + case BG_IMAGE_BR: { + dest_offset = float2(end.x, end.y); + } break; + } + + out.offset = dest_offset; + out.scale = tex_size / dest_size; + + // We load a fully opaque version of the bg color and combine it with + // the alpha separately, because we need these as separate values in + // the framgment shader. + out.bg_color = float4(load_color( + uchar4(uniforms.bg_color.rgb, 255), uniforms.use_display_p3, uniforms.use_linear_blending - ); + ).rgb, float(uniforms.bg_color.a) / 255.0); return out; } -fragment float4 cell_bg_fragment( - CellBgVertexOut in [[stage_in]], - constant uchar4 *cells [[buffer(0)]], +fragment float4 bg_image_fragment( + BgImageVertexOut in [[stage_in]], + texture2d image [[texture(0)]], constant Uniforms& uniforms [[buffer(1)]] +) { + constexpr sampler textureSampler( + coord::pixel, + address::clamp_to_zero, + filter::linear + ); + + // Our texture coordinate is based on the screen position, offset by the + // dest rect origin, and scaled by the ratio between the dest rect size + // and the original texture size, which effectively scales the original + // size of the texture to the dest rect size. + float2 tex_coord = (in.position.xy - in.offset) * in.scale; + + // If we need to repeat the texture, wrap the coordinates. + if (in.repeat) { + float2 tex_size = float2(image.get_width(), image.get_height()); + + tex_coord = fmod(fmod(tex_coord, tex_size) + tex_size, tex_size); + } + + float4 rgba = image.sample(textureSampler, tex_coord); + + if (!uniforms.use_linear_blending) { + rgba = unlinearize(rgba); + } + + // Premultiply the bg image. + rgba.rgb *= rgba.a; + + // Multiply it by the configured opacity, but cap it at + // the value that will make it fully opaque relative to + // the background color alpha, so it isn't overexposed. + rgba *= min(in.opacity, 1.0 / in.bg_color.a); + + // Blend it on to a fully opaque version of the background color. + rgba += max(float4(0.0), float4(in.bg_color.rgb, 1.0) * (1.0 - rgba.a)); + + // Multiply everything by the background color alpha. + rgba *= in.bg_color.a; + + return rgba; +} + +//------------------------------------------------------------------- +// Cell Background Shader +//------------------------------------------------------------------- +#pragma mark - Cell BG Shader + +fragment float4 cell_bg_fragment( + FullScreenVertexOut in [[stage_in]], + constant Uniforms& uniforms [[buffer(1)]], + constant uchar4 *cells [[buffer(2)]] ) { int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx) / uniforms.cell_size)); float4 bg = float4(0.0); - // If we have any background transparency then we render bg-colored cells as - // fully transparent, since the background is handled by the layer bg color - // and we don't want to double up our bg color, but if our bg color is fully - // opaque then our layer is opaque and can't handle transparency, so we need - // to return the bg color directly instead. - if (uniforms.bg_color.a == 255) { - bg = in.bg_color; - } // Clamp x position, extends edge bg colors in to padding on sides. if (grid_pos.x < 0) { @@ -297,17 +490,8 @@ fragment float4 cell_bg_fragment( // Load the color for the cell. uchar4 cell_color = cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x]; - // We have special case handling for when the cell color matches the bg color. - if (all(cell_color == uniforms.bg_color)) { - return bg; - } - // Convert the color and return it. // - // TODO: We may want to blend the color with the background - // color, rather than purely replacing it, this needs - // some consideration about config options though. - // // TODO: It might be a good idea to do a pass before this // to convert all of the bg colors, so we don't waste // a bunch of work converting the cell color in every @@ -374,19 +558,23 @@ vertex CellTextVertexOut cell_text_vertex( // Convert the grid x, y into world space x, y by accounting for cell size float2 cell_pos = uniforms.cell_size * float2(in.grid_pos); - // Turn the cell position into a vertex point depending on the - // vertex ID. Since we use instanced drawing, we have 4 vertices - // for each corner of the cell. We can use vertex ID to determine - // which one we're looking at. Using this, we can use 1 or 0 to keep - // or discard the value for the vertex. + // We use a triangle strip with 4 vertices to render quads, + // so we determine which corner of the cell this vertex is in + // based on the vertex ID. // - // 0 = top-right - // 1 = bot-right - // 2 = bot-left - // 3 = top-left + // 0 --> 1 + // | .'| + // | / | + // | L | + // 2 --> 3 + // + // 0 = top-left (0, 0) + // 1 = top-right (1, 0) + // 2 = bot-left (0, 1) + // 3 = bot-right (1, 1) float2 corner; - corner.x = (vid == 0 || vid == 1) ? 1.0f : 0.0f; - corner.y = (vid == 0 || vid == 3) ? 0.0f : 1.0f; + corner.x = float(vid == 1 || vid == 3); + corner.y = float(vid == 2 || vid == 3); CellTextVertexOut out; out.mode = in.mode; @@ -466,6 +654,13 @@ vertex CellTextVertexOut cell_text_vertex( uniforms.use_display_p3, true ); + // Blend it with the global bg color + float4 global_bg = load_color( + uniforms.bg_color, + uniforms.use_display_p3, + true + ); + out.bg_color += global_bg * (1.0 - out.bg_color.a); // If we have a minimum contrast, we need to check if we need to // change the color of the text to ensure it has enough contrast @@ -502,7 +697,7 @@ fragment float4 cell_text_fragment( CellTextVertexOut in [[stage_in]], texture2d textureGrayscale [[texture(0)]], texture2d textureColor [[texture(1)]], - constant Uniforms& uniforms [[buffer(2)]] + constant Uniforms& uniforms [[buffer(1)]] ) { constexpr sampler textureSampler( coord::pixel, @@ -570,19 +765,19 @@ fragment float4 cell_text_fragment( } case MODE_TEXT_COLOR: { - // For now, we assume that color glyphs are - // already premultiplied Display P3 colors. + // For now, we assume that color glyphs + // are already premultiplied linear colors. float4 color = textureColor.sample(textureSampler, in.tex_coord); - // If we aren't doing linear blending, we can return this right away. - if (!uniforms.use_linear_blending) { + // If we're doing linear blending, we can return this right away. + if (uniforms.use_linear_blending) { return color; } - // Otherwise we need to linearize the color. Since the alpha is - // premultiplied, we need to divide it out before linearizing. + // Otherwise we need to unlinearize the color. Since the alpha is + // premultiplied, we need to divide it out before unlinearizing. color.rgb /= color.a; - color = linearize(color); + color = unlinearize(color); color.rgb *= color.a; return color; @@ -621,19 +816,23 @@ vertex ImageVertexOut image_vertex( texture2d image [[texture(0)]], constant Uniforms& uniforms [[buffer(1)]] ) { - // Turn the image position into a vertex point depending on the - // vertex ID. Since we use instanced drawing, we have 4 vertices - // for each corner of the cell. We can use vertex ID to determine - // which one we're looking at. Using this, we can use 1 or 0 to keep - // or discard the value for the vertex. + // We use a triangle strip with 4 vertices to render quads, + // so we determine which corner of the cell this vertex is in + // based on the vertex ID. // - // 0 = top-right - // 1 = bot-right - // 2 = bot-left - // 3 = top-left + // 0 --> 1 + // | .'| + // | / | + // | L | + // 2 --> 3 + // + // 0 = top-left (0, 0) + // 1 = top-right (1, 0) + // 2 = bot-left (0, 1) + // 3 = bot-right (1, 1) float2 corner; - corner.x = (vid == 0 || vid == 1) ? 1.0f : 0.0f; - corner.y = (vid == 0 || vid == 3) ? 0.0f : 1.0f; + corner.x = float(vid == 1 || vid == 3); + corner.y = float(vid == 2 || vid == 3); // The texture coordinates start at our source x/y // and add the width/height depending on the corner. diff --git a/src/renderer/shaders/shadertoy_prefix.glsl b/src/renderer/shaders/shadertoy_prefix.glsl index a1a220bd4..6d9cf0f68 100644 --- a/src/renderer/shaders/shadertoy_prefix.glsl +++ b/src/renderer/shaders/shadertoy_prefix.glsl @@ -1,24 +1,29 @@ #version 430 core -layout(binding = 0) uniform Globals { - uniform vec3 iResolution; - uniform float iTime; - uniform float iTimeDelta; - uniform float iFrameRate; - uniform int iFrame; - uniform float iChannelTime[4]; - uniform vec3 iChannelResolution[4]; - uniform vec4 iMouse; - uniform vec4 iDate; - uniform float iSampleRate; +layout(binding = 1, std140) uniform Globals { + uniform vec3 iResolution; + uniform float iTime; + uniform float iTimeDelta; + uniform float iFrameRate; + uniform int iFrame; + uniform float iChannelTime[4]; + uniform vec3 iChannelResolution[4]; + uniform vec4 iMouse; + uniform vec4 iDate; + uniform float iSampleRate; + uniform vec4 iCurrentCursor; + uniform vec4 iPreviousCursor; + uniform vec4 iCurrentCursorColor; + uniform vec4 iPreviousCursorColor; + uniform float iTimeCursorChange; }; -layout(binding = 0) uniform sampler2D iChannel0; +layout(binding = 0) uniform sampler2D iChannel0; // These are unused currently by Ghostty: -// layout(binding = 1) uniform sampler2D iChannel1; -// layout(binding = 2) uniform sampler2D iChannel2; -// layout(binding = 3) uniform sampler2D iChannel3; +// layout(binding = 1) uniform sampler2D iChannel1; +// layout(binding = 2) uniform sampler2D iChannel2; +// layout(binding = 3) uniform sampler2D iChannel3; layout(location = 0) in vec4 gl_FragCoord; layout(location = 0) out vec4 _fragColor; diff --git a/src/renderer/shadertoy.zig b/src/renderer/shadertoy.zig index 45d86cbfe..576237587 100644 --- a/src/renderer/shadertoy.zig +++ b/src/renderer/shadertoy.zig @@ -9,6 +9,25 @@ const configpkg = @import("../config.zig"); const log = std.log.scoped(.shadertoy); +/// The uniform struct used for shadertoy shaders. +pub const Uniforms = extern struct { + resolution: [3]f32 align(16), + time: f32 align(4), + time_delta: f32 align(4), + frame_rate: f32 align(4), + frame: i32 align(4), + channel_time: [4][4]f32 align(16), + channel_resolution: [4][4]f32 align(16), + mouse: [4]f32 align(16), + date: [4]f32 align(16), + sample_rate: f32 align(4), + current_cursor: [4]f32 align(16), + previous_cursor: [4]f32 align(16), + current_cursor_color: [4]f32 align(16), + previous_cursor_color: [4]f32 align(16), + cursor_change_time: f32 align(4), +}; + /// The target to load shaders for. pub const Target = enum { glsl, msl }; @@ -205,18 +224,25 @@ pub const SpirvLog = struct { /// Convert SPIR-V binary to MSL. pub fn mslFromSpv(alloc: Allocator, spv: []const u8) ![:0]const u8 { - return try spvCross(alloc, spvcross.c.SPVC_BACKEND_MSL, spv, null); + const c = spvcross.c; + return try spvCross(alloc, spvcross.c.SPVC_BACKEND_MSL, spv, (struct { + fn setOptions(options: c.spvc_compiler_options) error{SpvcFailed}!void { + // We enable decoration binding, because we need this + // to properly locate the uniform block to index 1. + if (c.spvc_compiler_options_set_bool( + options, + c.SPVC_COMPILER_OPTION_MSL_ENABLE_DECORATION_BINDING, + c.SPVC_TRUE, + ) != c.SPVC_SUCCESS) { + return error.SpvcFailed; + } + } + }).setOptions); } -/// Convert SPIR-V binary to GLSL.. +/// Convert SPIR-V binary to GLSL. pub fn glslFromSpv(alloc: Allocator, spv: []const u8) ![:0]const u8 { - // Our minimum version for shadertoy shaders is OpenGL 4.2 because - // Spirv-Cross generates binding locations for uniforms which is - // only supported in OpenGL 4.2 and above. - // - // If we can figure out a way to NOT do this then we can lower this - // version. - const GLSL_VERSION = 420; + const GLSL_VERSION = 430; const c = spvcross.c; return try spvCross(alloc, c.SPVC_BACKEND_GLSL, spv, (struct { diff --git a/src/renderer/size.zig b/src/renderer/size.zig index 83e921a26..b26c1581e 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -22,7 +22,7 @@ pub const Size = struct { /// taking the screen size, removing padding, and dividing by the cell /// dimensions. pub fn grid(self: Size) GridSize { - return GridSize.init(self.screen.subPadding(self.padding), self.cell); + return .init(self.screen.subPadding(self.padding), self.cell); } /// The size of the terminal. This is the same as the screen without @@ -39,7 +39,7 @@ pub const Size = struct { self.padding = explicit; // Now we can calculate the balanced padding - self.padding = Padding.balanced( + self.padding = .balanced( self.screen, self.grid(), self.cell, diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 0cfd41663..0766198f9 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -15,10 +15,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# We need to be in interactive mode and we need to have the Ghostty -# resources dir set which also tells us we're running in Ghostty. +# We need to be in interactive mode to proceed. if [[ "$-" != *i* ]] ; then builtin return; fi -if [ -z "$GHOSTTY_RESOURCES_DIR" ]; then builtin return; fi # When automatic shell integration is active, we were started in POSIX # mode and need to manually recreate the bash startup sequence. @@ -98,7 +96,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then fi # Import bash-preexec, safe to do multiple times -builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh" +builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh" # This is set to 1 when we're executing a command so that we don't # send prompt marks multiple times. diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 95519fe99..9838bfb53 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -287,8 +287,8 @@ fn initPages( // Initialize the first set of pages to contain our viewport so that // the top of the first page is always the active area. node.* = .{ - .data = Page.initBuf( - OffsetBuf.init(page_buf), + .data = .initBuf( + .init(page_buf), Page.layout(cap), ), }; @@ -472,7 +472,7 @@ pub fn clone( }; // Setup our pools - break :alloc try MemoryPool.init( + break :alloc try .init( alloc, std.heap.page_allocator, page_count, @@ -908,16 +908,6 @@ const ReflowCursor = struct { const cell = &cells[x]; x += 1; - // std.log.warn("\nsrc_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={} wide={}", .{ - // src_y, - // x, - // self.y, - // self.x, - // self.page.size.cols, - // cell.content.codepoint, - // cell.wide, - // }); - // Copy cell contents. switch (cell.content_tag) { .codepoint, @@ -937,8 +927,15 @@ const ReflowCursor = struct { }; // Decrement the source position so that when we - // loop we'll process this source cell again. + // loop we'll process this source cell again, + // since we can't copy it into a spacer head. x -= 1; + + // Move to the next row (this sets pending wrap + // which will cause us to wrap on the next + // iteration). + self.cursorForward(); + continue; } else { self.page_cell.* = cell.*; } @@ -990,6 +987,17 @@ const ReflowCursor = struct { self.page_cell.hyperlink = false; self.page_cell.style_id = stylepkg.default_id; + // std.log.warn("\nsrc_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={X} wide={} page_cell_wide={}", .{ + // src_y, + // x, + // self.y, + // self.x, + // self.page.size.cols, + // cell.content.codepoint, + // cell.wide, + // self.page_cell.wide, + // }); + // Copy grapheme data. if (cell.content_tag == .codepoint_grapheme) { // Copy the graphemes @@ -1201,7 +1209,7 @@ const ReflowCursor = struct { node.data.size.rows = 1; list.pages.insertAfter(self.node, node); - self.* = ReflowCursor.init(node); + self.* = .init(node); self.new_rows = new_rows; } @@ -1817,7 +1825,7 @@ pub fn grow(self: *PageList) !?*List.Node { @memset(buf, 0); // Initialize our new page and reinsert it as the last - first.data = Page.initBuf(OffsetBuf.init(buf), layout); + first.data = .initBuf(.init(buf), layout); first.data.size.rows = 1; self.pages.insertAfter(last, first); @@ -1989,7 +1997,7 @@ fn createPageExt( // to undefined, 0xAA. if (comptime std.debug.runtime_safety) @memset(page_buf, 0); - page.* = .{ .data = Page.initBuf(OffsetBuf.init(page_buf), layout) }; + page.* = .{ .data = .initBuf(.init(page_buf), layout) }; page.data.size.rows = 0; if (total_size) |v| { @@ -3572,6 +3580,74 @@ pub const Pin = struct { return result; } + /// Move the pin left n columns, stopping at the start of the row. + pub fn leftClamp(self: Pin, n: size.CellCountInt) Pin { + var result = self; + result.x -|= n; + return result; + } + + /// Move the pin right n columns, stopping at the end of the row. + pub fn rightClamp(self: Pin, n: size.CellCountInt) Pin { + var result = self; + result.x = @min(self.x +| n, self.node.data.size.cols - 1); + return result; + } + + /// Move the pin left n cells, wrapping to the previous row as needed. + /// + /// If the offset goes beyond the top of the screen, returns null. + /// + /// TODO: Unit tests. + pub fn leftWrap(self: Pin, n: usize) ?Pin { + // NOTE: This assumes that all pages have the same width, which may + // be violated under certain circumstances by incomplete reflow. + const cols = self.node.data.size.cols; + const remaining_in_row = self.x; + + if (n <= remaining_in_row) return self.left(n); + + const extra_after_remaining = n - remaining_in_row; + + const rows_off = 1 + extra_after_remaining / cols; + + switch (self.upOverflow(rows_off)) { + .offset => |v| { + var result = v; + result.x = @intCast(cols - extra_after_remaining % cols); + return result; + }, + .overflow => return null, + } + } + + /// Move the pin right n cells, wrapping to the next row as needed. + /// + /// If the offset goes beyond the bottom of the screen, returns null. + /// + /// TODO: Unit tests. + pub fn rightWrap(self: Pin, n: usize) ?Pin { + // NOTE: This assumes that all pages have the same width, which may + // be violated under certain circumstances by incomplete reflow. + const cols = self.node.data.size.cols; + const remaining_in_row = cols - self.x - 1; + + if (n <= remaining_in_row) return self.right(n); + + const extra_after_remaining = n - remaining_in_row; + + const rows_off = 1 + extra_after_remaining / cols; + + switch (self.downOverflow(rows_off)) { + .offset => |v| { + var result = v; + result.x = @intCast(extra_after_remaining % cols - 1); + return result; + }, + .overflow => return null, + } + } + /// Move the pin down a certain number of rows, or return null if /// the pin goes beyond the end of the screen. pub fn down(self: Pin, n: usize) ?Pin { @@ -8307,6 +8383,125 @@ test "PageList resize reflow less cols to wrap a wide char" { } } +test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // We want to make the screen look like this: + // + // 👨‍👨‍👦‍👦👨‍👨‍👦‍👦 + + // First family emoji at (0, 0) + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0x1F468 }, // First codepoint of the grapheme + .wide = .wide, + }; + try page.setGraphemes(rac.row, rac.cell, &.{ + 0x200D, 0x1F468, + 0x200D, 0x1F466, + 0x200D, 0x1F466, + }); + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_tail, + }; + } + // Second family emoji at (2, 0) + { + const rac = page.getRowAndCell(2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0x1F468 }, // First codepoint of the grapheme + .wide = .wide, + }; + try page.setGraphemes(rac.row, rac.cell, &.{ + 0x200D, 0x1F468, + 0x200D, 0x1F466, + 0x200D, 0x1F466, + }); + } + { + const rac = page.getRowAndCell(3, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 3, .reflow = true }); + try testing.expectEqual(@as(usize, 3), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 0x1F468), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, 6), cps.len); + try testing.expectEqual(@as(u21, 0x200D), cps[0]); + try testing.expectEqual(@as(u21, 0x1F468), cps[1]); + try testing.expectEqual(@as(u21, 0x200D), cps[2]); + try testing.expectEqual(@as(u21, 0x1F466), cps[3]); + try testing.expectEqual(@as(u21, 0x200D), cps[4]); + try testing.expectEqual(@as(u21, 0x1F466), cps[5]); + + // Row should be wrapped + try testing.expect(rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide); + } + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 0x1F468), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, 6), cps.len); + try testing.expectEqual(@as(u21, 0x200D), cps[0]); + try testing.expectEqual(@as(u21, 0x1F468), cps[1]); + try testing.expectEqual(@as(u21, 0x200D), cps[2]); + try testing.expectEqual(@as(u21, 0x1F466), cps[3]); + try testing.expectEqual(@as(u21, 0x200D), cps[4]); + try testing.expectEqual(@as(u21, 0x1F466), cps[5]); + } + { + const rac = page.getRowAndCell(1, 1); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } +} + test "PageList resize reflow less cols copy kitty placeholder" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 4e74f04ba..ec3f322f6 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -217,7 +217,7 @@ intermediates_idx: u8 = 0, /// Param tracking, building params: [MAX_PARAMS]u16 = undefined, -params_sep: Action.CSI.SepList = Action.CSI.SepList.initEmpty(), +params_sep: Action.CSI.SepList = .initEmpty(), params_idx: u8 = 0, param_acc: u16 = 0, param_acc_idx: u8 = 0, @@ -395,7 +395,7 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { pub fn clear(self: *Parser) void { self.intermediates_idx = 0; self.params_idx = 0; - self.params_sep = Action.CSI.SepList.initEmpty(); + self.params_sep = .initEmpty(); self.param_acc = 0; self.param_acc_idx = 0; } @@ -877,7 +877,10 @@ test "osc: change window title (end in esc)" { // https://github.com/darrenstarr/VtNetCore/pull/14 // Saw this on HN, decided to add a test case because why not. test "osc: 112 incomplete sequence" { - var p = init(); + var p: Parser = init(); + defer p.deinit(); + p.osc_parser.alloc = std.testing.allocator; + _ = p.next(0x1B); _ = p.next(']'); _ = p.next('1'); @@ -892,8 +895,20 @@ test "osc: 112 incomplete sequence" { try testing.expect(a[2] == null); const cmd = a[0].?.osc_dispatch; - try testing.expect(cmd == .reset_color); - try testing.expectEqual(cmd.reset_color.kind, .cursor); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .reset_cursor); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + osc.Command.ColorOperation.Kind.cursor, + op.reset, + ); + } + try std.testing.expect(it.next() == null); } } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 9ab4b23e2..5b772ab84 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -171,7 +171,7 @@ pub const SavedCursor = struct { /// State required for all charset operations. pub const CharsetState = struct { /// The list of graphical charsets by slot - charsets: CharsetArray = CharsetArray.initFill(charsets.Charset.utf8), + charsets: CharsetArray = .initFill(charsets.Charset.utf8), /// GL is the slot to use when using a 7-bit printable char (up to 127) /// GR used for 8-bit printable chars. @@ -402,32 +402,47 @@ pub fn clonePool( }; const start_pin = pin_remap.get(ordered.tl) orelse start: { - // No start means it is outside the cloned area. We change it - // to the top-left. + // No start means it is outside the cloned area. // If we have no end pin then either // (1) our whole selection is outside the cloned area or // (2) our cloned area is within the selection if (pin_remap.get(ordered.br) == null) { - // If our tl is before the cloned area and br is after - // the cloned area then the whole screen is selected. - // This detection is somewhat more expensive so we try - // to avoid it if possible so its nested in this if. + // We check if the selection bottom right pin is above + // the cloned area or if the top left pin is below the + // cloned area, in either of these cases it means that + // the selection is fully out of bounds, so we have no + // selection in the cloned area and break out now. const clone_top = self.pages.pin(top) orelse break :sel null; - if (!sel.contains(self, clone_top)) break :sel null; + const clone_top_y = self.pages.pointFromPin( + .screen, + clone_top, + ).?.screen.y; + if (self.pages.pointFromPin( + .screen, + ordered.br.*, + ).?.screen.y < clone_top_y) break :sel null; + if (self.pages.pointFromPin( + .screen, + ordered.tl.*, + ).?.screen.y > clone_top_y) break :sel null; } - break :start try pages.trackPin(.{ .node = pages.pages.first.? }); + // We move the top pin back in bounds to the top row. + break :start try pages.trackPin(.{ + .node = pages.pages.first.?, + .x = if (sel.rectangle) ordered.tl.x else 0, + }); }; - const end_pin = pin_remap.get(ordered.br) orelse end: { - // No end means it is outside the cloned area. We change it - // to the bottom-right. - break :end try pages.trackPin(pages.pin(.{ .active = .{ - .x = pages.cols - 1, - .y = pages.rows - 1, - } }) orelse break :sel null); - }; + // If we got to this point it means that the selection is not + // fully out of bounds, so we move the bottom right pin back + // in bounds if it isn't already. + const end_pin = pin_remap.get(ordered.br) orelse try pages.trackPin(.{ + .node = pages.pages.last.?, + .x = if (sel.rectangle) ordered.br.x else pages.cols - 1, + .y = pages.pages.last.?.data.size.rows - 1, + }); break :sel .{ .bounds = .{ .tracked = .{ @@ -2433,7 +2448,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { return null; }; - return Selection.init(start, end, false); + return .init(start, end, false); } /// Return the selection for all contents on the screen. Surrounding @@ -2489,7 +2504,7 @@ pub fn selectAll(self: *Screen) ?Selection { return null; }; - return Selection.init(start, end, false); + return .init(start, end, false); } /// Select the nearest word to start point that is between start_pt and @@ -2624,7 +2639,7 @@ pub fn selectWord(self: *Screen, pin: Pin) ?Selection { break :start prev; }; - return Selection.init(start, end, false); + return .init(start, end, false); } /// Select the command output under the given point. The limits of the output @@ -2724,7 +2739,7 @@ pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { break :boundary it_prev; }; - return Selection.init(start, end, false); + return .init(start, end, false); } /// Returns the selection bounds for the prompt at the given point. If the @@ -2805,7 +2820,7 @@ pub fn selectPrompt(self: *Screen, pin: Pin) ?Selection { break :end it_prev; }; - return Selection.init(start, end, false); + return .init(start, end, false); } pub const LineIterator = struct { @@ -5287,6 +5302,45 @@ test "Screen: clone contains subset of selection" { } } +test "Screen: clone contains subset of rectangle selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 4, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + + // Select the full screen from x=1 to x=3 + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 3 } }).?, + true, + )); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 1 } }, + .{ .active = .{ .y = 2 } }, + ); + defer s2.deinit(); + + // Our selection should remain valid and be properly clipped + // preserving the columns of the start and end points of the + // selection. + { + const sel = s2.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s2.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 3, + } }, s2.pages.pointFromPin(.active, sel.end()).?); + } +} + test "Screen: clone basic" { const testing = std.testing; const alloc = testing.allocator; @@ -7857,7 +7911,7 @@ test "Screen: selectOutput" { try s.testWriteString("input2\n"); // 3 try s.testWriteString("output2output2output2output2\n"); // 4, 5, 6 due to overflow try s.testWriteString("output2\n"); // 7 - try s.testWriteString("prompt3$ input3\n"); // 8 + try s.testWriteString("$ input3\n"); // 8 try s.testWriteString("output3\n"); // 9 try s.testWriteString("output3\n"); // 10 try s.testWriteString("output3"); // 11 @@ -7945,14 +7999,14 @@ test "Screen: selectOutput" { } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 9, - .y = 12, + .y = 11, } }, s.pages.pointFromPin(.active, sel.end()).?); } // input / prompt at y = 0, pt.y = 0 { s.deinit(); s = try init(alloc, 10, 5, 0); - try s.testWriteString("prompt1$ input1\n"); + try s.testWriteString("$ input1\n"); try s.testWriteString("output1\n"); try s.testWriteString("prompt2\n"); { @@ -7988,7 +8042,7 @@ test "Screen: selectPrompt basics" { try s.testWriteString("input2\n"); // 3 try s.testWriteString("output2\n"); // 4 try s.testWriteString("output2\n"); // 5 - try s.testWriteString("prompt3$ input3\n"); // 6 + try s.testWriteString("$ input3\n"); // 6 try s.testWriteString("output3\n"); // 7 try s.testWriteString("output3\n"); // 8 try s.testWriteString("output3"); // 9 @@ -8203,7 +8257,7 @@ test "Screen: promptPath" { try s.testWriteString("input2\n"); // 3 try s.testWriteString("output2\n"); // 4 try s.testWriteString("output2\n"); // 5 - try s.testWriteString("prompt3$ input3\n"); // 6 + try s.testWriteString("$ input3\n"); // 6 try s.testWriteString("output3\n"); // 7 try s.testWriteString("output3\n"); // 8 try s.testWriteString("output3"); // 9 diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index a90595d20..267f223d5 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -228,7 +228,7 @@ pub fn order(self: Selection, s: *const Screen) Order { /// Note that only forward and reverse are useful desired orders for this /// function. All other orders act as if forward order was desired. pub fn ordered(self: Selection, s: *const Screen, desired: Order) Selection { - if (self.order(s) == desired) return Selection.init( + if (self.order(s) == desired) return .init( self.start(), self.end(), self.rectangle, @@ -237,9 +237,9 @@ pub fn ordered(self: Selection, s: *const Screen, desired: Order) Selection { const tl = self.topLeft(s); const br = self.bottomRight(s); return switch (desired) { - .forward => Selection.init(tl, br, self.rectangle), - .reverse => Selection.init(br, tl, self.rectangle), - else => Selection.init(tl, br, self.rectangle), + .forward => .init(tl, br, self.rectangle), + .reverse => .init(br, tl, self.rectangle), + else => .init(tl, br, self.rectangle), }; } diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig index 9892c13df..dde69d25e 100644 --- a/src/terminal/StringMap.zig +++ b/src/terminal/StringMap.zig @@ -80,7 +80,7 @@ pub const Match = struct { const end_idx: usize = @intCast(self.region.ends()[0] - 1); const start_pt = self.map.map[self.offset + start_idx]; const end_pt = self.map.map[self.offset + end_idx]; - return Selection.init(start_pt, end_pt, false); + return .init(start_pt, end_pt, false); } }; diff --git a/src/terminal/Tabstops.zig b/src/terminal/Tabstops.zig index 5a54fb28b..4ab5133d9 100644 --- a/src/terminal/Tabstops.zig +++ b/src/terminal/Tabstops.zig @@ -44,7 +44,7 @@ const masks = blk: { cols: usize = 0, /// Preallocated tab stops. -prealloc_stops: [prealloc_count]Unit = [1]Unit{0} ** prealloc_count, +prealloc_stops: [prealloc_count]Unit = @splat(0), /// Dynamically expanded stops above prealloc stops. dynamic_stops: []Unit = &[0]Unit{}, diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index efb9684eb..be7a58f9b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -79,7 +79,7 @@ default_palette: color.Palette = color.default, color_palette: struct { const Mask = std.StaticBitSet(@typeInfo(color.Palette).array.len); colors: color.Palette = color.default, - mask: Mask = Mask.initEmpty(), + mask: Mask = .initEmpty(), } = .{}, /// The previous printed character. This is used for the repeat previous @@ -210,9 +210,9 @@ pub fn init( .cols = cols, .rows = rows, .active_screen = .primary, - .screen = try Screen.init(alloc, cols, rows, opts.max_scrollback), - .secondary_screen = try Screen.init(alloc, cols, rows, 0), - .tabstops = try Tabstops.init(alloc, cols, TABSTOP_INTERVAL), + .screen = try .init(alloc, cols, rows, opts.max_scrollback), + .secondary_screen = try .init(alloc, cols, rows, 0), + .tabstops = try .init(alloc, cols, TABSTOP_INTERVAL), .scrolling_region = .{ .top = 0, .bottom = rows - 1, @@ -2329,7 +2329,7 @@ pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { try writer.writeByte('0'); const pen = self.screen.cursor.style; - var attrs = [_]u8{0} ** 8; + var attrs: [8]u8 = @splat(0); var i: usize = 0; if (pen.flags.bold) { @@ -2454,7 +2454,7 @@ pub fn resize( // Resize our tabstops if (self.cols != cols) { self.tabstops.deinit(alloc); - self.tabstops = try Tabstops.init(alloc, cols, 8); + self.tabstops = try .init(alloc, cols, 8); } // If we're making the screen smaller, dealloc the unused items. @@ -2515,39 +2515,37 @@ pub fn getScreen(self: *Terminal, t: ScreenType) *Screen { &self.secondary_screen; } -/// Options for switching to the alternate screen. -pub const AlternateScreenOptions = struct { - cursor_save: bool = false, - clear_on_enter: bool = false, - clear_on_exit: bool = false, -}; - -/// Switch to the alternate screen buffer. +/// Switch to the given screen type (alternate or primary). /// -/// The alternate screen buffer: -/// * has its own grid -/// * has its own cursor state (included saved cursor) -/// * does not support scrollback +/// This does NOT handle behaviors such as clearing the screen, +/// copying the cursor, etc. This should be handled by downstream +/// callers. /// -pub fn alternateScreen( - self: *Terminal, - options: AlternateScreenOptions, -) void { - //log.info("alt screen active={} options={} cursor={}", .{ self.active_screen, options, self.screen.cursor }); +/// After calling this function, the `self.screen` field will point +/// to the current screen, and the returned value will be the previous +/// screen. If the return value is null, then the screen was not +/// switched because it was already the active screen. +/// +/// Note: This is written in a generic way so that we can support +/// more than two screens in the future if needed. There isn't +/// currently a spec for this, but it is something I think might +/// be useful in the future. +pub fn switchScreen(self: *Terminal, t: ScreenType) ?*Screen { + // If we're already on the requested screen we do nothing. + if (self.active_screen == t) return null; - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - // for now, we ignore... - if (self.active_screen == .alternate) return; - - // If we requested cursor save, we save the cursor in the primary screen - if (options.cursor_save) self.saveCursor(); + // We always end hyperlink state when switching screens. + // We need to do this on the original screen. + self.screen.endHyperlink(); // Switch the screens const old = self.screen; self.screen = self.secondary_screen; self.secondary_screen = old; - self.active_screen = .alternate; + self.active_screen = t; + + // The new screen should not have any hyperlinks set + assert(self.screen.cursor.hyperlink_id == 0); // Bring our charset state with us self.screen.charset = old.charset; @@ -2555,62 +2553,122 @@ pub fn alternateScreen( // Clear our selection self.screen.clearSelection(); - // Mark kitty images as dirty so they redraw + // Mark kitty images as dirty so they redraw. Without this set + // the images will remain where they were (the dirty bit on + // the screen only tracks the terminal grid, not the images). self.screen.kitty_images.dirty = true; - // Mark our terminal as dirty + // Mark our terminal as dirty to redraw the grid. self.flags.dirty.clear = true; - // Bring our pen with us - self.screen.cursorCopy(old.cursor, .{ - .hyperlink = false, - }) catch |err| { - log.warn("cursor copy failed entering alt screen err={}", .{err}); - }; + return &self.secondary_screen; +} - if (options.clear_on_enter) { - self.eraseDisplay(.complete, false); +/// Switch screen via a mode switch (e.g. mode 47, 1047, 1049). +/// This is a much more opinionated operation than `switchScreen` +/// since it also handles the behaviors of the specific mode, +/// such as clearing the screen, saving/restoring the cursor, +/// etc. +/// +/// This should be used for legacy compatibility with VT protocols, +/// but more modern usage should use `switchScreen` instead and handle +/// details like clearing the screen, cursor saving, etc. manually. +pub fn switchScreenMode( + self: *Terminal, + mode: SwitchScreenMode, + enabled: bool, +) void { + // The behavior in this function is completely based on reading + // the xterm source, specifically "charproc.c" for + // `srm_ALTBUF`, `srm_OPT_ALTBUF`, and `srm_OPT_ALTBUF_CURSOR`. + // We shouldn't touch anything in here without adding a unit + // test AND verifying the behavior with xterm. + + switch (mode) { + .@"47" => {}, + + // If we're disabling 1047 and we're on alt screen then + // we clear the screen. + .@"1047" => if (!enabled and self.active_screen == .alternate) { + self.eraseDisplay(.complete, false); + }, + + // 1049 unconditionally saves the cursor on enabling, even + // if we're already on the alternate screen. + .@"1049" => if (enabled) self.saveCursor(), + } + + // Switch screens first to whatever we're going to. + const to: ScreenType = if (enabled) .alternate else .primary; + const old_ = self.switchScreen(to); + + switch (mode) { + // For these modes, we need to copy the cursor. We only copy + // the cursor if the screen actually changed, otherwise the + // cursor is already copied. The cursor is copied regardless + // of destination screen. + .@"47", .@"1047" => if (old_) |old| { + self.screen.cursorCopy(old.cursor, .{ + .hyperlink = false, + }) catch |err| { + log.warn( + "cursor copy failed entering alt screen err={}", + .{err}, + ); + }; + }, + + // Mode 1049 restores cursor on the primary screen when + // we disable it. + .@"1049" => if (enabled) { + assert(self.active_screen == .alternate); + self.eraseDisplay(.complete, false); + + // When we enter alt screen with 1049, we always copy the + // cursor from the primary screen (if we weren't already + // on it). + if (old_) |old| { + self.screen.cursorCopy(old.cursor, .{ + .hyperlink = false, + }) catch |err| { + log.warn( + "cursor copy failed entering alt screen err={}", + .{err}, + ); + }; + } + } else { + assert(self.active_screen == .primary); + self.restoreCursor() catch |err| { + log.warn( + "restore cursor on switch screen failed to={} err={}", + .{ to, err }, + ); + }; + }, } } -/// Switch back to the primary screen (reset alternate screen mode). -pub fn primaryScreen( - self: *Terminal, - options: AlternateScreenOptions, -) void { - //log.info("primary screen active={} options={}", .{ self.active_screen, options }); +/// Modal screen changes. These map to the literal terminal +/// modes to enable or disable alternate screen modes. They each +/// have subtle behaviors so we define them as an enum here. +pub const SwitchScreenMode = enum { + /// Legacy alternate screen mode. This goes to the alternate + /// screen or primary screen and only copies the cursor. The + /// screen is not erased. + @"47", - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - if (self.active_screen == .primary) return; + /// Alternate screen mode where the alternate screen is cleared + /// on exit. The primary screen is never cleared. The cursor is + /// copied. + @"1047", - if (options.clear_on_exit) self.eraseDisplay(.complete, false); - - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .primary; - - // Clear our selection - self.screen.clearSelection(); - - // Mark kitty images as dirty so they redraw - self.screen.kitty_images.dirty = true; - - // Mark our terminal as dirty - self.flags.dirty.clear = true; - - // We always end hyperlink state - self.screen.endHyperlink(); - - // Restore the cursor from the primary screen. This should not - // fail because we should not have to allocate memory since swapping - // screens does not create new cursors. - if (options.cursor_save) self.restoreCursor() catch |err| { - log.warn("restore cursor on primary screen failed err={}", .{err}); - }; -} + /// Save primary screen cursor, switch to alternate screen, + /// and clear the alternate screen on entry. On exit, + /// do not clear the screen, and restore the cursor on the + /// primary screen. + @"1049", +}; /// Return the current string value of the terminal. Newlines are /// encoded as "\n". This omits any formatting such as fg/bg. @@ -9203,37 +9261,6 @@ test "Terminal: saveCursor" { try testing.expect(t.modes.get(.origin)); } -test "Terminal: saveCursor with screen change" { - const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 3 }); - defer t.deinit(alloc); - - try t.setAttribute(.{ .bold = {} }); - t.setCursorPos(t.screen.cursor.y + 1, 3); - try testing.expect(t.screen.cursor.x == 2); - t.screen.charset.gr = .G3; - t.modes.set(.origin, true); - t.alternateScreen(.{ - .cursor_save = true, - .clear_on_enter = true, - }); - // make sure our cursor and charset have come with us - try testing.expect(t.screen.cursor.style.flags.bold); - try testing.expect(t.screen.cursor.x == 2); - try testing.expect(t.screen.charset.gr == .G3); - try testing.expect(t.modes.get(.origin)); - t.screen.charset.gr = .G0; - try t.setAttribute(.{ .reset_bold = {} }); - t.modes.set(.origin, false); - t.primaryScreen(.{ - .cursor_save = true, - .clear_on_enter = true, - }); - try testing.expect(t.screen.cursor.style.flags.bold); - try testing.expect(t.screen.charset.gr == .G3); - try testing.expect(t.modes.get(.origin)); -} - test "Terminal: saveCursor position" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 10, .rows = 5 }); @@ -10472,7 +10499,7 @@ test "Terminal: cursorIsAtPrompt alternate screen" { try testing.expect(t.cursorIsAtPrompt()); // Secondary screen is never a prompt - t.alternateScreen(.{}); + t.switchScreenMode(.@"1049", true); try testing.expect(!t.cursorIsAtPrompt()); t.markSemanticPrompt(.prompt); try testing.expect(!t.cursorIsAtPrompt()); @@ -10556,7 +10583,7 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" { var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - t.alternateScreen(.{}); + t.switchScreenMode(.@"1049", true); t.screen.kitty_keyboard.push(.{ .disambiguate = true, .report_events = false, @@ -10564,7 +10591,7 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" { .report_all = true, .report_associated = true, }); - t.primaryScreen(.{}); + t.switchScreenMode(.@"1049", false); t.fullReset(); try testing.expectEqual(0, t.secondary_screen.kitty_keyboard.current().int()); @@ -10869,3 +10896,236 @@ test "Terminal: DECCOLM resets scroll region" { try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); try testing.expectEqual(@as(usize, 79), t.scrolling_region.right); } + +test "Terminal: mode 47 alt screen plain" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Print on primary screen + try t.printString("1A"); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"47", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Print on alt screen. This should be off center because + // we copy the cursor over from the primary screen + try t.printString("2B"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } + + // Go back to primary + t.switchScreenMode(.@"47", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Primary screen should still have the original content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A", str); + } + + // Go back to alt screen with mode 47 + t.switchScreenMode(.@"47", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should retain content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } +} + +test "Terminal: mode 47 copies cursor both directions" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Color our cursor red + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"47", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Verify that our style is set + { + try testing.expect(t.screen.cursor.style_id != style.default_id); + const page = &t.screen.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + } + + // Set a new style + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } }); + + // Go back to primary + t.switchScreenMode(.@"47", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Verify that our style is still set + { + try testing.expect(t.screen.cursor.style_id != style.default_id); + const page = &t.screen.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + } +} + +test "Terminal: mode 1047 alt screen plain" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Print on primary screen + try t.printString("1A"); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"1047", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Print on alt screen. This should be off center because + // we copy the cursor over from the primary screen + try t.printString("2B"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } + + // Go back to primary + t.switchScreenMode(.@"1047", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Primary screen should still have the original content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A", str); + } + + // Go back to alt screen with mode 1047 + t.switchScreenMode(.@"1047", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: mode 1047 copies cursor both directions" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Color our cursor red + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"1047", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Verify that our style is set + { + try testing.expect(t.screen.cursor.style_id != style.default_id); + const page = &t.screen.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + } + + // Set a new style + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } }); + + // Go back to primary + t.switchScreenMode(.@"1047", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Verify that our style is still set + { + try testing.expect(t.screen.cursor.style_id != style.default_id); + const page = &t.screen.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + } +} + +test "Terminal: mode 1049 alt screen plain" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Print on primary screen + try t.printString("1A"); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"1049", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Print on alt screen. This should be off center because + // we copy the cursor over from the primary screen + try t.printString("2B"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } + + // Go back to primary + t.switchScreenMode(.@"1049", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Primary screen should still have the original content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A", str); + } + + // Write, our cursor should be restored back. + try t.printString("C"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1AC", str); + } + + // Go back to alt screen with mode 1049 + t.switchScreenMode(.@"1049", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index f96d39831..68d968768 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -403,7 +403,7 @@ test "BitmapAllocator alloc sequentially" { const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); defer alloc.free(buf); - var bm = Alloc.init(OffsetBuf.init(buf), layout); + var bm = Alloc.init(.init(buf), layout); const ptr = try bm.alloc(u8, buf, 1); ptr[0] = 'A'; @@ -429,7 +429,7 @@ test "BitmapAllocator alloc non-byte" { const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); defer alloc.free(buf); - var bm = Alloc.init(OffsetBuf.init(buf), layout); + var bm = Alloc.init(.init(buf), layout); const ptr = try bm.alloc(u21, buf, 1); ptr[0] = 'A'; @@ -453,7 +453,7 @@ test "BitmapAllocator alloc non-byte multi-chunk" { const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); defer alloc.free(buf); - var bm = Alloc.init(OffsetBuf.init(buf), layout); + var bm = Alloc.init(.init(buf), layout); const ptr = try bm.alloc(u21, buf, 6); try testing.expectEqual(@as(usize, 6), ptr.len); for (ptr) |*v| v.* = 'A'; @@ -478,7 +478,7 @@ test "BitmapAllocator alloc large" { const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); defer alloc.free(buf); - var bm = Alloc.init(OffsetBuf.init(buf), layout); + var bm = Alloc.init(.init(buf), layout); const ptr = try bm.alloc(u8, buf, 129); ptr[0] = 'A'; bm.free(buf, ptr); diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index 0cc17a747..9a16be3b2 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -893,7 +893,7 @@ test "HashMap basic usage" { const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); const count = 5; var i: u32 = 0; @@ -927,7 +927,7 @@ test "HashMap ensureTotalCapacity" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); const initial_capacity = map.capacity(); try testing.expect(initial_capacity >= 20); @@ -947,7 +947,7 @@ test "HashMap ensureUnusedCapacity with tombstones" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: i32 = 0; while (i < 100) : (i += 1) { @@ -965,7 +965,7 @@ test "HashMap clearRetainingCapacity" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); map.clearRetainingCapacity(); @@ -996,7 +996,7 @@ test "HashMap ensureTotalCapacity with existing elements" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); try map.put(0, 0); try expectEqual(map.count(), 1); @@ -1015,7 +1015,7 @@ test "HashMap remove" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1053,7 +1053,7 @@ test "HashMap reverse removes" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1081,7 +1081,7 @@ test "HashMap multiple removes on same metadata" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1124,7 +1124,7 @@ test "HashMap put and remove loop in random order" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var keys = std.ArrayList(u32).init(alloc); defer keys.deinit(); @@ -1162,7 +1162,7 @@ test "HashMap put" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1193,7 +1193,7 @@ test "HashMap put full load" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); for (0..cap) |i| try map.put(i, i); for (0..cap) |i| try expectEqual(map.get(i).?, i); @@ -1209,7 +1209,7 @@ test "HashMap putAssumeCapacity" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 20) : (i += 1) { @@ -1244,7 +1244,7 @@ test "HashMap repeat putAssumeCapacity/remove" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); const limit = cap; @@ -1280,7 +1280,7 @@ test "HashMap getOrPut" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 10) : (i += 1) { @@ -1309,7 +1309,7 @@ test "HashMap basic hash map usage" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); try testing.expect((try map.fetchPut(1, 11)) == null); try testing.expect((try map.fetchPut(2, 22)) == null); @@ -1360,7 +1360,7 @@ test "HashMap ensureUnusedCapacity" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); try map.ensureUnusedCapacity(32); try testing.expectError(error.OutOfMemory, map.ensureUnusedCapacity(cap + 1)); @@ -1374,7 +1374,7 @@ test "HashMap removeByPtr" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: i32 = undefined; i = 0; @@ -1405,7 +1405,7 @@ test "HashMap removeByPtr 0 sized key" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); try map.put(0, 0); @@ -1429,7 +1429,7 @@ test "HashMap repeat fetchRemove" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); map.putAssumeCapacity(0, {}); map.putAssumeCapacity(1, {}); @@ -1457,7 +1457,7 @@ test "OffsetHashMap basic usage" { const layout = OffsetMap.layout(cap); const buf = try alloc.alignedAlloc(u8, OffsetMap.base_align, layout.total_size); defer alloc.free(buf); - var offset_map = OffsetMap.init(OffsetBuf.init(buf), layout); + var offset_map = OffsetMap.init(.init(buf), layout); var map = offset_map.map(buf.ptr); const count = 5; @@ -1492,7 +1492,7 @@ test "OffsetHashMap remake map" { const layout = OffsetMap.layout(cap); const buf = try alloc.alignedAlloc(u8, OffsetMap.base_align, layout.total_size); defer alloc.free(buf); - var offset_map = OffsetMap.init(OffsetBuf.init(buf), layout); + var offset_map = OffsetMap.init(.init(buf), layout); { var map = offset_map.map(buf.ptr); diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index 61ba33a4d..adc6edafe 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -155,17 +155,17 @@ pub const Parser = struct { break :action c; }; const control: Command.Control = switch (action) { - 'q' => .{ .query = try Transmission.parse(self.kv) }, - 't' => .{ .transmit = try Transmission.parse(self.kv) }, + 'q' => .{ .query = try .parse(self.kv) }, + 't' => .{ .transmit = try .parse(self.kv) }, 'T' => .{ .transmit_and_display = .{ - .transmission = try Transmission.parse(self.kv), - .display = try Display.parse(self.kv), + .transmission = try .parse(self.kv), + .display = try .parse(self.kv), } }, - 'p' => .{ .display = try Display.parse(self.kv) }, - 'd' => .{ .delete = try Delete.parse(self.kv) }, - 'f' => .{ .transmit_animation_frame = try AnimationFrameLoading.parse(self.kv) }, - 'a' => .{ .control_animation = try AnimationControl.parse(self.kv) }, - 'c' => .{ .compose_animation = try AnimationFrameComposition.parse(self.kv) }, + 'p' => .{ .display = try .parse(self.kv) }, + 'd' => .{ .delete = try .parse(self.kv) }, + 'f' => .{ .transmit_animation_frame = try .parse(self.kv) }, + 'a' => .{ .control_animation = try .parse(self.kv) }, + 'c' => .{ .compose_animation = try .parse(self.kv) }, else => return error.InvalidFormat, }; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 25c819b10..f917c104a 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -324,7 +324,7 @@ fn loadAndAddImage( } break :loading loading.*; - } else try LoadingImage.init(alloc, cmd); + } else try .init(alloc, cmd); // We only want to deinit on error. If we're chunking, then we don't // want to deinit at all. If we're not chunking, then we'll deinit diff --git a/src/terminal/kitty/key.zig b/src/terminal/kitty/key.zig index 8bafcb7dc..0883c90f2 100644 --- a/src/terminal/kitty/key.zig +++ b/src/terminal/kitty/key.zig @@ -8,7 +8,7 @@ const std = @import("std"); pub const FlagStack = struct { const len = 8; - flags: [len]Flags = .{Flags{}} ** len, + flags: [len]Flags = @splat(.{}), idx: u3 = 0, /// Return the current stack value @@ -51,7 +51,7 @@ pub const FlagStack = struct { // could send a huge number of pop commands to waste cpu. if (n >= self.flags.len) { self.idx = 0; - self.flags = .{Flags{}} ** len; + self.flags = @splat(.{}); return; } diff --git a/src/terminal/main.zig b/src/terminal/main.zig index df3788d30..74ffe6341 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -35,6 +35,7 @@ pub const Page = page.Page; pub const PageList = @import("PageList.zig"); pub const Parser = @import("Parser.zig"); pub const Pin = PageList.Pin; +pub const Point = point.Point; pub const Screen = @import("Screen.zig"); pub const ScreenType = Terminal.ScreenType; pub const Selection = @import("Selection.zig"); diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index 60ecc7698..9a74db73c 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -206,6 +206,7 @@ const entries: []const ModeEntry = &.{ .{ .name = "cursor_visible", .value = 25, .default = true }, .{ .name = "enable_mode_3", .value = 40 }, .{ .name = "reverse_wrap", .value = 45 }, + .{ .name = "alt_screen_legacy", .value = 47 }, .{ .name = "keypad_keys", .value = 66 }, .{ .name = "enable_left_and_right_margin", .value = 69 }, .{ .name = "mouse_event_normal", .value = 1000 }, @@ -222,6 +223,7 @@ const entries: []const ModeEntry = &.{ .{ .name = "alt_sends_escape", .value = 1039 }, .{ .name = "reverse_wrap_extended", .value = 1045 }, .{ .name = "alt_screen", .value = 1047 }, + .{ .name = "save_cursor", .value = 1048 }, .{ .name = "alt_screen_save_cursor_clear_enter", .value = 1049 }, .{ .name = "bracketed_paste", .value = 2004 }, .{ .name = "synchronized_output", .value = 2026 }, diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index ce7afdf64..d0b59e834 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -109,37 +109,21 @@ pub const Command = union(enum) { value: []const u8, }, - /// OSC 4, OSC 10, and OSC 11 color report. - report_color: struct { - /// OSC 4 requests a palette color, OSC 10 requests the foreground - /// color, OSC 11 the background color. - kind: ColorKind, - - /// We must reply with the same string terminator (ST) as used in the - /// request. + /// OSC color operations to set, reset, or report color settings. Some OSCs + /// allow multiple operations to be specified in a single OSC so we need a + /// list-like datastructure to manage them. We use std.SegmentedList because + /// it minimizes the number of allocations and copies because a large + /// majority of the time there will be only one operation per OSC. + /// + /// Currently, these OSCs are handled by `color_operation`: + /// + /// 4, 10, 11, 12, 104, 110, 111, 112 + color_operation: struct { + source: ColorOperation.Source, + operations: ColorOperation.List = .{}, terminator: Terminator = .st, }, - /// Modify the foreground (OSC 10) or background color (OSC 11), or a palette color (OSC 4) - set_color: struct { - /// OSC 4 sets a palette color, OSC 10 sets the foreground color, OSC 11 - /// the background color. - kind: ColorKind, - - /// The color spec as a string - value: []const u8, - }, - - /// Reset a palette color (OSC 104) or the foreground (OSC 110), background - /// (OSC 111), or cursor (OSC 112) color. - reset_color: struct { - kind: ColorKind, - - /// OSC 104 can have parameters indicating which palette colors to - /// reset. - value: []const u8, - }, - /// Kitty color protocol, OSC 21 /// https://sw.kovidgoyal.net/kitty/color-stack/#id1 kitty_color_protocol: kitty.color.OSC, @@ -182,20 +166,44 @@ pub const Command = union(enum) { /// Wait input (OSC 9;5) wait_input: void, - pub const ColorKind = union(enum) { - palette: u8, - foreground, - background, - cursor, + pub const ColorOperation = union(enum) { + pub const Source = enum(u16) { + // these numbers are based on the OSC operation code + // see https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands + get_set_palette = 4, + get_set_foreground = 10, + get_set_background = 11, + get_set_cursor = 12, + reset_palette = 104, + reset_foreground = 110, + reset_background = 111, + reset_cursor = 112, - pub fn code(self: ColorKind) []const u8 { - return switch (self) { - .palette => "4", - .foreground => "10", - .background => "11", - .cursor => "12", - }; - } + pub fn format( + self: Source, + comptime _: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + try std.fmt.formatInt(@intFromEnum(self), 10, .lower, options, writer); + } + }; + + pub const List = std.SegmentedList(ColorOperation, 2); + + pub const Kind = union(enum) { + palette: u8, + foreground, + background, + cursor, + }; + + set: struct { + kind: Kind, + color: RGB, + }, + reset: Kind, + report: Kind, }; pub const ProgressState = enum { @@ -205,6 +213,15 @@ pub const Command = union(enum) { indeterminate, pause, }; + + comptime { + assert(@sizeOf(Command) == switch (@sizeOf(usize)) { + 4 => 44, + 8 => 64, + else => unreachable, + }); + // @compileLog(@sizeOf(Command)); + } }; /// The terminator used to end an OSC command. For OSC commands that demand @@ -234,6 +251,15 @@ pub const Terminator = enum { .bel => "\x07", }; } + + pub fn format( + self: Terminator, + comptime _: []const u8, + _: std.fmt.FormatOptions, + writer: anytype, + ) !void { + try writer.writeAll(self.string()); + } }; pub const Parser = struct { @@ -288,6 +314,7 @@ pub const Parser = struct { @"0", @"1", @"10", + @"104", @"11", @"12", @"13", @@ -304,15 +331,6 @@ pub const Parser = struct { @"8", @"9", - // OSC 10 is used to query or set the current foreground color. - query_fg_color, - - // OSC 11 is used to query or set the current background color. - query_bg_color, - - // OSC 12 is used to query or set the current cursor color. - query_cursor_color, - // We're in a semantic prompt OSC command but we aren't sure // what the command is yet, i.e. `133;` semantic_prompt, @@ -327,17 +345,26 @@ pub const Parser = struct { clipboard_kind_end, // Get/set color palette index - color_palette_index, - color_palette_index_end, + osc_4_index, + osc_4_color, + + // Get/set foreground color + osc_10, + + // Get/set background color + osc_11, + + // Get/set cursor color + osc_12, + + // Reset color palette index + osc_104, // Hyperlinks hyperlink_param_key, hyperlink_param_value, hyperlink_uri, - // Reset color palette index - reset_color_palette_index, - // rxvt extension. Only used for OSC 777 and only the value "notify" is // supported rxvt_extension, @@ -423,6 +450,10 @@ pub const Parser = struct { v.list.deinit(); self.command = default; }, + .color_operation => |*v| { + v.operations.deinit(self.alloc.?); + self.command = default; + }, else => {}, } } @@ -502,41 +533,123 @@ pub const Parser = struct { }, .@"10" => switch (c) { - ';' => self.state = .query_fg_color, - '4' => { - self.command = .{ .reset_color = .{ - .kind = .{ .palette = 0 }, - .value = "", - } }; + ';' => osc_10: { + if (self.alloc == null) { + log.warn("OSC 10 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_10; + } + self.command = .{ + .color_operation = .{ + .source = .get_set_foreground, + }, + }; + self.state = .osc_10; + self.buf_start = self.buf_idx; + self.complete = true; + }, + '4' => self.state = .@"104", + else => self.state = .invalid, + }, - self.state = .reset_color_palette_index; + .osc_10, .osc_11, .osc_12 => switch (c) { + ';' => self.parseOSC101112(false), + else => {}, + }, + + .@"104" => switch (c) { + ';' => osc_104: { + if (self.alloc == null) { + log.warn("OSC 104 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_104; + } + self.command = .{ + .color_operation = .{ + .source = .reset_palette, + }, + }; + self.state = .osc_104; + self.buf_start = self.buf_idx; self.complete = true; }, else => self.state = .invalid, }, + .osc_104 => switch (c) { + ';' => self.parseOSC104(false), + else => {}, + }, + .@"11" => switch (c) { - ';' => self.state = .query_bg_color, - '0' => { - self.command = .{ .reset_color = .{ .kind = .foreground, .value = undefined } }; + ';' => osc_11: { + if (self.alloc == null) { + log.warn("OSC 11 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_11; + } + self.command = .{ + .color_operation = .{ + .source = .get_set_background, + }, + }; + self.state = .osc_11; + self.buf_start = self.buf_idx; self.complete = true; - self.state = .invalid; }, - '1' => { - self.command = .{ .reset_color = .{ .kind = .background, .value = undefined } }; + '0'...'2' => blk: { + if (self.alloc == null) { + log.warn("OSC 11{c} requires an allocator, but none was provided", .{c}); + self.state = .invalid; + break :blk; + } + + const alloc = self.alloc orelse return; + + self.command = .{ + .color_operation = .{ + .source = switch (c) { + '0' => .reset_foreground, + '1' => .reset_background, + '2' => .reset_cursor, + else => unreachable, + }, + }, + }; + const op = self.command.color_operation.operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .reset = switch (c) { + '0' => .foreground, + '1' => .background, + '2' => .cursor, + else => unreachable, + }, + }; + self.state = .swallow; self.complete = true; - self.state = .invalid; - }, - '2' => { - self.command = .{ .reset_color = .{ .kind = .cursor, .value = undefined } }; - self.complete = true; - self.state = .invalid; }, else => self.state = .invalid, }, .@"12" => switch (c) { - ';' => self.state = .query_cursor_color, + ';' => osc_12: { + if (self.alloc == null) { + log.warn("OSC 12 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_12; + } + self.command = .{ + .color_operation = .{ + .source = .get_set_cursor, + }, + }; + self.state = .osc_12; + self.buf_start = self.buf_idx; + self.complete = true; + }, else => self.state = .invalid, }, @@ -621,64 +734,35 @@ pub const Parser = struct { }, .@"4" => switch (c) { - ';' => { - self.state = .color_palette_index; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .color_palette_index => switch (c) { - '0'...'9' => {}, - ';' => blk: { - const str = self.buf[self.buf_start .. self.buf_idx - 1]; - if (str.len == 0) { + ';' => osc_4: { + if (self.alloc == null) { + log.info("OSC 4 requires an allocator, but none was provided", .{}); self.state = .invalid; - break :blk; + break :osc_4; } - - if (std.fmt.parseUnsigned(u8, str, 10)) |num| { - self.state = .color_palette_index_end; - self.temp_state = .{ .num = num }; - } else |err| switch (err) { - error.Overflow => self.state = .invalid, - error.InvalidCharacter => unreachable, - } - }, - else => self.state = .invalid, - }, - - .color_palette_index_end => switch (c) { - '?' => { - self.command = .{ .report_color = .{ - .kind = .{ .palette = @intCast(self.temp_state.num) }, - } }; - + self.command = .{ + .color_operation = .{ + .source = .get_set_palette, + }, + }; + self.state = .osc_4_index; + self.buf_start = self.buf_idx; self.complete = true; }, - else => { - self.command = .{ .set_color = .{ - .kind = .{ .palette = @intCast(self.temp_state.num) }, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, + else => self.state = .invalid, }, - .reset_color_palette_index => switch (c) { + .osc_4_index => switch (c) { + ';' => self.state = .osc_4_color, + else => {}, + }, + + .osc_4_color => switch (c) { ';' => { - self.state = .string; - self.temp_state = .{ .str = &self.command.reset_color.value }; - self.buf_start = self.buf_idx; - self.complete = false; - }, - else => { - self.state = .invalid; - self.complete = false; + self.parseOSC4(false); + self.state = .osc_4_index; }, + else => {}, }, .@"5" => switch (c) { @@ -969,60 +1053,6 @@ pub const Parser = struct { }, }, - .query_fg_color => switch (c) { - '?' => { - self.command = .{ .report_color = .{ .kind = .foreground } }; - self.complete = true; - self.state = .invalid; - }, - else => { - self.command = .{ .set_color = .{ - .kind = .foreground, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, - }, - - .query_bg_color => switch (c) { - '?' => { - self.command = .{ .report_color = .{ .kind = .background } }; - self.complete = true; - self.state = .invalid; - }, - else => { - self.command = .{ .set_color = .{ - .kind = .background, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, - }, - - .query_cursor_color => switch (c) { - '?' => { - self.command = .{ .report_color = .{ .kind = .cursor } }; - self.complete = true; - self.state = .invalid; - }, - else => { - self.command = .{ .set_color = .{ - .kind = .cursor, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, - }, - .semantic_prompt => switch (c) { 'A' => { self.state = .semantic_option_start; @@ -1327,6 +1357,173 @@ pub const Parser = struct { self.temp_state.str.* = list.items; } + fn parseOSC4(self: *Parser, final: bool) void { + assert(self.state == .osc_4_color); + assert(self.command == .color_operation); + assert(self.command.color_operation.source == .get_set_palette); + + const alloc = self.alloc orelse return; + const operations = &self.command.color_operation.operations; + + const str = self.buf[self.buf_start .. self.buf_idx - (1 - @intFromBool(final))]; + self.buf_start = 0; + self.buf_idx = 0; + + var it = std.mem.splitScalar(u8, str, ';'); + const index_str = it.next() orelse { + log.warn("OSC 4 is missing palette index", .{}); + return; + }; + const spec_str = it.next() orelse { + log.warn("OSC 4 is missing color spec", .{}); + return; + }; + const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) { + error.Overflow, error.InvalidCharacter => { + log.warn("invalid color palette index in OSC 4: {s} {}", .{ index_str, err }); + return; + }, + }; + if (std.mem.eql(u8, spec_str, "?")) { + const op = operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .report = .{ .palette = index }, + }; + } else { + const color = RGB.parse(spec_str) catch |err| { + log.warn("invalid color specification in OSC 4: '{s}' {}", .{ spec_str, err }); + return; + }; + const op = operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .set = .{ + .kind = .{ + .palette = index, + }, + .color = color, + }, + }; + } + } + + fn parseOSC101112(self: *Parser, final: bool) void { + assert(switch (self.state) { + .osc_10, .osc_11, .osc_12 => true, + else => false, + }); + assert(self.command == .color_operation); + assert(self.command.color_operation.source == switch (self.state) { + .osc_10 => Command.ColorOperation.Source.get_set_foreground, + .osc_11 => Command.ColorOperation.Source.get_set_background, + .osc_12 => Command.ColorOperation.Source.get_set_cursor, + else => unreachable, + }); + + const spec_str = self.buf[self.buf_start .. self.buf_idx - (1 - @intFromBool(final))]; + + if (self.command.color_operation.operations.count() > 0) { + // don't emit the warning if the string is empty + if (spec_str.len == 0) return; + + log.warn("OSC 1{s} can only accept 1 color", .{switch (self.state) { + .osc_10 => "0", + .osc_11 => "1", + .osc_12 => "2", + else => unreachable, + }}); + return; + } + + if (spec_str.len == 0) { + log.warn("OSC 1{s} requires an argument", .{switch (self.state) { + .osc_10 => "0", + .osc_11 => "1", + .osc_12 => "2", + else => unreachable, + }}); + return; + } + + const alloc = self.alloc orelse return; + const operations = &self.command.color_operation.operations; + + if (std.mem.eql(u8, spec_str, "?")) { + const op = operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .report = switch (self.state) { + .osc_10 => .foreground, + .osc_11 => .background, + .osc_12 => .cursor, + else => unreachable, + }, + }; + } else { + const color = RGB.parse(spec_str) catch |err| { + log.warn("invalid color specification in OSC 1{s}: {s} {}", .{ + switch (self.state) { + .osc_10 => "0", + .osc_11 => "1", + .osc_12 => "2", + else => unreachable, + }, + spec_str, + err, + }); + return; + }; + const op = operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .set = .{ + .kind = switch (self.state) { + .osc_10 => .foreground, + .osc_11 => .background, + .osc_12 => .cursor, + else => unreachable, + }, + .color = color, + }, + }; + } + } + + fn parseOSC104(self: *Parser, final: bool) void { + assert(self.state == .osc_104); + assert(self.command == .color_operation); + assert(self.command.color_operation.source == .reset_palette); + + const alloc = self.alloc orelse return; + + const index_str = self.buf[self.buf_start .. self.buf_idx - (1 - @intFromBool(final))]; + self.buf_start = 0; + self.buf_idx = 0; + + const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) { + error.Overflow, error.InvalidCharacter => { + log.warn("invalid color palette index in OSC 104: {s} {}", .{ index_str, err }); + return; + }, + }; + const op = self.command.color_operation.operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .reset = .{ .palette = index }, + }; + } + /// End the sequence and return the command, if any. If the return value /// is null, then no valid command was found. The optional terminator_ch /// is the final character in the OSC sequence. This is used to determine @@ -1350,12 +1547,15 @@ pub const Parser = struct { .allocable_string => self.endAllocableString(), .kitty_color_protocol_key => self.endKittyColorProtocolOption(.key_only, true), .kitty_color_protocol_value => self.endKittyColorProtocolOption(.key_and_value, true), + .osc_4_color => self.parseOSC4(true), + .osc_10, .osc_11, .osc_12 => self.parseOSC101112(true), + .osc_104 => self.parseOSC104(true), else => {}, } switch (self.command) { - .report_color => |*c| c.terminator = Terminator.init(terminator_ch), - .kitty_color_protocol => |*c| c.terminator = Terminator.init(terminator_ch), + .kitty_color_protocol => |*c| c.terminator = .init(terminator_ch), + .color_operation => |*c| c.terminator = .init(terminator_ch), else => {}, } @@ -1564,17 +1764,109 @@ test "OSC: end_of_input" { try testing.expect(cmd == .end_of_input); } -test "OSC: reset cursor color" { +test "OSC: OSC110: reset foreground color" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "110"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .reset_foreground); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind.foreground, + op.reset, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC111: reset background color" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "111"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .reset_background); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind.background, + op.reset, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC112: reset cursor color" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "112"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; - try testing.expect(cmd == .reset_color); - try testing.expectEqual(cmd.reset_color.kind, .cursor); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .reset_cursor); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind.cursor, + op.reset, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC112: reset cursor color with semicolon" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "112;"; + for (input) |ch| p.next(ch); + log.warn("finish: {s}", .{@tagName(p.state)}); + + const cmd = p.end(0x07).?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .reset_cursor); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind.cursor, + op.reset, + ); + } + try testing.expect(it.next() == null); } test "OSC: get/set clipboard" { @@ -1607,9 +1899,8 @@ test "OSC: get/set clipboard (optional parameter)" { test "OSC: get/set clipboard with allocator" { const testing = std.testing; - const alloc = testing.allocator; - var p: Parser = .{ .alloc = alloc }; + var p: Parser = .{ .alloc = testing.allocator }; defer p.deinit(); const input = "52;s;?"; @@ -1671,90 +1962,746 @@ test "OSC: longer than buffer" { try testing.expect(p.complete == false); } -test "OSC: report default foreground color" { +test "OSC: OSC10: report foreground color" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "10;?"; for (input) |ch| p.next(ch); // This corresponds to ST = ESC followed by \ const cmd = p.end('\x1b').?; - try testing.expect(cmd == .report_color); - try testing.expectEqual(cmd.report_color.kind, .foreground); - try testing.expectEqual(cmd.report_color.terminator, .st); + + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .get_set_foreground); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind.foreground, + op.report, + ); + } + try testing.expect(it.next() == null); } -test "OSC: set foreground color" { +test "OSC: OSC10: set foreground color" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "10;rgbi:0.0/0.5/1.0"; for (input) |ch| p.next(ch); const cmd = p.end('\x07').?; - try testing.expect(cmd == .set_color); - try testing.expectEqual(cmd.set_color.kind, .foreground); - try testing.expectEqualStrings(cmd.set_color.value, "rgbi:0.0/0.5/1.0"); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .get_set_foreground); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind.foreground, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0x00, .g = 0x7f, .b = 0xff }, + op.set.color, + ); + } + try testing.expect(it.next() == null); } -test "OSC: report default background color" { +test "OSC: OSC11: report background color" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "11;?"; for (input) |ch| p.next(ch); // This corresponds to ST = BEL character const cmd = p.end('\x07').?; - try testing.expect(cmd == .report_color); - try testing.expectEqual(cmd.report_color.kind, .background); - try testing.expectEqual(cmd.report_color.terminator, .bel); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .get_set_background); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind.background, + op.report, + ); + } + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(it.next() == null); } -test "OSC: set background color" { +test "OSC: OSC11: set background color" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "11;rgb:f/ff/ffff"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .set_color); - try testing.expectEqual(cmd.set_color.kind, .background); - try testing.expectEqualStrings(cmd.set_color.value, "rgb:f/ff/ffff"); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .get_set_background); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind.background, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xff, .g = 0xff, .b = 0xff }, + op.set.color, + ); + } + try testing.expect(it.next() == null); } -test "OSC: get palette color" { +test "OSC: OSC12: report cursor color" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "12;?"; + for (input) |ch| p.next(ch); + + // This corresponds to ST = BEL character + const cmd = p.end('\x07').?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .get_set_cursor); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind.cursor, + op.report, + ); + } + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(it.next() == null); +} + +test "OSC: OSC12: set cursor color" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "12;rgb:f/ff/ffff"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .get_set_cursor); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind.cursor, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xff, .g = 0xff, .b = 0xff }, + op.set.color, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: get palette color 1" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;1;?"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .report_color); - try testing.expectEqual(Command.ColorKind{ .palette = 1 }, cmd.report_color.kind); - try testing.expectEqual(cmd.report_color.terminator, .st); + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.report, + ); + try testing.expectEqual(cmd.color_operation.terminator, .st); + } + try testing.expect(it.next() == null); } -test "OSC: set palette color" { +test "OSC: OSC4: get palette color 2" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;1;?;2;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 2); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 2 }, + op.report, + ); + } + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: set palette color 1" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;17;rgb:aa/bb/cc"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .set_color); - try testing.expectEqual(Command.ColorKind{ .palette = 17 }, cmd.set_color.kind); - try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc"); + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 17 }, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + op.set.color, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: set palette color 2" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;17;rgb:aa/bb/cc;1;rgb:00/11/22"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 2); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 17 }, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + op.set.color, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0x00, .g = 0x11, .b = 0x22 }, + op.set.color, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: get with invalid index 1" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;1111;?;1;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: get with invalid index 2" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;5;?;1111;?;1;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 2); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 5 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +// Inspired by Microsoft Edit +test "OSC: OSC4: multiple get 8a" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 8); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 0 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 2 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 3 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 4 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 5 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 6 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 7 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +// Inspired by Microsoft Edit +test "OSC: OSC4: multiple get 8b" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 8); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 8 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 9 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 10 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 11 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 12 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 13 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 14 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 15 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: set with invalid index" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;256;#ffffff;1;#aabbcc"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + op.set.color, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: mix get/set palette color" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;17;rgb:aa/bb/cc;254;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 2); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 17 }, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + op.set.color, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 254 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: incomplete color/spec 1" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;17"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 0); + var it = cmd.color_operation.operations.constIterator(0); + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: incomplete color/spec 2" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;17;?;42"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 17 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC104: reset palette color 1" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "104;17"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .reset_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 17 }, + op.reset, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC104: reset palette color 2" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "104;17;111"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .reset_palette); + try testing.expectEqual(2, cmd.color_operation.operations.count()); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 17 }, + op.reset, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 111 }, + op.reset, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC104: invalid palette index" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "104;ffff;111"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .reset_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 111 }, + op.reset, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC104: empty palette index" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "104;;111"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .reset_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 111 }, + op.reset, + ); + } + try std.testing.expect(it.next() == null); } test "OSC: conemu sleep" { diff --git a/src/terminal/page.zig b/src/terminal/page.zig index acb757592..fea16c28b 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -241,23 +241,23 @@ pub const Page = struct { l.styles_layout, .{}, ), - .string_alloc = StringAlloc.init( + .string_alloc = .init( buf.add(l.string_alloc_start), l.string_alloc_layout, ), - .grapheme_alloc = GraphemeAlloc.init( + .grapheme_alloc = .init( buf.add(l.grapheme_alloc_start), l.grapheme_alloc_layout, ), - .grapheme_map = GraphemeMap.init( + .grapheme_map = .init( buf.add(l.grapheme_map_start), l.grapheme_map_layout, ), - .hyperlink_map = hyperlink.Map.init( + .hyperlink_map = .init( buf.add(l.hyperlink_map_start), l.hyperlink_map_layout, ), - .hyperlink_set = hyperlink.Set.init( + .hyperlink_set = .init( buf.add(l.hyperlink_set_start), l.hyperlink_set_layout, .{}, @@ -280,7 +280,7 @@ pub const Page = struct { // We zero the page memory as u64 instead of u8 because // we can and it's empirically quite a bit faster. @memset(@as([*]u64, @ptrCast(self.memory))[0 .. self.memory.len / 8], 0); - self.* = initBuf(OffsetBuf.init(self.memory), layout(self.capacity)); + self.* = initBuf(.init(self.memory), layout(self.capacity)); } pub const IntegrityError = error{ @@ -1316,7 +1316,12 @@ pub const Page = struct { /// Set the graphemes for the given cell. This asserts that the cell /// has no graphemes set, and only contains a single codepoint. - pub fn setGraphemes(self: *Page, row: *Row, cell: *Cell, cps: []u21) GraphemeError!void { + pub fn setGraphemes( + self: *Page, + row: *Row, + cell: *Cell, + cps: []const u21, + ) GraphemeError!void { defer self.assertIntegrity(); assert(cell.codepoint() > 0); @@ -2260,7 +2265,7 @@ test "Page appendGrapheme small" { defer page.deinit(); const rac = page.getRowAndCell(0, 0); - rac.cell.* = Cell.init(0x09); + rac.cell.* = .init(0x09); // One try page.appendGrapheme(rac.row, rac.cell, 0x0A); @@ -2289,7 +2294,7 @@ test "Page appendGrapheme larger than chunk" { defer page.deinit(); const rac = page.getRowAndCell(0, 0); - rac.cell.* = Cell.init(0x09); + rac.cell.* = .init(0x09); const count = grapheme_chunk_len * 10; for (0..count) |i| { @@ -2312,11 +2317,11 @@ test "Page clearGrapheme not all cells" { defer page.deinit(); const rac = page.getRowAndCell(0, 0); - rac.cell.* = Cell.init(0x09); + rac.cell.* = .init(0x09); try page.appendGrapheme(rac.row, rac.cell, 0x0A); const rac2 = page.getRowAndCell(1, 0); - rac2.cell.* = Cell.init(0x09); + rac2.cell.* = .init(0x09); try page.appendGrapheme(rac2.row, rac2.cell, 0x0A); // Clear it diff --git a/src/terminal/point.zig b/src/terminal/point.zig index 12b71014b..f2544f90c 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -3,10 +3,12 @@ const Allocator = std.mem.Allocator; const assert = std.debug.assert; const size = @import("size.zig"); -/// The possible reference locations for a point. When someone says "(42, 80)" in the context of a terminal, that could mean multiple -/// things: it is in the current visible viewport? the current active -/// area of the screen where the cursor is? the entire scrollback history? -/// etc. This tag is used to differentiate those cases. +/// The possible reference locations for a point. When someone says "(42, 80)" +/// in the context of a terminal, that could mean multiple things: it is in the +/// current visible viewport? the current active area of the screen where the +/// cursor is? the entire scrollback history? etc. +/// +/// This tag is used to differentiate those cases. pub const Tag = enum { /// Top-left is part of the active area where a running program can /// jump the cursor and make changes. The active area is the "editable" diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index 8023461f3..153e331a6 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -115,7 +115,7 @@ pub fn RefCountedSet( /// input. We handle this gracefully by returning an error /// anywhere where we're about to insert if there's any /// item with a PSL in the last slot of the stats array. - psl_stats: [32]Id = [_]Id{0} ** 32, + psl_stats: [32]Id = @splat(0), /// The backing store of items items: Offset(Item), @@ -663,7 +663,7 @@ pub fn RefCountedSet( const table = self.table.ptr(base); const items = self.items.ptr(base); - var psl_stats: [32]Id = [_]Id{0} ** 32; + var psl_stats: [32]Id = @splat(0); for (items[0..self.layout.cap], 0..) |item, id| { if (item.meta.bucket < std.math.maxInt(Id)) { @@ -676,7 +676,7 @@ pub fn RefCountedSet( assert(std.mem.eql(Id, &psl_stats, &self.psl_stats)); - psl_stats = [_]Id{0} ** 32; + psl_stats = @splat(0); for (table[0..self.layout.table_cap], 0..) |id, bucket| { const item = items[id]; diff --git a/src/terminal/search.zig b/src/terminal/search.zig index 56b181c48..2f87f894b 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -365,7 +365,7 @@ const SlidingWindow = struct { } self.assertIntegrity(); - return Selection.init(tl, br, false); + return .init(tl, br, false); } /// Convert a data index into a pin. @@ -417,7 +417,7 @@ const SlidingWindow = struct { // Initialize our metadata for the node. var meta: Meta = .{ .node = node, - .cell_map = Page.CellMap.init(alloc), + .cell_map = .init(alloc), }; errdefer meta.deinit(); diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index 2bc32c5f9..e4b85fbdd 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -98,7 +98,7 @@ pub const Attribute = union(enum) { /// Parser parses the attributes from a list of SGR parameters. pub const Parser = struct { params: []const u16, - params_sep: SepList = SepList.initEmpty(), + params_sep: SepList = .initEmpty(), idx: usize = 0, /// Next returns the next attribute or null if there are no more attributes. @@ -376,7 +376,7 @@ fn testParse(params: []const u16) Attribute { } fn testParseColon(params: []const u16) Attribute { - var p: Parser = .{ .params = params, .params_sep = SepList.initFull() }; + var p: Parser = .{ .params = params, .params_sep = .initFull() }; return p.next().?; } diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 76fa6c129..fd30720b3 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1555,23 +1555,9 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, - .report_color => |v| { - if (@hasDecl(T, "reportColor")) { - try self.handler.reportColor(v.kind, v.terminator); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .set_color => |v| { - if (@hasDecl(T, "setColor")) { - try self.handler.setColor(v.kind, v.value); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .reset_color => |v| { - if (@hasDecl(T, "resetColor")) { - try self.handler.resetColor(v.kind, v.value); + .color_operation => |v| { + if (@hasDecl(T, "handleColorOperation")) { + try self.handler.handleColorOperation(v.source, &v.operations, v.terminator); return; } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 7f176561b..865e15f64 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -8,9 +8,6 @@ const Offset = size.Offset; const OffsetBuf = size.OffsetBuf; const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet; -const XxHash3 = std.hash.XxHash3; -const autoHash = std.hash.autoHash; - /// The unique identifier for a style. This is at most the number of cells /// that can fit into a terminal page. pub const Id = size.CellCountInt; @@ -87,10 +84,9 @@ pub const Style = struct { /// True if the style is equal to another style. pub fn eql(self: Style, other: Style) bool { - const packed_self = PackedStyle.fromStyle(self); - const packed_other = PackedStyle.fromStyle(other); - // TODO: in Zig 0.14, equating packed structs is allowed. Remove this work around. - return @as(u128, @bitCast(packed_self)) == @as(u128, @bitCast(packed_other)); + // We convert the styles to packed structs and compare as integers + // because this is much faster than comparing each field separately. + return PackedStyle.fromStyle(self) == PackedStyle.fromStyle(other); } /// Returns the bg color for a cell with this style given the cell @@ -303,9 +299,9 @@ pub const Style = struct { .underline = std.meta.activeTag(style.underline_color), }, .data = .{ - .fg = Data.fromColor(style.fg_color), - .bg = Data.fromColor(style.bg_color), - .underline = Data.fromColor(style.underline_color), + .fg = .fromColor(style.fg_color), + .bg = .fromColor(style.bg_color), + .underline = .fromColor(style.underline_color), }, .flags = style.flags, }; @@ -314,12 +310,15 @@ pub const Style = struct { pub fn hash(self: *const Style) u64 { const packed_style = PackedStyle.fromStyle(self.*); - return XxHash3.hash(0, std.mem.asBytes(&packed_style)); + return std.hash.XxHash3.hash(0, std.mem.asBytes(&packed_style)); } comptime { assert(@sizeOf(PackedStyle) == 16); assert(std.meta.hasUniqueRepresentation(PackedStyle)); + for (@typeInfo(PackedStyle.Data).@"union".fields) |field| { + assert(@bitSizeOf(field.type) == @bitSizeOf(PackedStyle.Data)); + } } }; @@ -350,7 +349,7 @@ test "Set basic usage" { const style: Style = .{ .flags = .{ .bold = true } }; const style2: Style = .{ .flags = .{ .italic = true } }; - var set = Set.init(OffsetBuf.init(buf), layout, .{}); + var set = Set.init(.init(buf), layout, .{}); // Add style const id = try set.add(buf, style); diff --git a/src/terminal/x11_color.zig b/src/terminal/x11_color.zig index 88bc30f09..977cd4538 100644 --- a/src/terminal/x11_color.zig +++ b/src/terminal/x11_color.zig @@ -33,7 +33,7 @@ fn colorMap() !ColorMap { } assert(i == len); - return ColorMap.initComptime(kvs); + return .initComptime(kvs); } /// This is the rgb.txt file from the X11 project. This was last sourced diff --git a/src/terminfo/Source.zig b/src/terminfo/Source.zig index 8ffd9cabb..7692e6f54 100644 --- a/src/terminfo/Source.zig +++ b/src/terminfo/Source.zig @@ -74,7 +74,7 @@ pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([]const u8) { // We have all of our capabilities plus To, TN, and RGB which aren't // in the capabilities list but are query-able. const len = self.capabilities.len + 3; - var kvs: [len]KV = .{.{ "", "" }} ** len; + var kvs: [len]KV = @splat(.{ "", "" }); // We first build all of our entries with raw K=V pairs. kvs[0] = .{ "TN", self.names[0] }; diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 23c626879..b8f838cf9 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -10,6 +10,7 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const posix = std.posix; const xev = @import("../global.zig").xev; +const apprt = @import("../apprt.zig"); const build_config = @import("../build_config.zig"); const configpkg = @import("../config.zig"); const crash = @import("../crash/main.zig"); @@ -153,8 +154,6 @@ pub fn threadEnter( // Setup our threadata backend state to be our own td.backend = .{ .exec = .{ .start = process_start, - .abnormal_runtime_threshold_ms = io.config.abnormal_runtime_threshold_ms, - .wait_after_command = io.config.wait_after_command, .write_stream = stream, .process = process, .read_thread = read_thread, @@ -273,83 +272,6 @@ pub fn resize( return try self.subprocess.resize(grid_size, screen_size); } -/// Called when the child process exited abnormally but before the surface -/// is notified. -pub fn childExitedAbnormally( - self: *Exec, - gpa: Allocator, - t: *terminal.Terminal, - exit_code: u32, - runtime_ms: u64, -) !void { - var arena = ArenaAllocator.init(gpa); - defer arena.deinit(); - const alloc = arena.allocator(); - - // Build up our command for the error message - const command = try std.mem.join(alloc, " ", self.subprocess.args); - const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{runtime_ms}); - - // No matter what move the cursor back to the column 0. - t.carriageReturn(); - - // Reset styles - try t.setAttribute(.{ .unset = {} }); - - // If there is data in the viewport, we want to scroll down - // a little bit and write a horizontal rule before writing - // our message. This lets the use see the error message the - // command may have output. - const viewport_str = try t.plainString(alloc); - if (viewport_str.len > 0) { - try t.linefeed(); - for (0..t.cols) |_| try t.print(0x2501); - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - } - - // Output our error message - try t.setAttribute(.{ .@"8_fg" = .bright_red }); - try t.setAttribute(.{ .bold = {} }); - try t.printString("Ghostty failed to launch the requested command:"); - try t.setAttribute(.{ .unset = {} }); - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString(command); - try t.setAttribute(.{ .unset = {} }); - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString("Runtime: "); - try t.setAttribute(.{ .@"8_fg" = .red }); - try t.printString(runtime_str); - try t.setAttribute(.{ .unset = {} }); - - // We don't print this on macOS because the exit code is always 0 - // due to the way we launch the process. - if (comptime !builtin.target.os.tag.isDarwin()) { - const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{exit_code}); - t.carriageReturn(); - try t.linefeed(); - try t.printString("Exit Code: "); - try t.setAttribute(.{ .@"8_fg" = .red }); - try t.printString(exit_code_str); - try t.setAttribute(.{ .unset = {} }); - } - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString("Press any key to close the window."); - - // Hide the cursor - t.modes.set(.cursor_visible, false); -} - /// This outputs an error message when exec failed and we are the /// child process. This returns so the caller should probably exit /// after calling this. @@ -386,61 +308,13 @@ fn processExitCommon(td: *termio.Termio.ThreadData, exit_code: u32) void { .{ exit_code, runtime_ms orelse 0 }, ); - // If our runtime was below some threshold then we assume that this - // was an abnormal exit and we show an error message. - if (runtime_ms) |runtime| runtime: { - // On macOS, our exit code detection doesn't work, possibly - // because of our `login` wrapper. More investigation required. - if (comptime !builtin.target.os.tag.isDarwin()) { - // If our exit code is zero, then the command was successful - // and we don't ever consider it abnormal. - if (exit_code == 0) break :runtime; - } - - // Our runtime always has to be under the threshold to be - // considered abnormal. This is because a user can always - // manually do something like `exit 1` in their shell to - // force the exit code to be non-zero. We only want to detect - // abnormal exits that happen so quickly the user can't react. - if (runtime > execdata.abnormal_runtime_threshold_ms) break :runtime; - log.warn("abnormal process exit detected, showing error message", .{}); - - // Notify our main writer thread which has access to more - // information so it can show a better error message. - td.mailbox.send(.{ - .child_exited_abnormally = .{ - .exit_code = exit_code, - .runtime_ms = runtime, - }, - }, null); - td.mailbox.notify(); - - return; - } - - // If we're purposely waiting then we just return since the process - // exited flag is set to true. This allows the terminal window to remain - // open. - if (execdata.wait_after_command) { - // We output a message so that the user knows whats going on and - // doesn't think their terminal just froze. - terminal: { - td.renderer_state.mutex.lock(); - defer td.renderer_state.mutex.unlock(); - const t = td.renderer_state.terminal; - t.carriageReturn(); - t.linefeed() catch break :terminal; - t.printString("Process exited. Press any key to close the terminal.") catch - break :terminal; - t.modes.set(.cursor_visible, false); - } - - return; - } - - // Notify our surface we want to close + // We always notify the surface immediately that the child has + // exited and some metadata about the exit. _ = td.surface_mailbox.push(.{ - .child_exited = {}, + .child_exited = .{ + .exit_code = exit_code, + .runtime_ms = runtime_ms orelse 0, + }, }, .{ .forever = {} }); } @@ -561,14 +435,8 @@ pub fn queueWrite( _ = self; const exec = &td.backend.exec; - // If our process is exited then we send our surface a message - // about it but we don't queue any more writes. - if (exec.exited) { - _ = td.surface_mailbox.push(.{ - .child_exited = {}, - }, .{ .forever = {} }); - return; - } + // If our process is exited then we don't send any more writes. + if (exec.exited) return; // We go through and chunk the data if necessary to fit into // our cached buffers that we can queue to the stream. @@ -656,17 +524,6 @@ pub const ThreadData = struct { start: std.time.Instant, exited: bool = false, - /// The number of milliseconds below which we consider a process - /// exit to be abnormal. This is used to show an error message - /// when the process exits too quickly. - abnormal_runtime_threshold_ms: u32, - - /// If true, do not immediately send a child exited message to the - /// surface to close the surface when the command exits. If this is - /// false we'll show a process exited message and wait for user input - /// to close the surface. - wait_after_command: bool, - /// The data stream is the main IO for the pty. write_stream: xev.Stream, @@ -1362,6 +1219,13 @@ pub const ReadThread = struct { // Always close our end of the pipe when we exit. defer posix.close(quit); + // Right now, on Darwin, `std.Thread.setName` can only name the current + // thread, and we have no way to get the current thread from within it, + // so instead we use this code to name the thread instead. + if (builtin.os.tag.isDarwin()) { + internal_os.macos.pthread_setname_np(&"io-reader".*); + } + // Setup our crash metadata crash.sentry.thread_state = .{ .type = .io, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index ecfb9951e..865a2df86 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -70,6 +70,89 @@ terminal_stream: terminalpkg.Stream(StreamHandler), /// flooding with cursor resets. last_cursor_reset: ?std.time.Instant = null, +/// State we have for thread enter. This may be null if we don't need +/// to keep track of any state or if its already been freed. +thread_enter_state: ?*ThreadEnterState = null, + +/// The state we need to keep around only until we enter the IO +/// thread. Then we can throw it all away. +const ThreadEnterState = struct { + arena: ArenaAllocator, + + /// Initial input to send to the subprocess after starting. This + /// memory is freed once the subprocess start is attempted, even + /// if it fails, because Exec only starts once. + input: configpkg.io.RepeatableReadableIO, + + pub fn create( + alloc: Allocator, + config: *const configpkg.Config, + ) !?*ThreadEnterState { + // If we have no input then we have no thread enter state + if (config.input.list.items.len == 0) return null; + + // Create our arena allocator + var arena = ArenaAllocator.init(alloc); + errdefer arena.deinit(); + const arena_alloc = arena.allocator(); + + // Allocate our ThreadEnterState + const ptr = try arena_alloc.create(ThreadEnterState); + + // Copy the input from the config + const input = try config.input.cloneParsed(arena_alloc); + + // Return the initialized state + ptr.* = .{ + .arena = arena, + .input = input, + }; + return ptr; + } + + pub fn destroy(self: *ThreadEnterState) void { + self.arena.deinit(); + } + + /// Prepare the inputs for use. Allocations happen on the arena. + pub fn prepareInput( + self: *ThreadEnterState, + ) (Allocator.Error || error{InputNotFound})![]const Input { + const alloc = self.arena.allocator(); + + var input = try alloc.alloc( + Input, + self.input.list.items.len, + ); + for (self.input.list.items, 0..) |item, i| { + input[i] = switch (item) { + .raw => |v| .{ .string = try alloc.dupe(u8, v) }, + .path => |path| file: { + const f = std.fs.cwd().openFile( + path, + .{}, + ) catch |err| { + log.warn("failed to open input file={s} err={}", .{ + path, + err, + }); + return error.InputNotFound; + }; + + break :file .{ .file = f }; + }, + }; + } + + return input; + } + + const Input = union(enum) { + string: []const u8, + file: std.fs.File, + }; +}; + /// The configuration for this IO that is derived from the main /// configuration. This must be exported so that we don't need to /// pass around Config pointers which makes memory management a pain. @@ -85,8 +168,6 @@ pub const DerivedConfig = struct { foreground: configpkg.Config.Color, background: configpkg.Config.Color, osc_color_report_format: configpkg.Config.OSCColorReportFormat, - abnormal_runtime_threshold_ms: u32, - wait_after_command: bool, enquiry_response: []const u8, pub fn init( @@ -107,8 +188,6 @@ pub const DerivedConfig = struct { .foreground = config.foreground, .background = config.background, .osc_color_report_format = config.@"osc-color-report-format", - .abnormal_runtime_threshold_ms = config.@"abnormal-command-exit-runtime", - .wait_after_command = config.@"wait-after-command", .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), // This has to be last so that we copy AFTER the arena allocations @@ -211,6 +290,11 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { }; }; + const thread_enter_state = try ThreadEnterState.create( + alloc, + opts.full_config, + ); + self.* = .{ .alloc = alloc, .terminal = term, @@ -232,6 +316,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { }, }, }, + .thread_enter_state = thread_enter_state, }; } @@ -244,9 +329,30 @@ pub fn deinit(self: *Termio) void { // Clear any StreamHandler state self.terminal_stream.handler.deinit(); self.terminal_stream.deinit(); + + // Clear any initial state if we have it + if (self.thread_enter_state) |v| v.destroy(); } -pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !void { +pub fn threadEnter( + self: *Termio, + thread: *termio.Thread, + data: *ThreadData, +) !void { + // Always free our thread enter state when we're done. + defer if (self.thread_enter_state) |v| { + v.destroy(); + self.thread_enter_state = null; + }; + + // If we have thread enter state then we're going to validate + // and set that all up now so that we can error before we actually + // start the command and pty. + const inputs: ?[]const ThreadEnterState.Input = if (self.thread_enter_state) |v| + try v.prepareInput() + else + null; + data.* = .{ .alloc = self.alloc, .loop = &thread.loop, @@ -258,6 +364,29 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo // Setup our backend try self.backend.threadEnter(self.alloc, self, data); + errdefer self.backend.threadExit(data); + + // If we have inputs, then queue them all up. + for (inputs orelse &.{}) |input| switch (input) { + .string => |v| self.queueWrite(data, v, false) catch |err| { + log.warn("failed to queue input string err={}", .{err}); + return error.InputFailed; + }, + .file => |f| self.queueWrite( + data, + f.readToEndAlloc( + self.alloc, + 10 * 1024 * 1024, // 10 MiB max + ) catch |err| { + log.warn("failed to read input file err={}", .{err}); + return error.InputFailed; + }, + false, + ) catch |err| { + log.warn("failed to queue input file err={}", .{err}); + return error.InputFailed; + }, + }; } pub fn threadExit(self: *Termio, data: *ThreadData) void { @@ -527,15 +656,6 @@ pub fn jumpToPrompt(self: *Termio, delta: isize) !void { try self.renderer_wakeup.notify(); } -/// Called when the child process exited abnormally but before -/// the surface is notified. -pub fn childExitedAbnormally(self: *Termio, exit_code: u32, runtime_ms: u64) !void { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - const t = self.renderer_state.terminal; - try self.backend.childExitedAbnormally(self.alloc, t, exit_code, runtime_ms); -} - /// Called when focus is gained or lost (when focus events are enabled) pub fn focusGained(self: *Termio, td: *ThreadData, focused: bool) !void { self.renderer_state.mutex.lock(); diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index d8018341d..a701a29f8 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -16,6 +16,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const builtin = @import("builtin"); const xev = @import("../global.zig").xev; const crash = @import("../crash/main.zig"); +const internal_os = @import("../os/main.zig"); const termio = @import("../termio.zig"); const renderer = @import("../renderer.zig"); const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue; @@ -145,6 +146,8 @@ pub fn threadMain(self: *Thread, io: *termio.Termio) void { // have "OpenptyFailed". const Err = @TypeOf(err) || error{ OpenptyFailed, + InputNotFound, + InputFailed, }; switch (@as(Err, @errorCast(err))) { @@ -164,6 +167,24 @@ pub fn threadMain(self: *Thread, io: *termio.Termio) void { t.printString(str) catch {}; }, + error.InputNotFound, + error.InputFailed, + => { + const str = + \\A configured `input` path was not found, was not readable, + \\was too large, or the underlying pty failed to accept + \\the write. + \\ + \\Ghostty can't continue since it can't guarantee that + \\initial terminal state will be as desired. Please review + \\the value of `input` in your configuration file and + \\ensure that all the path values exist and are readable. + ; + + t.eraseDisplay(.complete, false); + t.printString(str) catch {}; + }, + else => { const str = std.fmt.allocPrint( alloc, @@ -202,6 +223,13 @@ pub fn threadMain(self: *Thread, io: *termio.Termio) void { fn threadMain_(self: *Thread, io: *termio.Termio) !void { defer log.debug("IO thread exited", .{}); + // Right now, on Darwin, `std.Thread.setName` can only name the current + // thread, and we have no way to get the current thread from within it, + // so instead we use this code to name the thread instead. + if (builtin.os.tag.isDarwin()) { + internal_os.macos.pthread_setname_np(&"io".*); + } + // Setup our crash metadata crash.sentry.thread_state = .{ .type = .io, @@ -283,7 +311,6 @@ fn drainMailbox( .jump_to_prompt => |v| try io.jumpToPrompt(v), .start_synchronized_output => self.startSynchronizedOutput(cb), .linefeed_mode => |v| self.flags.linefeed_mode = v, - .child_exited_abnormally => |v| try io.childExitedAbnormally(v.exit_code, v.runtime_ms), .focused => |v| try io.focusGained(data, v), .write_small => |v| try io.queueWrite( data, diff --git a/src/termio/backend.zig b/src/termio/backend.zig index 46ed3431c..280fcbde1 100644 --- a/src/termio/backend.zig +++ b/src/termio/backend.zig @@ -122,11 +122,7 @@ pub const ThreadData = union(Kind) { } pub fn changeConfig(self: *ThreadData, config: *termio.DerivedConfig) void { - switch (self.*) { - .exec => |*exec| { - exec.abnormal_runtime_threshold_ms = config.abnormal_runtime_threshold_ms; - exec.wait_after_command = config.wait_after_command; - }, - } + _ = self; + _ = config; } }; diff --git a/src/termio/message.zig b/src/termio/message.zig index 42767e109..e497a298f 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -1,6 +1,7 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const apprt = @import("../apprt.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); @@ -58,15 +59,6 @@ pub const Message = union(enum) { /// Enable or disable linefeed mode (mode 20). linefeed_mode: bool, - /// The child exited abnormally. The termio state is marked - /// as process exited but the surface hasn't been notified to - /// close because termio can use this to update the terminal - /// with an error message. - child_exited_abnormally: struct { - exit_code: u32, - runtime_ms: u64, - }, - /// The surface gained or lost focus. focused: bool, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 299c7cd45..90add84ae 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -582,36 +582,33 @@ pub const StreamHandler = struct { self.terminal.scrolling_region.right = self.terminal.cols - 1; }, + .alt_screen_legacy => { + self.terminal.switchScreenMode(.@"47", enabled); + try self.queueRender(); + }, + .alt_screen => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = false, - .clear_on_enter = false, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens + self.terminal.switchScreenMode(.@"1047", enabled); try self.queueRender(); }, .alt_screen_save_cursor_clear_enter => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = true, - .clear_on_enter = true, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens + self.terminal.switchScreenMode(.@"1049", enabled); try self.queueRender(); }, + // Mode 1048 is xterm's conditional save cursor depending + // on if alt screen is enabled or not (at the terminal emulator + // level). Alt screen is always enabled for us so this just + // does a save/restore cursor. + .save_cursor => { + if (enabled) { + self.terminal.saveCursor(); + } else { + try self.terminal.restoreCursor(); + } + }, + // Force resize back to the window size .enable_mode_3 => { const grid_size = self.size.grid(); @@ -1084,7 +1081,7 @@ pub const StreamHandler = struct { return; } - const uri = std.Uri.parse(url) catch |e| { + const uri: std.Uri = internal_os.hostname.parseUrl(url) catch |e| { log.warn("invalid url in OSC 7: {}", .{e}); return; }; @@ -1185,200 +1182,185 @@ pub const StreamHandler = struct { } } - /// Implements OSC 4, OSC 10, and OSC 11, which reports palette color, - /// default foreground color, and background color respectively. - pub fn reportColor( + pub fn handleColorOperation( self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, + source: terminal.osc.Command.ColorOperation.Source, + operations: *const terminal.osc.Command.ColorOperation.List, terminator: terminal.osc.Terminator, ) !void { - if (self.osc_color_report_format == .none) return; + // return early if there is nothing to do + if (operations.count() == 0) return; - const color = switch (kind) { - .palette => |i| self.terminal.color_palette.colors[i], - .foreground => self.foreground_color orelse self.default_foreground_color, - .background => self.background_color orelse self.default_background_color, - .cursor => self.cursor_color orelse - self.default_cursor_color orelse - self.foreground_color orelse - self.default_foreground_color, - }; + var buffer: [1024]u8 = undefined; + var fba: std.heap.FixedBufferAllocator = .init(&buffer); + const alloc = fba.allocator(); - var msg: termio.Message = .{ .write_small = .{} }; - const resp = switch (self.osc_color_report_format) { - .@"16-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - i, - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), - }, + var response: std.ArrayListUnmanaged(u8) = .empty; + const writer = response.writer(alloc); - .@"8-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - i, - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - }, - .none => unreachable, // early return above - }; - msg.write_small.len = @intCast(resp.len); - self.messageWriter(msg); - } + var report: bool = false; - pub fn setColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - const color = try terminal.color.RGB.parse(value); + try writer.print("\x1b]{}", .{source}); - switch (kind) { - .palette => |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = color; - self.terminal.color_palette.mask.set(i); - }, - .foreground => { - self.foreground_color = color; - _ = self.renderer_mailbox.push(.{ - .foreground_color = color, - }, .{ .forever = {} }); - }, - .background => { - self.background_color = color; - _ = self.renderer_mailbox.push(.{ - .background_color = color, - }, .{ .forever = {} }); - }, - .cursor => { - self.cursor_color = color; - _ = self.renderer_mailbox.push(.{ - .cursor_color = color, - }, .{ .forever = {} }); - }, - } + var it = operations.constIterator(0); - // Notify the surface of the color change - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = kind, - .color = color, - } }); - } - - pub fn resetColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - switch (kind) { - .palette => { - const mask = &self.terminal.color_palette.mask; - if (value.len == 0) { - // Find all bit positions in the mask which are set and - // reset those indices to the default palette - var it = mask.iterator(.{}); - while (it.next()) |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); - - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .{ .palette = @intCast(i) }, - .color = self.terminal.color_palette.colors[i], - } }); + while (it.next()) |op| { + switch (op.*) { + .set => |set| { + switch (set.kind) { + .palette => |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = set.color; + self.terminal.color_palette.mask.set(i); + }, + .foreground => { + self.foreground_color = set.color; + _ = self.renderer_mailbox.push(.{ + .foreground_color = set.color, + }, .{ .forever = {} }); + }, + .background => { + self.background_color = set.color; + _ = self.renderer_mailbox.push(.{ + .background_color = set.color, + }, .{ .forever = {} }); + }, + .cursor => { + self.cursor_color = set.color; + _ = self.renderer_mailbox.push(.{ + .cursor_color = set.color, + }, .{ .forever = {} }); + }, } - } else { - var it = std.mem.tokenizeScalar(u8, value, ';'); - while (it.next()) |param| { - // Skip invalid parameters - const i = std.fmt.parseUnsigned(u8, param, 10) catch continue; - if (mask.isSet(i)) { + + // Notify the surface of the color change + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = set.kind, + .color = set.color, + } }); + }, + + .reset => |kind| { + switch (kind) { + .palette => |i| { + const mask = &self.terminal.color_palette.mask; self.terminal.flags.dirty.palette = true; self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; mask.unset(i); + self.surfaceMessageWriter(.{ + .color_change = .{ + .kind = .{ .palette = @intCast(i) }, + .color = self.terminal.color_palette.colors[i], + }, + }); + }, + .foreground => { + self.foreground_color = null; + _ = self.renderer_mailbox.push(.{ + .foreground_color = self.foreground_color, + }, .{ .forever = {} }); + self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .{ .palette = @intCast(i) }, - .color = self.terminal.color_palette.colors[i], + .kind = .foreground, + .color = self.default_foreground_color, } }); - } + }, + .background => { + self.background_color = null; + _ = self.renderer_mailbox.push(.{ + .background_color = self.background_color, + }, .{ .forever = {} }); + + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = .background, + .color = self.default_background_color, + } }); + }, + .cursor => { + self.cursor_color = null; + + _ = self.renderer_mailbox.push(.{ + .cursor_color = self.cursor_color, + }, .{ .forever = {} }); + + if (self.default_cursor_color) |color| { + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = .cursor, + .color = color, + } }); + } + }, } - } - }, - .foreground => { - self.foreground_color = null; - _ = self.renderer_mailbox.push(.{ - .foreground_color = self.foreground_color, - }, .{ .forever = {} }); + }, - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .foreground, - .color = self.default_foreground_color, - } }); - }, - .background => { - self.background_color = null; - _ = self.renderer_mailbox.push(.{ - .background_color = self.background_color, - }, .{ .forever = {} }); + .report => |kind| report: { + if (self.osc_color_report_format == .none) break :report; - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .background, - .color = self.default_background_color, - } }); - }, - .cursor => { - self.cursor_color = null; + report = true; - _ = self.renderer_mailbox.push(.{ - .cursor_color = self.cursor_color, - }, .{ .forever = {} }); + const color = switch (kind) { + .palette => |i| self.terminal.color_palette.colors[i], + .foreground => self.foreground_color orelse self.default_foreground_color, + .background => self.background_color orelse self.default_background_color, + .cursor => self.cursor_color orelse + self.default_cursor_color orelse + self.foreground_color orelse + self.default_foreground_color, + }; - if (self.default_cursor_color) |color| { - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .cursor, - .color = color, - } }); - } - }, + switch (self.osc_color_report_format) { + .@"16-bit" => switch (kind) { + .palette => |i| try writer.print( + ";{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}", + .{ + i, + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + }, + ), + else => try writer.print( + ";rgb:{x:0>4}/{x:0>4}/{x:0>4}", + .{ + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + }, + ), + }, + + .@"8-bit" => switch (kind) { + .palette => |i| try writer.print( + ";{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}", + .{ + i, + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + }, + ), + else => try writer.print( + ";rgb:{x:0>2}/{x:0>2}/{x:0>2}", + .{ + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + }, + ), + }, + + .none => unreachable, + } + }, + } + } + if (report) { + // If any of the operations were reports, finalize the report + // string and send it to the terminal. + try writer.writeAll(terminator.string()); + const msg = try termio.Message.writeReq(self.alloc, response.items); + self.messageWriter(msg); } } diff --git a/src/unicode/props.zig b/src/unicode/props.zig index 8c7621b79..99c57aa0a 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -125,7 +125,7 @@ pub fn get(cp: u21) Properties { return .{ .width = @intCast(@min(2, @max(0, zg_width))), - .grapheme_boundary_class = GraphemeBoundaryClass.init(cp), + .grapheme_boundary_class = .init(cp), }; } diff --git a/typos.toml b/typos.toml index 4f4bf7ee7..fafc38858 100644 --- a/typos.toml +++ b/typos.toml @@ -49,6 +49,8 @@ grey = "gray" greyscale = "grayscale" DECID = "DECID" flate = "flate" +typ = "typ" +kend = "kend" [type.po] extend-glob = ["*.po"] diff --git a/vendor/glad/include/glad/gl.h b/vendor/glad/include/glad/gl.h index 2f71276dc..b9b398187 100644 --- a/vendor/glad/include/glad/gl.h +++ b/vendor/glad/include/glad/gl.h @@ -1,5 +1,5 @@ /** - * Loader generated by glad 2.0.0 on Mon Oct 24 00:13:28 2022 + * Loader generated by glad 2.0.8 on Mon May 19 01:37:34 2025 * * SPDX-License-Identifier: (WTFPL OR CC0-1.0) AND Apache-2.0 * @@ -8,7 +8,7 @@ * Extensions: 0 * * APIs: - * - gl:core=3.3 + * - gl:core=4.3 * * Options: * - ALIAS = False @@ -19,10 +19,10 @@ * - ON_DEMAND = False * * Commandline: - * --api='gl:core=3.3' --extensions='' c --loader --mx + * --api='gl:core=4.3' --extensions='' c --loader --mx * * Online: - * http://glad.sh/#api=gl%3Acore%3D3.3&extensions=&generator=c&options=LOADER%2CMX + * http://glad.sh/#api=gl%3Acore%3D4.3&extensions=&generator=c&options=LOADER%2CMX * */ @@ -165,7 +165,7 @@ extern "C" { #define GLAD_VERSION_MAJOR(version) (version / 10000) #define GLAD_VERSION_MINOR(version) (version % 10000) -#define GLAD_GENERATOR_VERSION "2.0.0" +#define GLAD_GENERATOR_VERSION "2.0.8" typedef void (*GLADapiproc)(void); @@ -177,14 +177,25 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #endif /* GLAD_PLATFORM_H_ */ +#define GL_ACTIVE_ATOMIC_COUNTER_BUFFERS 0x92D9 #define GL_ACTIVE_ATTRIBUTES 0x8B89 #define GL_ACTIVE_ATTRIBUTE_MAX_LENGTH 0x8B8A +#define GL_ACTIVE_PROGRAM 0x8259 +#define GL_ACTIVE_RESOURCES 0x92F5 +#define GL_ACTIVE_SUBROUTINES 0x8DE5 +#define GL_ACTIVE_SUBROUTINE_MAX_LENGTH 0x8E48 +#define GL_ACTIVE_SUBROUTINE_UNIFORMS 0x8DE6 +#define GL_ACTIVE_SUBROUTINE_UNIFORM_LOCATIONS 0x8E47 +#define GL_ACTIVE_SUBROUTINE_UNIFORM_MAX_LENGTH 0x8E49 #define GL_ACTIVE_TEXTURE 0x84E0 #define GL_ACTIVE_UNIFORMS 0x8B86 #define GL_ACTIVE_UNIFORM_BLOCKS 0x8A36 #define GL_ACTIVE_UNIFORM_BLOCK_MAX_NAME_LENGTH 0x8A35 #define GL_ACTIVE_UNIFORM_MAX_LENGTH 0x8B87 +#define GL_ACTIVE_VARIABLES 0x9305 #define GL_ALIASED_LINE_WIDTH_RANGE 0x846E +#define GL_ALL_BARRIER_BITS 0xFFFFFFFF +#define GL_ALL_SHADER_BITS 0xFFFFFFFF #define GL_ALPHA 0x1906 #define GL_ALREADY_SIGNALED 0x911A #define GL_ALWAYS 0x0207 @@ -192,9 +203,28 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_AND_INVERTED 0x1504 #define GL_AND_REVERSE 0x1502 #define GL_ANY_SAMPLES_PASSED 0x8C2F +#define GL_ANY_SAMPLES_PASSED_CONSERVATIVE 0x8D6A #define GL_ARRAY_BUFFER 0x8892 #define GL_ARRAY_BUFFER_BINDING 0x8894 +#define GL_ARRAY_SIZE 0x92FB +#define GL_ARRAY_STRIDE 0x92FE +#define GL_ATOMIC_COUNTER_BARRIER_BIT 0x00001000 +#define GL_ATOMIC_COUNTER_BUFFER 0x92C0 +#define GL_ATOMIC_COUNTER_BUFFER_ACTIVE_ATOMIC_COUNTERS 0x92C5 +#define GL_ATOMIC_COUNTER_BUFFER_ACTIVE_ATOMIC_COUNTER_INDICES 0x92C6 +#define GL_ATOMIC_COUNTER_BUFFER_BINDING 0x92C1 +#define GL_ATOMIC_COUNTER_BUFFER_DATA_SIZE 0x92C4 +#define GL_ATOMIC_COUNTER_BUFFER_INDEX 0x9301 +#define GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_COMPUTE_SHADER 0x90ED +#define GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_FRAGMENT_SHADER 0x92CB +#define GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_GEOMETRY_SHADER 0x92CA +#define GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_TESS_CONTROL_SHADER 0x92C8 +#define GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_TESS_EVALUATION_SHADER 0x92C9 +#define GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_VERTEX_SHADER 0x92C7 +#define GL_ATOMIC_COUNTER_BUFFER_SIZE 0x92C3 +#define GL_ATOMIC_COUNTER_BUFFER_START 0x92C2 #define GL_ATTACHED_SHADERS 0x8B85 +#define GL_AUTO_GENERATE_MIPMAP 0x8295 #define GL_BACK 0x0405 #define GL_BACK_LEFT 0x0402 #define GL_BACK_RIGHT 0x0403 @@ -213,26 +243,34 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_BLEND_SRC 0x0BE1 #define GL_BLEND_SRC_ALPHA 0x80CB #define GL_BLEND_SRC_RGB 0x80C9 +#define GL_BLOCK_INDEX 0x92FD #define GL_BLUE 0x1905 #define GL_BLUE_INTEGER 0x8D96 #define GL_BOOL 0x8B56 #define GL_BOOL_VEC2 0x8B57 #define GL_BOOL_VEC3 0x8B58 #define GL_BOOL_VEC4 0x8B59 +#define GL_BUFFER 0x82E0 #define GL_BUFFER_ACCESS 0x88BB #define GL_BUFFER_ACCESS_FLAGS 0x911F +#define GL_BUFFER_BINDING 0x9302 +#define GL_BUFFER_DATA_SIZE 0x9303 #define GL_BUFFER_MAPPED 0x88BC #define GL_BUFFER_MAP_LENGTH 0x9120 #define GL_BUFFER_MAP_OFFSET 0x9121 #define GL_BUFFER_MAP_POINTER 0x88BD #define GL_BUFFER_SIZE 0x8764 +#define GL_BUFFER_UPDATE_BARRIER_BIT 0x00000200 #define GL_BUFFER_USAGE 0x8765 +#define GL_BUFFER_VARIABLE 0x92E5 #define GL_BYTE 0x1400 +#define GL_CAVEAT_SUPPORT 0x82B8 #define GL_CCW 0x0901 #define GL_CLAMP_READ_COLOR 0x891C #define GL_CLAMP_TO_BORDER 0x812D #define GL_CLAMP_TO_EDGE 0x812F #define GL_CLEAR 0x1500 +#define GL_CLEAR_BUFFER 0x82B4 #define GL_CLIP_DISTANCE0 0x3000 #define GL_CLIP_DISTANCE1 0x3001 #define GL_CLIP_DISTANCE2 0x3002 @@ -276,39 +314,93 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_COLOR_ATTACHMENT9 0x8CE9 #define GL_COLOR_BUFFER_BIT 0x00004000 #define GL_COLOR_CLEAR_VALUE 0x0C22 +#define GL_COLOR_COMPONENTS 0x8283 +#define GL_COLOR_ENCODING 0x8296 #define GL_COLOR_LOGIC_OP 0x0BF2 +#define GL_COLOR_RENDERABLE 0x8286 #define GL_COLOR_WRITEMASK 0x0C23 +#define GL_COMMAND_BARRIER_BIT 0x00000040 #define GL_COMPARE_REF_TO_TEXTURE 0x884E +#define GL_COMPATIBLE_SUBROUTINES 0x8E4B #define GL_COMPILE_STATUS 0x8B81 +#define GL_COMPRESSED_R11_EAC 0x9270 #define GL_COMPRESSED_RED 0x8225 #define GL_COMPRESSED_RED_RGTC1 0x8DBB #define GL_COMPRESSED_RG 0x8226 +#define GL_COMPRESSED_RG11_EAC 0x9272 #define GL_COMPRESSED_RGB 0x84ED +#define GL_COMPRESSED_RGB8_ETC2 0x9274 +#define GL_COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2 0x9276 #define GL_COMPRESSED_RGBA 0x84EE +#define GL_COMPRESSED_RGBA8_ETC2_EAC 0x9278 +#define GL_COMPRESSED_RGBA_BPTC_UNORM 0x8E8C +#define GL_COMPRESSED_RGB_BPTC_SIGNED_FLOAT 0x8E8E +#define GL_COMPRESSED_RGB_BPTC_UNSIGNED_FLOAT 0x8E8F #define GL_COMPRESSED_RG_RGTC2 0x8DBD +#define GL_COMPRESSED_SIGNED_R11_EAC 0x9271 #define GL_COMPRESSED_SIGNED_RED_RGTC1 0x8DBC +#define GL_COMPRESSED_SIGNED_RG11_EAC 0x9273 #define GL_COMPRESSED_SIGNED_RG_RGTC2 0x8DBE #define GL_COMPRESSED_SRGB 0x8C48 +#define GL_COMPRESSED_SRGB8_ALPHA8_ETC2_EAC 0x9279 +#define GL_COMPRESSED_SRGB8_ETC2 0x9275 +#define GL_COMPRESSED_SRGB8_PUNCHTHROUGH_ALPHA1_ETC2 0x9277 #define GL_COMPRESSED_SRGB_ALPHA 0x8C49 +#define GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM 0x8E8D #define GL_COMPRESSED_TEXTURE_FORMATS 0x86A3 +#define GL_COMPUTE_SHADER 0x91B9 +#define GL_COMPUTE_SHADER_BIT 0x00000020 +#define GL_COMPUTE_SUBROUTINE 0x92ED +#define GL_COMPUTE_SUBROUTINE_UNIFORM 0x92F3 +#define GL_COMPUTE_TEXTURE 0x82A0 +#define GL_COMPUTE_WORK_GROUP_SIZE 0x8267 #define GL_CONDITION_SATISFIED 0x911C #define GL_CONSTANT_ALPHA 0x8003 #define GL_CONSTANT_COLOR 0x8001 #define GL_CONTEXT_COMPATIBILITY_PROFILE_BIT 0x00000002 #define GL_CONTEXT_CORE_PROFILE_BIT 0x00000001 #define GL_CONTEXT_FLAGS 0x821E +#define GL_CONTEXT_FLAG_DEBUG_BIT 0x00000002 #define GL_CONTEXT_FLAG_FORWARD_COMPATIBLE_BIT 0x00000001 #define GL_CONTEXT_PROFILE_MASK 0x9126 #define GL_COPY 0x1503 #define GL_COPY_INVERTED 0x150C #define GL_COPY_READ_BUFFER 0x8F36 +#define GL_COPY_READ_BUFFER_BINDING 0x8F36 #define GL_COPY_WRITE_BUFFER 0x8F37 +#define GL_COPY_WRITE_BUFFER_BINDING 0x8F37 #define GL_CULL_FACE 0x0B44 #define GL_CULL_FACE_MODE 0x0B45 #define GL_CURRENT_PROGRAM 0x8B8D #define GL_CURRENT_QUERY 0x8865 #define GL_CURRENT_VERTEX_ATTRIB 0x8626 #define GL_CW 0x0900 +#define GL_DEBUG_CALLBACK_FUNCTION 0x8244 +#define GL_DEBUG_CALLBACK_USER_PARAM 0x8245 +#define GL_DEBUG_GROUP_STACK_DEPTH 0x826D +#define GL_DEBUG_LOGGED_MESSAGES 0x9145 +#define GL_DEBUG_NEXT_LOGGED_MESSAGE_LENGTH 0x8243 +#define GL_DEBUG_OUTPUT 0x92E0 +#define GL_DEBUG_OUTPUT_SYNCHRONOUS 0x8242 +#define GL_DEBUG_SEVERITY_HIGH 0x9146 +#define GL_DEBUG_SEVERITY_LOW 0x9148 +#define GL_DEBUG_SEVERITY_MEDIUM 0x9147 +#define GL_DEBUG_SEVERITY_NOTIFICATION 0x826B +#define GL_DEBUG_SOURCE_API 0x8246 +#define GL_DEBUG_SOURCE_APPLICATION 0x824A +#define GL_DEBUG_SOURCE_OTHER 0x824B +#define GL_DEBUG_SOURCE_SHADER_COMPILER 0x8248 +#define GL_DEBUG_SOURCE_THIRD_PARTY 0x8249 +#define GL_DEBUG_SOURCE_WINDOW_SYSTEM 0x8247 +#define GL_DEBUG_TYPE_DEPRECATED_BEHAVIOR 0x824D +#define GL_DEBUG_TYPE_ERROR 0x824C +#define GL_DEBUG_TYPE_MARKER 0x8268 +#define GL_DEBUG_TYPE_OTHER 0x8251 +#define GL_DEBUG_TYPE_PERFORMANCE 0x8250 +#define GL_DEBUG_TYPE_POP_GROUP 0x826A +#define GL_DEBUG_TYPE_PORTABILITY 0x824F +#define GL_DEBUG_TYPE_PUSH_GROUP 0x8269 +#define GL_DEBUG_TYPE_UNDEFINED_BEHAVIOR 0x824E #define GL_DECR 0x1E03 #define GL_DECR_WRAP 0x8508 #define GL_DELETE_STATUS 0x8B80 @@ -324,16 +416,33 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_DEPTH_COMPONENT24 0x81A6 #define GL_DEPTH_COMPONENT32 0x81A7 #define GL_DEPTH_COMPONENT32F 0x8CAC +#define GL_DEPTH_COMPONENTS 0x8284 #define GL_DEPTH_FUNC 0x0B74 #define GL_DEPTH_RANGE 0x0B70 +#define GL_DEPTH_RENDERABLE 0x8287 #define GL_DEPTH_STENCIL 0x84F9 #define GL_DEPTH_STENCIL_ATTACHMENT 0x821A +#define GL_DEPTH_STENCIL_TEXTURE_MODE 0x90EA #define GL_DEPTH_TEST 0x0B71 #define GL_DEPTH_WRITEMASK 0x0B72 +#define GL_DISPATCH_INDIRECT_BUFFER 0x90EE +#define GL_DISPATCH_INDIRECT_BUFFER_BINDING 0x90EF #define GL_DITHER 0x0BD0 #define GL_DONT_CARE 0x1100 #define GL_DOUBLE 0x140A #define GL_DOUBLEBUFFER 0x0C32 +#define GL_DOUBLE_MAT2 0x8F46 +#define GL_DOUBLE_MAT2x3 0x8F49 +#define GL_DOUBLE_MAT2x4 0x8F4A +#define GL_DOUBLE_MAT3 0x8F47 +#define GL_DOUBLE_MAT3x2 0x8F4B +#define GL_DOUBLE_MAT3x4 0x8F4C +#define GL_DOUBLE_MAT4 0x8F48 +#define GL_DOUBLE_MAT4x2 0x8F4D +#define GL_DOUBLE_MAT4x3 0x8F4E +#define GL_DOUBLE_VEC2 0x8FFC +#define GL_DOUBLE_VEC3 0x8FFD +#define GL_DOUBLE_VEC4 0x8FFE #define GL_DRAW_BUFFER 0x0C01 #define GL_DRAW_BUFFER0 0x8825 #define GL_DRAW_BUFFER1 0x8826 @@ -353,11 +462,14 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_DRAW_BUFFER9 0x882E #define GL_DRAW_FRAMEBUFFER 0x8CA9 #define GL_DRAW_FRAMEBUFFER_BINDING 0x8CA6 +#define GL_DRAW_INDIRECT_BUFFER 0x8F3F +#define GL_DRAW_INDIRECT_BUFFER_BINDING 0x8F43 #define GL_DST_ALPHA 0x0304 #define GL_DST_COLOR 0x0306 #define GL_DYNAMIC_COPY 0x88EA #define GL_DYNAMIC_DRAW 0x88E8 #define GL_DYNAMIC_READ 0x88E9 +#define GL_ELEMENT_ARRAY_BARRIER_BIT 0x00000002 #define GL_ELEMENT_ARRAY_BUFFER 0x8893 #define GL_ELEMENT_ARRAY_BUFFER_BINDING 0x8895 #define GL_EQUAL 0x0202 @@ -366,7 +478,9 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_FALSE 0 #define GL_FASTEST 0x1101 #define GL_FILL 0x1B02 +#define GL_FILTER 0x829A #define GL_FIRST_VERTEX_CONVENTION 0x8E4D +#define GL_FIXED 0x140C #define GL_FIXED_ONLY 0x891D #define GL_FLOAT 0x1406 #define GL_FLOAT_32_UNSIGNED_INT_24_8_REV 0x8DAD @@ -382,8 +496,15 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_FLOAT_VEC2 0x8B50 #define GL_FLOAT_VEC3 0x8B51 #define GL_FLOAT_VEC4 0x8B52 +#define GL_FRACTIONAL_EVEN 0x8E7C +#define GL_FRACTIONAL_ODD 0x8E7B +#define GL_FRAGMENT_INTERPOLATION_OFFSET_BITS 0x8E5D #define GL_FRAGMENT_SHADER 0x8B30 +#define GL_FRAGMENT_SHADER_BIT 0x00000002 #define GL_FRAGMENT_SHADER_DERIVATIVE_HINT 0x8B8B +#define GL_FRAGMENT_SUBROUTINE 0x92EC +#define GL_FRAGMENT_SUBROUTINE_UNIFORM 0x92F2 +#define GL_FRAGMENT_TEXTURE 0x829F #define GL_FRAMEBUFFER 0x8D40 #define GL_FRAMEBUFFER_ATTACHMENT_ALPHA_SIZE 0x8215 #define GL_FRAMEBUFFER_ATTACHMENT_BLUE_SIZE 0x8214 @@ -399,15 +520,24 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_FRAMEBUFFER_ATTACHMENT_TEXTURE_CUBE_MAP_FACE 0x8CD3 #define GL_FRAMEBUFFER_ATTACHMENT_TEXTURE_LAYER 0x8CD4 #define GL_FRAMEBUFFER_ATTACHMENT_TEXTURE_LEVEL 0x8CD2 +#define GL_FRAMEBUFFER_BARRIER_BIT 0x00000400 #define GL_FRAMEBUFFER_BINDING 0x8CA6 +#define GL_FRAMEBUFFER_BLEND 0x828B #define GL_FRAMEBUFFER_COMPLETE 0x8CD5 #define GL_FRAMEBUFFER_DEFAULT 0x8218 +#define GL_FRAMEBUFFER_DEFAULT_FIXED_SAMPLE_LOCATIONS 0x9314 +#define GL_FRAMEBUFFER_DEFAULT_HEIGHT 0x9311 +#define GL_FRAMEBUFFER_DEFAULT_LAYERS 0x9312 +#define GL_FRAMEBUFFER_DEFAULT_SAMPLES 0x9313 +#define GL_FRAMEBUFFER_DEFAULT_WIDTH 0x9310 #define GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT 0x8CD6 #define GL_FRAMEBUFFER_INCOMPLETE_DRAW_BUFFER 0x8CDB #define GL_FRAMEBUFFER_INCOMPLETE_LAYER_TARGETS 0x8DA8 #define GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT 0x8CD7 #define GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE 0x8D56 #define GL_FRAMEBUFFER_INCOMPLETE_READ_BUFFER 0x8CDC +#define GL_FRAMEBUFFER_RENDERABLE 0x8289 +#define GL_FRAMEBUFFER_RENDERABLE_LAYERED 0x828A #define GL_FRAMEBUFFER_SRGB 0x8DB9 #define GL_FRAMEBUFFER_UNDEFINED 0x8219 #define GL_FRAMEBUFFER_UNSUPPORTED 0x8CDD @@ -416,24 +546,97 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_FRONT_FACE 0x0B46 #define GL_FRONT_LEFT 0x0400 #define GL_FRONT_RIGHT 0x0401 +#define GL_FULL_SUPPORT 0x82B7 #define GL_FUNC_ADD 0x8006 #define GL_FUNC_REVERSE_SUBTRACT 0x800B #define GL_FUNC_SUBTRACT 0x800A #define GL_GEOMETRY_INPUT_TYPE 0x8917 #define GL_GEOMETRY_OUTPUT_TYPE 0x8918 #define GL_GEOMETRY_SHADER 0x8DD9 +#define GL_GEOMETRY_SHADER_BIT 0x00000004 +#define GL_GEOMETRY_SHADER_INVOCATIONS 0x887F +#define GL_GEOMETRY_SUBROUTINE 0x92EB +#define GL_GEOMETRY_SUBROUTINE_UNIFORM 0x92F1 +#define GL_GEOMETRY_TEXTURE 0x829E #define GL_GEOMETRY_VERTICES_OUT 0x8916 #define GL_GEQUAL 0x0206 +#define GL_GET_TEXTURE_IMAGE_FORMAT 0x8291 +#define GL_GET_TEXTURE_IMAGE_TYPE 0x8292 #define GL_GREATER 0x0204 #define GL_GREEN 0x1904 #define GL_GREEN_INTEGER 0x8D95 #define GL_HALF_FLOAT 0x140B +#define GL_HIGH_FLOAT 0x8DF2 +#define GL_HIGH_INT 0x8DF5 +#define GL_IMAGE_1D 0x904C +#define GL_IMAGE_1D_ARRAY 0x9052 +#define GL_IMAGE_2D 0x904D +#define GL_IMAGE_2D_ARRAY 0x9053 +#define GL_IMAGE_2D_MULTISAMPLE 0x9055 +#define GL_IMAGE_2D_MULTISAMPLE_ARRAY 0x9056 +#define GL_IMAGE_2D_RECT 0x904F +#define GL_IMAGE_3D 0x904E +#define GL_IMAGE_BINDING_ACCESS 0x8F3E +#define GL_IMAGE_BINDING_FORMAT 0x906E +#define GL_IMAGE_BINDING_LAYER 0x8F3D +#define GL_IMAGE_BINDING_LAYERED 0x8F3C +#define GL_IMAGE_BINDING_LEVEL 0x8F3B +#define GL_IMAGE_BINDING_NAME 0x8F3A +#define GL_IMAGE_BUFFER 0x9051 +#define GL_IMAGE_CLASS_10_10_10_2 0x82C3 +#define GL_IMAGE_CLASS_11_11_10 0x82C2 +#define GL_IMAGE_CLASS_1_X_16 0x82BE +#define GL_IMAGE_CLASS_1_X_32 0x82BB +#define GL_IMAGE_CLASS_1_X_8 0x82C1 +#define GL_IMAGE_CLASS_2_X_16 0x82BD +#define GL_IMAGE_CLASS_2_X_32 0x82BA +#define GL_IMAGE_CLASS_2_X_8 0x82C0 +#define GL_IMAGE_CLASS_4_X_16 0x82BC +#define GL_IMAGE_CLASS_4_X_32 0x82B9 +#define GL_IMAGE_CLASS_4_X_8 0x82BF +#define GL_IMAGE_COMPATIBILITY_CLASS 0x82A8 +#define GL_IMAGE_CUBE 0x9050 +#define GL_IMAGE_CUBE_MAP_ARRAY 0x9054 +#define GL_IMAGE_FORMAT_COMPATIBILITY_BY_CLASS 0x90C9 +#define GL_IMAGE_FORMAT_COMPATIBILITY_BY_SIZE 0x90C8 +#define GL_IMAGE_FORMAT_COMPATIBILITY_TYPE 0x90C7 +#define GL_IMAGE_PIXEL_FORMAT 0x82A9 +#define GL_IMAGE_PIXEL_TYPE 0x82AA +#define GL_IMAGE_TEXEL_SIZE 0x82A7 +#define GL_IMPLEMENTATION_COLOR_READ_FORMAT 0x8B9B +#define GL_IMPLEMENTATION_COLOR_READ_TYPE 0x8B9A #define GL_INCR 0x1E02 #define GL_INCR_WRAP 0x8507 #define GL_INFO_LOG_LENGTH 0x8B84 #define GL_INT 0x1404 #define GL_INTERLEAVED_ATTRIBS 0x8C8C +#define GL_INTERNALFORMAT_ALPHA_SIZE 0x8274 +#define GL_INTERNALFORMAT_ALPHA_TYPE 0x827B +#define GL_INTERNALFORMAT_BLUE_SIZE 0x8273 +#define GL_INTERNALFORMAT_BLUE_TYPE 0x827A +#define GL_INTERNALFORMAT_DEPTH_SIZE 0x8275 +#define GL_INTERNALFORMAT_DEPTH_TYPE 0x827C +#define GL_INTERNALFORMAT_GREEN_SIZE 0x8272 +#define GL_INTERNALFORMAT_GREEN_TYPE 0x8279 +#define GL_INTERNALFORMAT_PREFERRED 0x8270 +#define GL_INTERNALFORMAT_RED_SIZE 0x8271 +#define GL_INTERNALFORMAT_RED_TYPE 0x8278 +#define GL_INTERNALFORMAT_SHARED_SIZE 0x8277 +#define GL_INTERNALFORMAT_STENCIL_SIZE 0x8276 +#define GL_INTERNALFORMAT_STENCIL_TYPE 0x827D +#define GL_INTERNALFORMAT_SUPPORTED 0x826F #define GL_INT_2_10_10_10_REV 0x8D9F +#define GL_INT_IMAGE_1D 0x9057 +#define GL_INT_IMAGE_1D_ARRAY 0x905D +#define GL_INT_IMAGE_2D 0x9058 +#define GL_INT_IMAGE_2D_ARRAY 0x905E +#define GL_INT_IMAGE_2D_MULTISAMPLE 0x9060 +#define GL_INT_IMAGE_2D_MULTISAMPLE_ARRAY 0x9061 +#define GL_INT_IMAGE_2D_RECT 0x905A +#define GL_INT_IMAGE_3D 0x9059 +#define GL_INT_IMAGE_BUFFER 0x905C +#define GL_INT_IMAGE_CUBE 0x905B +#define GL_INT_IMAGE_CUBE_MAP_ARRAY 0x905F #define GL_INT_SAMPLER_1D 0x8DC9 #define GL_INT_SAMPLER_1D_ARRAY 0x8DCE #define GL_INT_SAMPLER_2D 0x8DCA @@ -444,6 +647,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_INT_SAMPLER_3D 0x8DCB #define GL_INT_SAMPLER_BUFFER 0x8DD0 #define GL_INT_SAMPLER_CUBE 0x8DCC +#define GL_INT_SAMPLER_CUBE_MAP_ARRAY 0x900E #define GL_INT_VEC2 0x8B53 #define GL_INT_VEC3 0x8B54 #define GL_INT_VEC4 0x8B55 @@ -453,8 +657,12 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_INVALID_OPERATION 0x0502 #define GL_INVALID_VALUE 0x0501 #define GL_INVERT 0x150A +#define GL_ISOLINES 0x8E7A +#define GL_IS_PER_PATCH 0x92E7 +#define GL_IS_ROW_MAJOR 0x9300 #define GL_KEEP 0x1E00 #define GL_LAST_VERTEX_CONVENTION 0x8E4E +#define GL_LAYER_PROVOKING_VERTEX 0x825E #define GL_LEFT 0x0406 #define GL_LEQUAL 0x0203 #define GL_LESS 0x0201 @@ -473,71 +681,176 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_LINE_WIDTH_GRANULARITY 0x0B23 #define GL_LINE_WIDTH_RANGE 0x0B22 #define GL_LINK_STATUS 0x8B82 +#define GL_LOCATION 0x930E +#define GL_LOCATION_INDEX 0x930F #define GL_LOGIC_OP_MODE 0x0BF0 #define GL_LOWER_LEFT 0x8CA1 +#define GL_LOW_FLOAT 0x8DF0 +#define GL_LOW_INT 0x8DF3 #define GL_MAJOR_VERSION 0x821B +#define GL_MANUAL_GENERATE_MIPMAP 0x8294 #define GL_MAP_FLUSH_EXPLICIT_BIT 0x0010 #define GL_MAP_INVALIDATE_BUFFER_BIT 0x0008 #define GL_MAP_INVALIDATE_RANGE_BIT 0x0004 #define GL_MAP_READ_BIT 0x0001 #define GL_MAP_UNSYNCHRONIZED_BIT 0x0020 #define GL_MAP_WRITE_BIT 0x0002 +#define GL_MATRIX_STRIDE 0x92FF #define GL_MAX 0x8008 #define GL_MAX_3D_TEXTURE_SIZE 0x8073 #define GL_MAX_ARRAY_TEXTURE_LAYERS 0x88FF +#define GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS 0x92DC +#define GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE 0x92D8 #define GL_MAX_CLIP_DISTANCES 0x0D32 #define GL_MAX_COLOR_ATTACHMENTS 0x8CDF #define GL_MAX_COLOR_TEXTURE_SAMPLES 0x910E +#define GL_MAX_COMBINED_ATOMIC_COUNTERS 0x92D7 +#define GL_MAX_COMBINED_ATOMIC_COUNTER_BUFFERS 0x92D1 +#define GL_MAX_COMBINED_COMPUTE_UNIFORM_COMPONENTS 0x8266 +#define GL_MAX_COMBINED_DIMENSIONS 0x8282 #define GL_MAX_COMBINED_FRAGMENT_UNIFORM_COMPONENTS 0x8A33 #define GL_MAX_COMBINED_GEOMETRY_UNIFORM_COMPONENTS 0x8A32 +#define GL_MAX_COMBINED_IMAGE_UNIFORMS 0x90CF +#define GL_MAX_COMBINED_IMAGE_UNITS_AND_FRAGMENT_OUTPUTS 0x8F39 +#define GL_MAX_COMBINED_SHADER_OUTPUT_RESOURCES 0x8F39 +#define GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS 0x90DC +#define GL_MAX_COMBINED_TESS_CONTROL_UNIFORM_COMPONENTS 0x8E1E +#define GL_MAX_COMBINED_TESS_EVALUATION_UNIFORM_COMPONENTS 0x8E1F #define GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS 0x8B4D #define GL_MAX_COMBINED_UNIFORM_BLOCKS 0x8A2E #define GL_MAX_COMBINED_VERTEX_UNIFORM_COMPONENTS 0x8A31 +#define GL_MAX_COMPUTE_ATOMIC_COUNTERS 0x8265 +#define GL_MAX_COMPUTE_ATOMIC_COUNTER_BUFFERS 0x8264 +#define GL_MAX_COMPUTE_IMAGE_UNIFORMS 0x91BD +#define GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS 0x90DB +#define GL_MAX_COMPUTE_SHARED_MEMORY_SIZE 0x8262 +#define GL_MAX_COMPUTE_TEXTURE_IMAGE_UNITS 0x91BC +#define GL_MAX_COMPUTE_UNIFORM_BLOCKS 0x91BB +#define GL_MAX_COMPUTE_UNIFORM_COMPONENTS 0x8263 +#define GL_MAX_COMPUTE_WORK_GROUP_COUNT 0x91BE +#define GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS 0x90EB +#define GL_MAX_COMPUTE_WORK_GROUP_SIZE 0x91BF #define GL_MAX_CUBE_MAP_TEXTURE_SIZE 0x851C +#define GL_MAX_DEBUG_GROUP_STACK_DEPTH 0x826C +#define GL_MAX_DEBUG_LOGGED_MESSAGES 0x9144 +#define GL_MAX_DEBUG_MESSAGE_LENGTH 0x9143 +#define GL_MAX_DEPTH 0x8280 #define GL_MAX_DEPTH_TEXTURE_SAMPLES 0x910F #define GL_MAX_DRAW_BUFFERS 0x8824 #define GL_MAX_DUAL_SOURCE_DRAW_BUFFERS 0x88FC #define GL_MAX_ELEMENTS_INDICES 0x80E9 #define GL_MAX_ELEMENTS_VERTICES 0x80E8 +#define GL_MAX_ELEMENT_INDEX 0x8D6B +#define GL_MAX_FRAGMENT_ATOMIC_COUNTERS 0x92D6 +#define GL_MAX_FRAGMENT_ATOMIC_COUNTER_BUFFERS 0x92D0 +#define GL_MAX_FRAGMENT_IMAGE_UNIFORMS 0x90CE #define GL_MAX_FRAGMENT_INPUT_COMPONENTS 0x9125 +#define GL_MAX_FRAGMENT_INTERPOLATION_OFFSET 0x8E5C +#define GL_MAX_FRAGMENT_SHADER_STORAGE_BLOCKS 0x90DA #define GL_MAX_FRAGMENT_UNIFORM_BLOCKS 0x8A2D #define GL_MAX_FRAGMENT_UNIFORM_COMPONENTS 0x8B49 +#define GL_MAX_FRAGMENT_UNIFORM_VECTORS 0x8DFD +#define GL_MAX_FRAMEBUFFER_HEIGHT 0x9316 +#define GL_MAX_FRAMEBUFFER_LAYERS 0x9317 +#define GL_MAX_FRAMEBUFFER_SAMPLES 0x9318 +#define GL_MAX_FRAMEBUFFER_WIDTH 0x9315 +#define GL_MAX_GEOMETRY_ATOMIC_COUNTERS 0x92D5 +#define GL_MAX_GEOMETRY_ATOMIC_COUNTER_BUFFERS 0x92CF +#define GL_MAX_GEOMETRY_IMAGE_UNIFORMS 0x90CD #define GL_MAX_GEOMETRY_INPUT_COMPONENTS 0x9123 #define GL_MAX_GEOMETRY_OUTPUT_COMPONENTS 0x9124 #define GL_MAX_GEOMETRY_OUTPUT_VERTICES 0x8DE0 +#define GL_MAX_GEOMETRY_SHADER_INVOCATIONS 0x8E5A +#define GL_MAX_GEOMETRY_SHADER_STORAGE_BLOCKS 0x90D7 #define GL_MAX_GEOMETRY_TEXTURE_IMAGE_UNITS 0x8C29 #define GL_MAX_GEOMETRY_TOTAL_OUTPUT_COMPONENTS 0x8DE1 #define GL_MAX_GEOMETRY_UNIFORM_BLOCKS 0x8A2C #define GL_MAX_GEOMETRY_UNIFORM_COMPONENTS 0x8DDF +#define GL_MAX_HEIGHT 0x827F +#define GL_MAX_IMAGE_SAMPLES 0x906D +#define GL_MAX_IMAGE_UNITS 0x8F38 #define GL_MAX_INTEGER_SAMPLES 0x9110 +#define GL_MAX_LABEL_LENGTH 0x82E8 +#define GL_MAX_LAYERS 0x8281 +#define GL_MAX_NAME_LENGTH 0x92F6 +#define GL_MAX_NUM_ACTIVE_VARIABLES 0x92F7 +#define GL_MAX_NUM_COMPATIBLE_SUBROUTINES 0x92F8 +#define GL_MAX_PATCH_VERTICES 0x8E7D #define GL_MAX_PROGRAM_TEXEL_OFFSET 0x8905 +#define GL_MAX_PROGRAM_TEXTURE_GATHER_OFFSET 0x8E5F #define GL_MAX_RECTANGLE_TEXTURE_SIZE 0x84F8 #define GL_MAX_RENDERBUFFER_SIZE 0x84E8 #define GL_MAX_SAMPLES 0x8D57 #define GL_MAX_SAMPLE_MASK_WORDS 0x8E59 #define GL_MAX_SERVER_WAIT_TIMEOUT 0x9111 +#define GL_MAX_SHADER_STORAGE_BLOCK_SIZE 0x90DE +#define GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS 0x90DD +#define GL_MAX_SUBROUTINES 0x8DE7 +#define GL_MAX_SUBROUTINE_UNIFORM_LOCATIONS 0x8DE8 +#define GL_MAX_TESS_CONTROL_ATOMIC_COUNTERS 0x92D3 +#define GL_MAX_TESS_CONTROL_ATOMIC_COUNTER_BUFFERS 0x92CD +#define GL_MAX_TESS_CONTROL_IMAGE_UNIFORMS 0x90CB +#define GL_MAX_TESS_CONTROL_INPUT_COMPONENTS 0x886C +#define GL_MAX_TESS_CONTROL_OUTPUT_COMPONENTS 0x8E83 +#define GL_MAX_TESS_CONTROL_SHADER_STORAGE_BLOCKS 0x90D8 +#define GL_MAX_TESS_CONTROL_TEXTURE_IMAGE_UNITS 0x8E81 +#define GL_MAX_TESS_CONTROL_TOTAL_OUTPUT_COMPONENTS 0x8E85 +#define GL_MAX_TESS_CONTROL_UNIFORM_BLOCKS 0x8E89 +#define GL_MAX_TESS_CONTROL_UNIFORM_COMPONENTS 0x8E7F +#define GL_MAX_TESS_EVALUATION_ATOMIC_COUNTERS 0x92D4 +#define GL_MAX_TESS_EVALUATION_ATOMIC_COUNTER_BUFFERS 0x92CE +#define GL_MAX_TESS_EVALUATION_IMAGE_UNIFORMS 0x90CC +#define GL_MAX_TESS_EVALUATION_INPUT_COMPONENTS 0x886D +#define GL_MAX_TESS_EVALUATION_OUTPUT_COMPONENTS 0x8E86 +#define GL_MAX_TESS_EVALUATION_SHADER_STORAGE_BLOCKS 0x90D9 +#define GL_MAX_TESS_EVALUATION_TEXTURE_IMAGE_UNITS 0x8E82 +#define GL_MAX_TESS_EVALUATION_UNIFORM_BLOCKS 0x8E8A +#define GL_MAX_TESS_EVALUATION_UNIFORM_COMPONENTS 0x8E80 +#define GL_MAX_TESS_GEN_LEVEL 0x8E7E +#define GL_MAX_TESS_PATCH_COMPONENTS 0x8E84 #define GL_MAX_TEXTURE_BUFFER_SIZE 0x8C2B #define GL_MAX_TEXTURE_IMAGE_UNITS 0x8872 #define GL_MAX_TEXTURE_LOD_BIAS 0x84FD #define GL_MAX_TEXTURE_SIZE 0x0D33 +#define GL_MAX_TRANSFORM_FEEDBACK_BUFFERS 0x8E70 #define GL_MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS 0x8C8A #define GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS 0x8C8B #define GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_COMPONENTS 0x8C80 #define GL_MAX_UNIFORM_BLOCK_SIZE 0x8A30 #define GL_MAX_UNIFORM_BUFFER_BINDINGS 0x8A2F +#define GL_MAX_UNIFORM_LOCATIONS 0x826E #define GL_MAX_VARYING_COMPONENTS 0x8B4B #define GL_MAX_VARYING_FLOATS 0x8B4B +#define GL_MAX_VARYING_VECTORS 0x8DFC +#define GL_MAX_VERTEX_ATOMIC_COUNTERS 0x92D2 +#define GL_MAX_VERTEX_ATOMIC_COUNTER_BUFFERS 0x92CC #define GL_MAX_VERTEX_ATTRIBS 0x8869 +#define GL_MAX_VERTEX_ATTRIB_BINDINGS 0x82DA +#define GL_MAX_VERTEX_ATTRIB_RELATIVE_OFFSET 0x82D9 +#define GL_MAX_VERTEX_IMAGE_UNIFORMS 0x90CA #define GL_MAX_VERTEX_OUTPUT_COMPONENTS 0x9122 +#define GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS 0x90D6 +#define GL_MAX_VERTEX_STREAMS 0x8E71 #define GL_MAX_VERTEX_TEXTURE_IMAGE_UNITS 0x8B4C #define GL_MAX_VERTEX_UNIFORM_BLOCKS 0x8A2B #define GL_MAX_VERTEX_UNIFORM_COMPONENTS 0x8B4A +#define GL_MAX_VERTEX_UNIFORM_VECTORS 0x8DFB +#define GL_MAX_VIEWPORTS 0x825B #define GL_MAX_VIEWPORT_DIMS 0x0D3A +#define GL_MAX_WIDTH 0x827E +#define GL_MEDIUM_FLOAT 0x8DF1 +#define GL_MEDIUM_INT 0x8DF4 #define GL_MIN 0x8007 #define GL_MINOR_VERSION 0x821C +#define GL_MIN_FRAGMENT_INTERPOLATION_OFFSET 0x8E5B +#define GL_MIN_MAP_BUFFER_ALIGNMENT 0x90BC #define GL_MIN_PROGRAM_TEXEL_OFFSET 0x8904 +#define GL_MIN_PROGRAM_TEXTURE_GATHER_OFFSET 0x8E5E +#define GL_MIN_SAMPLE_SHADING_VALUE 0x8C37 +#define GL_MIPMAP 0x8293 #define GL_MIRRORED_REPEAT 0x8370 #define GL_MULTISAMPLE 0x809D +#define GL_NAME_LENGTH 0x92F9 #define GL_NAND 0x150E #define GL_NEAREST 0x2600 #define GL_NEAREST_MIPMAP_LINEAR 0x2702 @@ -549,9 +862,16 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_NOR 0x1508 #define GL_NOTEQUAL 0x0205 #define GL_NO_ERROR 0 +#define GL_NUM_ACTIVE_VARIABLES 0x9304 +#define GL_NUM_COMPATIBLE_SUBROUTINES 0x8E4A #define GL_NUM_COMPRESSED_TEXTURE_FORMATS 0x86A2 #define GL_NUM_EXTENSIONS 0x821D +#define GL_NUM_PROGRAM_BINARY_FORMATS 0x87FE +#define GL_NUM_SAMPLE_COUNTS 0x9380 +#define GL_NUM_SHADER_BINARY_FORMATS 0x8DF9 +#define GL_NUM_SHADING_LANGUAGE_VERSIONS 0x82E9 #define GL_OBJECT_TYPE 0x9112 +#define GL_OFFSET 0x92FC #define GL_ONE 1 #define GL_ONE_MINUS_CONSTANT_ALPHA 0x8004 #define GL_ONE_MINUS_CONSTANT_COLOR 0x8002 @@ -566,6 +886,10 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_OR_REVERSE 0x150B #define GL_OUT_OF_MEMORY 0x0505 #define GL_PACK_ALIGNMENT 0x0D05 +#define GL_PACK_COMPRESSED_BLOCK_DEPTH 0x912D +#define GL_PACK_COMPRESSED_BLOCK_HEIGHT 0x912C +#define GL_PACK_COMPRESSED_BLOCK_SIZE 0x912E +#define GL_PACK_COMPRESSED_BLOCK_WIDTH 0x912B #define GL_PACK_IMAGE_HEIGHT 0x806C #define GL_PACK_LSB_FIRST 0x0D01 #define GL_PACK_ROW_LENGTH 0x0D02 @@ -573,6 +897,11 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_PACK_SKIP_PIXELS 0x0D04 #define GL_PACK_SKIP_ROWS 0x0D03 #define GL_PACK_SWAP_BYTES 0x0D00 +#define GL_PATCHES 0x000E +#define GL_PATCH_DEFAULT_INNER_LEVEL 0x8E73 +#define GL_PATCH_DEFAULT_OUTER_LEVEL 0x8E74 +#define GL_PATCH_VERTICES 0x8E72 +#define GL_PIXEL_BUFFER_BARRIER_BIT 0x00000080 #define GL_PIXEL_PACK_BUFFER 0x88EB #define GL_PIXEL_PACK_BUFFER_BINDING 0x88ED #define GL_PIXEL_UNPACK_BUFFER 0x88EC @@ -594,8 +923,18 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_POLYGON_SMOOTH_HINT 0x0C53 #define GL_PRIMITIVES_GENERATED 0x8C87 #define GL_PRIMITIVE_RESTART 0x8F9D +#define GL_PRIMITIVE_RESTART_FIXED_INDEX 0x8D69 #define GL_PRIMITIVE_RESTART_INDEX 0x8F9E +#define GL_PROGRAM 0x82E2 +#define GL_PROGRAM_BINARY_FORMATS 0x87FF +#define GL_PROGRAM_BINARY_LENGTH 0x8741 +#define GL_PROGRAM_BINARY_RETRIEVABLE_HINT 0x8257 +#define GL_PROGRAM_INPUT 0x92E3 +#define GL_PROGRAM_OUTPUT 0x92E4 +#define GL_PROGRAM_PIPELINE 0x82E4 +#define GL_PROGRAM_PIPELINE_BINDING 0x825A #define GL_PROGRAM_POINT_SIZE 0x8642 +#define GL_PROGRAM_SEPARABLE 0x8258 #define GL_PROVOKING_VERTEX 0x8E4F #define GL_PROXY_TEXTURE_1D 0x8063 #define GL_PROXY_TEXTURE_1D_ARRAY 0x8C19 @@ -605,8 +944,11 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_PROXY_TEXTURE_2D_MULTISAMPLE_ARRAY 0x9103 #define GL_PROXY_TEXTURE_3D 0x8070 #define GL_PROXY_TEXTURE_CUBE_MAP 0x851B +#define GL_PROXY_TEXTURE_CUBE_MAP_ARRAY 0x900B #define GL_PROXY_TEXTURE_RECTANGLE 0x84F7 +#define GL_QUADS 0x0007 #define GL_QUADS_FOLLOW_PROVOKING_VERTEX_CONVENTION 0x8E4C +#define GL_QUERY 0x82E3 #define GL_QUERY_BY_REGION_NO_WAIT 0x8E16 #define GL_QUERY_BY_REGION_WAIT 0x8E15 #define GL_QUERY_COUNTER_BITS 0x8864 @@ -633,9 +975,18 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_READ_FRAMEBUFFER 0x8CA8 #define GL_READ_FRAMEBUFFER_BINDING 0x8CAA #define GL_READ_ONLY 0x88B8 +#define GL_READ_PIXELS 0x828C +#define GL_READ_PIXELS_FORMAT 0x828D +#define GL_READ_PIXELS_TYPE 0x828E #define GL_READ_WRITE 0x88BA #define GL_RED 0x1903 #define GL_RED_INTEGER 0x8D94 +#define GL_REFERENCED_BY_COMPUTE_SHADER 0x930B +#define GL_REFERENCED_BY_FRAGMENT_SHADER 0x930A +#define GL_REFERENCED_BY_GEOMETRY_SHADER 0x9309 +#define GL_REFERENCED_BY_TESS_CONTROL_SHADER 0x9307 +#define GL_REFERENCED_BY_TESS_EVALUATION_SHADER 0x9308 +#define GL_REFERENCED_BY_VERTEX_SHADER 0x9306 #define GL_RENDERBUFFER 0x8D41 #define GL_RENDERBUFFER_ALPHA_SIZE 0x8D53 #define GL_RENDERBUFFER_BINDING 0x8CA7 @@ -679,6 +1030,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_RGB32UI 0x8D71 #define GL_RGB4 0x804F #define GL_RGB5 0x8050 +#define GL_RGB565 0x8D62 #define GL_RGB5_A1 0x8057 #define GL_RGB8 0x8051 #define GL_RGB8I 0x8D8F @@ -705,6 +1057,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_RGB_INTEGER 0x8D98 #define GL_RG_INTEGER 0x8228 #define GL_RIGHT 0x0407 +#define GL_SAMPLER 0x82E6 #define GL_SAMPLER_1D 0x8B5D #define GL_SAMPLER_1D_ARRAY 0x8DC0 #define GL_SAMPLER_1D_ARRAY_SHADOW 0x8DC3 @@ -721,6 +1074,8 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_SAMPLER_BINDING 0x8919 #define GL_SAMPLER_BUFFER 0x8DC2 #define GL_SAMPLER_CUBE 0x8B60 +#define GL_SAMPLER_CUBE_MAP_ARRAY 0x900C +#define GL_SAMPLER_CUBE_MAP_ARRAY_SHADOW 0x900D #define GL_SAMPLER_CUBE_SHADOW 0x8DC5 #define GL_SAMPLES 0x80A9 #define GL_SAMPLES_PASSED 0x8914 @@ -733,16 +1088,35 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_SAMPLE_MASK 0x8E51 #define GL_SAMPLE_MASK_VALUE 0x8E52 #define GL_SAMPLE_POSITION 0x8E50 +#define GL_SAMPLE_SHADING 0x8C36 #define GL_SCISSOR_BOX 0x0C10 #define GL_SCISSOR_TEST 0x0C11 #define GL_SEPARATE_ATTRIBS 0x8C8D #define GL_SET 0x150F +#define GL_SHADER 0x82E1 +#define GL_SHADER_BINARY_FORMATS 0x8DF8 +#define GL_SHADER_COMPILER 0x8DFA +#define GL_SHADER_IMAGE_ACCESS_BARRIER_BIT 0x00000020 +#define GL_SHADER_IMAGE_ATOMIC 0x82A6 +#define GL_SHADER_IMAGE_LOAD 0x82A4 +#define GL_SHADER_IMAGE_STORE 0x82A5 #define GL_SHADER_SOURCE_LENGTH 0x8B88 +#define GL_SHADER_STORAGE_BARRIER_BIT 0x00002000 +#define GL_SHADER_STORAGE_BLOCK 0x92E6 +#define GL_SHADER_STORAGE_BUFFER 0x90D2 +#define GL_SHADER_STORAGE_BUFFER_BINDING 0x90D3 +#define GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT 0x90DF +#define GL_SHADER_STORAGE_BUFFER_SIZE 0x90D5 +#define GL_SHADER_STORAGE_BUFFER_START 0x90D4 #define GL_SHADER_TYPE 0x8B4F #define GL_SHADING_LANGUAGE_VERSION 0x8B8C #define GL_SHORT 0x1402 #define GL_SIGNALED 0x9119 #define GL_SIGNED_NORMALIZED 0x8F9C +#define GL_SIMULTANEOUS_TEXTURE_AND_DEPTH_TEST 0x82AC +#define GL_SIMULTANEOUS_TEXTURE_AND_DEPTH_WRITE 0x82AE +#define GL_SIMULTANEOUS_TEXTURE_AND_STENCIL_TEST 0x82AD +#define GL_SIMULTANEOUS_TEXTURE_AND_STENCIL_WRITE 0x82AF #define GL_SMOOTH_LINE_WIDTH_GRANULARITY 0x0B23 #define GL_SMOOTH_LINE_WIDTH_RANGE 0x0B22 #define GL_SMOOTH_POINT_SIZE_GRANULARITY 0x0B13 @@ -756,6 +1130,10 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_SRGB8 0x8C41 #define GL_SRGB8_ALPHA8 0x8C43 #define GL_SRGB_ALPHA 0x8C42 +#define GL_SRGB_READ 0x8297 +#define GL_SRGB_WRITE 0x8298 +#define GL_STACK_OVERFLOW 0x0503 +#define GL_STACK_UNDERFLOW 0x0504 #define GL_STATIC_COPY 0x88E6 #define GL_STATIC_DRAW 0x88E4 #define GL_STATIC_READ 0x88E5 @@ -770,6 +1148,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_STENCIL_BACK_WRITEMASK 0x8CA5 #define GL_STENCIL_BUFFER_BIT 0x00000400 #define GL_STENCIL_CLEAR_VALUE 0x0B91 +#define GL_STENCIL_COMPONENTS 0x8285 #define GL_STENCIL_FAIL 0x0B94 #define GL_STENCIL_FUNC 0x0B92 #define GL_STENCIL_INDEX 0x1901 @@ -780,6 +1159,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_STENCIL_PASS_DEPTH_FAIL 0x0B95 #define GL_STENCIL_PASS_DEPTH_PASS 0x0B96 #define GL_STENCIL_REF 0x0B97 +#define GL_STENCIL_RENDERABLE 0x8288 #define GL_STENCIL_TEST 0x0B90 #define GL_STENCIL_VALUE_MASK 0x0B93 #define GL_STENCIL_WRITEMASK 0x0B98 @@ -794,6 +1174,21 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_SYNC_FLUSH_COMMANDS_BIT 0x00000001 #define GL_SYNC_GPU_COMMANDS_COMPLETE 0x9117 #define GL_SYNC_STATUS 0x9114 +#define GL_TESS_CONTROL_OUTPUT_VERTICES 0x8E75 +#define GL_TESS_CONTROL_SHADER 0x8E88 +#define GL_TESS_CONTROL_SHADER_BIT 0x00000008 +#define GL_TESS_CONTROL_SUBROUTINE 0x92E9 +#define GL_TESS_CONTROL_SUBROUTINE_UNIFORM 0x92EF +#define GL_TESS_CONTROL_TEXTURE 0x829C +#define GL_TESS_EVALUATION_SHADER 0x8E87 +#define GL_TESS_EVALUATION_SHADER_BIT 0x00000010 +#define GL_TESS_EVALUATION_SUBROUTINE 0x92EA +#define GL_TESS_EVALUATION_SUBROUTINE_UNIFORM 0x92F0 +#define GL_TESS_EVALUATION_TEXTURE 0x829D +#define GL_TESS_GEN_MODE 0x8E76 +#define GL_TESS_GEN_POINT_MODE 0x8E79 +#define GL_TESS_GEN_SPACING 0x8E77 +#define GL_TESS_GEN_VERTEX_ORDER 0x8E78 #define GL_TEXTURE 0x1702 #define GL_TEXTURE0 0x84C0 #define GL_TEXTURE1 0x84C1 @@ -846,18 +1241,26 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_TEXTURE_BINDING_3D 0x806A #define GL_TEXTURE_BINDING_BUFFER 0x8C2C #define GL_TEXTURE_BINDING_CUBE_MAP 0x8514 +#define GL_TEXTURE_BINDING_CUBE_MAP_ARRAY 0x900A #define GL_TEXTURE_BINDING_RECTANGLE 0x84F6 #define GL_TEXTURE_BLUE_SIZE 0x805E #define GL_TEXTURE_BLUE_TYPE 0x8C12 #define GL_TEXTURE_BORDER_COLOR 0x1004 #define GL_TEXTURE_BUFFER 0x8C2A #define GL_TEXTURE_BUFFER_DATA_STORE_BINDING 0x8C2D +#define GL_TEXTURE_BUFFER_OFFSET 0x919D +#define GL_TEXTURE_BUFFER_OFFSET_ALIGNMENT 0x919F +#define GL_TEXTURE_BUFFER_SIZE 0x919E #define GL_TEXTURE_COMPARE_FUNC 0x884D #define GL_TEXTURE_COMPARE_MODE 0x884C #define GL_TEXTURE_COMPRESSED 0x86A1 +#define GL_TEXTURE_COMPRESSED_BLOCK_HEIGHT 0x82B2 +#define GL_TEXTURE_COMPRESSED_BLOCK_SIZE 0x82B3 +#define GL_TEXTURE_COMPRESSED_BLOCK_WIDTH 0x82B1 #define GL_TEXTURE_COMPRESSED_IMAGE_SIZE 0x86A0 #define GL_TEXTURE_COMPRESSION_HINT 0x84EF #define GL_TEXTURE_CUBE_MAP 0x8513 +#define GL_TEXTURE_CUBE_MAP_ARRAY 0x9009 #define GL_TEXTURE_CUBE_MAP_NEGATIVE_X 0x8516 #define GL_TEXTURE_CUBE_MAP_NEGATIVE_Y 0x8518 #define GL_TEXTURE_CUBE_MAP_NEGATIVE_Z 0x851A @@ -868,10 +1271,17 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_TEXTURE_DEPTH 0x8071 #define GL_TEXTURE_DEPTH_SIZE 0x884A #define GL_TEXTURE_DEPTH_TYPE 0x8C16 +#define GL_TEXTURE_FETCH_BARRIER_BIT 0x00000008 #define GL_TEXTURE_FIXED_SAMPLE_LOCATIONS 0x9107 +#define GL_TEXTURE_GATHER 0x82A2 +#define GL_TEXTURE_GATHER_SHADOW 0x82A3 #define GL_TEXTURE_GREEN_SIZE 0x805D #define GL_TEXTURE_GREEN_TYPE 0x8C11 #define GL_TEXTURE_HEIGHT 0x1001 +#define GL_TEXTURE_IMAGE_FORMAT 0x828F +#define GL_TEXTURE_IMAGE_TYPE 0x8290 +#define GL_TEXTURE_IMMUTABLE_FORMAT 0x912F +#define GL_TEXTURE_IMMUTABLE_LEVELS 0x82DF #define GL_TEXTURE_INTERNAL_FORMAT 0x1003 #define GL_TEXTURE_LOD_BIAS 0x8501 #define GL_TEXTURE_MAG_FILTER 0x2800 @@ -883,6 +1293,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_TEXTURE_RED_SIZE 0x805C #define GL_TEXTURE_RED_TYPE 0x8C10 #define GL_TEXTURE_SAMPLES 0x9106 +#define GL_TEXTURE_SHADOW 0x82A1 #define GL_TEXTURE_SHARED_SIZE 0x8C3F #define GL_TEXTURE_STENCIL_SIZE 0x88F1 #define GL_TEXTURE_SWIZZLE_A 0x8E45 @@ -890,6 +1301,12 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_TEXTURE_SWIZZLE_G 0x8E43 #define GL_TEXTURE_SWIZZLE_R 0x8E42 #define GL_TEXTURE_SWIZZLE_RGBA 0x8E46 +#define GL_TEXTURE_UPDATE_BARRIER_BIT 0x00000100 +#define GL_TEXTURE_VIEW 0x82B5 +#define GL_TEXTURE_VIEW_MIN_LAYER 0x82DD +#define GL_TEXTURE_VIEW_MIN_LEVEL 0x82DB +#define GL_TEXTURE_VIEW_NUM_LAYERS 0x82DE +#define GL_TEXTURE_VIEW_NUM_LEVELS 0x82DC #define GL_TEXTURE_WIDTH 0x1000 #define GL_TEXTURE_WRAP_R 0x8072 #define GL_TEXTURE_WRAP_S 0x2802 @@ -898,12 +1315,22 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_TIMEOUT_IGNORED 0xFFFFFFFFFFFFFFFF #define GL_TIMESTAMP 0x8E28 #define GL_TIME_ELAPSED 0x88BF +#define GL_TOP_LEVEL_ARRAY_SIZE 0x930C +#define GL_TOP_LEVEL_ARRAY_STRIDE 0x930D +#define GL_TRANSFORM_FEEDBACK 0x8E22 +#define GL_TRANSFORM_FEEDBACK_ACTIVE 0x8E24 +#define GL_TRANSFORM_FEEDBACK_BARRIER_BIT 0x00000800 +#define GL_TRANSFORM_FEEDBACK_BINDING 0x8E25 #define GL_TRANSFORM_FEEDBACK_BUFFER 0x8C8E +#define GL_TRANSFORM_FEEDBACK_BUFFER_ACTIVE 0x8E24 #define GL_TRANSFORM_FEEDBACK_BUFFER_BINDING 0x8C8F #define GL_TRANSFORM_FEEDBACK_BUFFER_MODE 0x8C7F +#define GL_TRANSFORM_FEEDBACK_BUFFER_PAUSED 0x8E23 #define GL_TRANSFORM_FEEDBACK_BUFFER_SIZE 0x8C85 #define GL_TRANSFORM_FEEDBACK_BUFFER_START 0x8C84 +#define GL_TRANSFORM_FEEDBACK_PAUSED 0x8E23 #define GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN 0x8C88 +#define GL_TRANSFORM_FEEDBACK_VARYING 0x92F4 #define GL_TRANSFORM_FEEDBACK_VARYINGS 0x8C83 #define GL_TRANSFORM_FEEDBACK_VARYING_MAX_LENGTH 0x8C76 #define GL_TRIANGLES 0x0004 @@ -912,15 +1339,24 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_TRIANGLE_STRIP 0x0005 #define GL_TRIANGLE_STRIP_ADJACENCY 0x000D #define GL_TRUE 1 +#define GL_TYPE 0x92FA +#define GL_UNDEFINED_VERTEX 0x8260 +#define GL_UNIFORM 0x92E1 #define GL_UNIFORM_ARRAY_STRIDE 0x8A3C +#define GL_UNIFORM_ATOMIC_COUNTER_BUFFER_INDEX 0x92DA +#define GL_UNIFORM_BARRIER_BIT 0x00000004 +#define GL_UNIFORM_BLOCK 0x92E2 #define GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS 0x8A42 #define GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES 0x8A43 #define GL_UNIFORM_BLOCK_BINDING 0x8A3F #define GL_UNIFORM_BLOCK_DATA_SIZE 0x8A40 #define GL_UNIFORM_BLOCK_INDEX 0x8A3A #define GL_UNIFORM_BLOCK_NAME_LENGTH 0x8A41 +#define GL_UNIFORM_BLOCK_REFERENCED_BY_COMPUTE_SHADER 0x90EC #define GL_UNIFORM_BLOCK_REFERENCED_BY_FRAGMENT_SHADER 0x8A46 #define GL_UNIFORM_BLOCK_REFERENCED_BY_GEOMETRY_SHADER 0x8A45 +#define GL_UNIFORM_BLOCK_REFERENCED_BY_TESS_CONTROL_SHADER 0x84F0 +#define GL_UNIFORM_BLOCK_REFERENCED_BY_TESS_EVALUATION_SHADER 0x84F1 #define GL_UNIFORM_BLOCK_REFERENCED_BY_VERTEX_SHADER 0x8A44 #define GL_UNIFORM_BUFFER 0x8A11 #define GL_UNIFORM_BUFFER_BINDING 0x8A28 @@ -934,6 +1370,10 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_UNIFORM_SIZE 0x8A38 #define GL_UNIFORM_TYPE 0x8A37 #define GL_UNPACK_ALIGNMENT 0x0CF5 +#define GL_UNPACK_COMPRESSED_BLOCK_DEPTH 0x9129 +#define GL_UNPACK_COMPRESSED_BLOCK_HEIGHT 0x9128 +#define GL_UNPACK_COMPRESSED_BLOCK_SIZE 0x912A +#define GL_UNPACK_COMPRESSED_BLOCK_WIDTH 0x9127 #define GL_UNPACK_IMAGE_HEIGHT 0x806E #define GL_UNPACK_LSB_FIRST 0x0CF1 #define GL_UNPACK_ROW_LENGTH 0x0CF2 @@ -953,6 +1393,18 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_UNSIGNED_INT_5_9_9_9_REV 0x8C3E #define GL_UNSIGNED_INT_8_8_8_8 0x8035 #define GL_UNSIGNED_INT_8_8_8_8_REV 0x8367 +#define GL_UNSIGNED_INT_ATOMIC_COUNTER 0x92DB +#define GL_UNSIGNED_INT_IMAGE_1D 0x9062 +#define GL_UNSIGNED_INT_IMAGE_1D_ARRAY 0x9068 +#define GL_UNSIGNED_INT_IMAGE_2D 0x9063 +#define GL_UNSIGNED_INT_IMAGE_2D_ARRAY 0x9069 +#define GL_UNSIGNED_INT_IMAGE_2D_MULTISAMPLE 0x906B +#define GL_UNSIGNED_INT_IMAGE_2D_MULTISAMPLE_ARRAY 0x906C +#define GL_UNSIGNED_INT_IMAGE_2D_RECT 0x9065 +#define GL_UNSIGNED_INT_IMAGE_3D 0x9064 +#define GL_UNSIGNED_INT_IMAGE_BUFFER 0x9067 +#define GL_UNSIGNED_INT_IMAGE_CUBE 0x9066 +#define GL_UNSIGNED_INT_IMAGE_CUBE_MAP_ARRAY 0x906A #define GL_UNSIGNED_INT_SAMPLER_1D 0x8DD1 #define GL_UNSIGNED_INT_SAMPLER_1D_ARRAY 0x8DD6 #define GL_UNSIGNED_INT_SAMPLER_2D 0x8DD2 @@ -963,6 +1415,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_UNSIGNED_INT_SAMPLER_3D 0x8DD3 #define GL_UNSIGNED_INT_SAMPLER_BUFFER 0x8DD8 #define GL_UNSIGNED_INT_SAMPLER_CUBE 0x8DD4 +#define GL_UNSIGNED_INT_SAMPLER_CUBE_MAP_ARRAY 0x900F #define GL_UNSIGNED_INT_VEC2 0x8DC6 #define GL_UNSIGNED_INT_VEC3 0x8DC7 #define GL_UNSIGNED_INT_VEC4 0x8DC8 @@ -978,19 +1431,52 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro #define GL_VALIDATE_STATUS 0x8B83 #define GL_VENDOR 0x1F00 #define GL_VERSION 0x1F02 +#define GL_VERTEX_ARRAY 0x8074 #define GL_VERTEX_ARRAY_BINDING 0x85B5 +#define GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT 0x00000001 #define GL_VERTEX_ATTRIB_ARRAY_BUFFER_BINDING 0x889F #define GL_VERTEX_ATTRIB_ARRAY_DIVISOR 0x88FE #define GL_VERTEX_ATTRIB_ARRAY_ENABLED 0x8622 #define GL_VERTEX_ATTRIB_ARRAY_INTEGER 0x88FD +#define GL_VERTEX_ATTRIB_ARRAY_LONG 0x874E #define GL_VERTEX_ATTRIB_ARRAY_NORMALIZED 0x886A #define GL_VERTEX_ATTRIB_ARRAY_POINTER 0x8645 #define GL_VERTEX_ATTRIB_ARRAY_SIZE 0x8623 #define GL_VERTEX_ATTRIB_ARRAY_STRIDE 0x8624 #define GL_VERTEX_ATTRIB_ARRAY_TYPE 0x8625 +#define GL_VERTEX_ATTRIB_BINDING 0x82D4 +#define GL_VERTEX_ATTRIB_RELATIVE_OFFSET 0x82D5 +#define GL_VERTEX_BINDING_BUFFER 0x8F4F +#define GL_VERTEX_BINDING_DIVISOR 0x82D6 +#define GL_VERTEX_BINDING_OFFSET 0x82D7 +#define GL_VERTEX_BINDING_STRIDE 0x82D8 #define GL_VERTEX_PROGRAM_POINT_SIZE 0x8642 #define GL_VERTEX_SHADER 0x8B31 +#define GL_VERTEX_SHADER_BIT 0x00000001 +#define GL_VERTEX_SUBROUTINE 0x92E8 +#define GL_VERTEX_SUBROUTINE_UNIFORM 0x92EE +#define GL_VERTEX_TEXTURE 0x829B #define GL_VIEWPORT 0x0BA2 +#define GL_VIEWPORT_BOUNDS_RANGE 0x825D +#define GL_VIEWPORT_INDEX_PROVOKING_VERTEX 0x825F +#define GL_VIEWPORT_SUBPIXEL_BITS 0x825C +#define GL_VIEW_CLASS_128_BITS 0x82C4 +#define GL_VIEW_CLASS_16_BITS 0x82CA +#define GL_VIEW_CLASS_24_BITS 0x82C9 +#define GL_VIEW_CLASS_32_BITS 0x82C8 +#define GL_VIEW_CLASS_48_BITS 0x82C7 +#define GL_VIEW_CLASS_64_BITS 0x82C6 +#define GL_VIEW_CLASS_8_BITS 0x82CB +#define GL_VIEW_CLASS_96_BITS 0x82C5 +#define GL_VIEW_CLASS_BPTC_FLOAT 0x82D3 +#define GL_VIEW_CLASS_BPTC_UNORM 0x82D2 +#define GL_VIEW_CLASS_RGTC1_RED 0x82D0 +#define GL_VIEW_CLASS_RGTC2_RG 0x82D1 +#define GL_VIEW_CLASS_S3TC_DXT1_RGB 0x82CC +#define GL_VIEW_CLASS_S3TC_DXT1_RGBA 0x82CD +#define GL_VIEW_CLASS_S3TC_DXT3_RGBA 0x82CE +#define GL_VIEW_CLASS_S3TC_DXT5_RGBA 0x82CF +#define GL_VIEW_COMPATIBILITY_CLASS 0x82B6 #define GL_WAIT_FAILED 0x911D #define GL_WRITE_ONLY 0x88B9 #define GL_XOR 0x1506 @@ -1074,12 +1560,18 @@ typedef void (GLAD_API_PTR *GLVULKANPROCNV)(void); #define GL_VERSION_3_1 1 #define GL_VERSION_3_2 1 #define GL_VERSION_3_3 1 +#define GL_VERSION_4_0 1 +#define GL_VERSION_4_1 1 +#define GL_VERSION_4_2 1 +#define GL_VERSION_4_3 1 +typedef void (GLAD_API_PTR *PFNGLACTIVESHADERPROGRAMPROC)(GLuint pipeline, GLuint program); typedef void (GLAD_API_PTR *PFNGLACTIVETEXTUREPROC)(GLenum texture); typedef void (GLAD_API_PTR *PFNGLATTACHSHADERPROC)(GLuint program, GLuint shader); typedef void (GLAD_API_PTR *PFNGLBEGINCONDITIONALRENDERPROC)(GLuint id, GLenum mode); typedef void (GLAD_API_PTR *PFNGLBEGINQUERYPROC)(GLenum target, GLuint id); +typedef void (GLAD_API_PTR *PFNGLBEGINQUERYINDEXEDPROC)(GLenum target, GLuint index, GLuint id); typedef void (GLAD_API_PTR *PFNGLBEGINTRANSFORMFEEDBACKPROC)(GLenum primitiveMode); typedef void (GLAD_API_PTR *PFNGLBINDATTRIBLOCATIONPROC)(GLuint program, GLuint index, const GLchar * name); typedef void (GLAD_API_PTR *PFNGLBINDBUFFERPROC)(GLenum target, GLuint buffer); @@ -1088,27 +1580,38 @@ typedef void (GLAD_API_PTR *PFNGLBINDBUFFERRANGEPROC)(GLenum target, GLuint inde typedef void (GLAD_API_PTR *PFNGLBINDFRAGDATALOCATIONPROC)(GLuint program, GLuint color, const GLchar * name); typedef void (GLAD_API_PTR *PFNGLBINDFRAGDATALOCATIONINDEXEDPROC)(GLuint program, GLuint colorNumber, GLuint index, const GLchar * name); typedef void (GLAD_API_PTR *PFNGLBINDFRAMEBUFFERPROC)(GLenum target, GLuint framebuffer); +typedef void (GLAD_API_PTR *PFNGLBINDIMAGETEXTUREPROC)(GLuint unit, GLuint texture, GLint level, GLboolean layered, GLint layer, GLenum access, GLenum format); +typedef void (GLAD_API_PTR *PFNGLBINDPROGRAMPIPELINEPROC)(GLuint pipeline); typedef void (GLAD_API_PTR *PFNGLBINDRENDERBUFFERPROC)(GLenum target, GLuint renderbuffer); typedef void (GLAD_API_PTR *PFNGLBINDSAMPLERPROC)(GLuint unit, GLuint sampler); typedef void (GLAD_API_PTR *PFNGLBINDTEXTUREPROC)(GLenum target, GLuint texture); +typedef void (GLAD_API_PTR *PFNGLBINDTRANSFORMFEEDBACKPROC)(GLenum target, GLuint id); typedef void (GLAD_API_PTR *PFNGLBINDVERTEXARRAYPROC)(GLuint array); +typedef void (GLAD_API_PTR *PFNGLBINDVERTEXBUFFERPROC)(GLuint bindingindex, GLuint buffer, GLintptr offset, GLsizei stride); typedef void (GLAD_API_PTR *PFNGLBLENDCOLORPROC)(GLfloat red, GLfloat green, GLfloat blue, GLfloat alpha); typedef void (GLAD_API_PTR *PFNGLBLENDEQUATIONPROC)(GLenum mode); typedef void (GLAD_API_PTR *PFNGLBLENDEQUATIONSEPARATEPROC)(GLenum modeRGB, GLenum modeAlpha); +typedef void (GLAD_API_PTR *PFNGLBLENDEQUATIONSEPARATEIPROC)(GLuint buf, GLenum modeRGB, GLenum modeAlpha); +typedef void (GLAD_API_PTR *PFNGLBLENDEQUATIONIPROC)(GLuint buf, GLenum mode); typedef void (GLAD_API_PTR *PFNGLBLENDFUNCPROC)(GLenum sfactor, GLenum dfactor); typedef void (GLAD_API_PTR *PFNGLBLENDFUNCSEPARATEPROC)(GLenum sfactorRGB, GLenum dfactorRGB, GLenum sfactorAlpha, GLenum dfactorAlpha); +typedef void (GLAD_API_PTR *PFNGLBLENDFUNCSEPARATEIPROC)(GLuint buf, GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstAlpha); +typedef void (GLAD_API_PTR *PFNGLBLENDFUNCIPROC)(GLuint buf, GLenum src, GLenum dst); typedef void (GLAD_API_PTR *PFNGLBLITFRAMEBUFFERPROC)(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1, GLint dstY1, GLbitfield mask, GLenum filter); typedef void (GLAD_API_PTR *PFNGLBUFFERDATAPROC)(GLenum target, GLsizeiptr size, const void * data, GLenum usage); typedef void (GLAD_API_PTR *PFNGLBUFFERSUBDATAPROC)(GLenum target, GLintptr offset, GLsizeiptr size, const void * data); typedef GLenum (GLAD_API_PTR *PFNGLCHECKFRAMEBUFFERSTATUSPROC)(GLenum target); typedef void (GLAD_API_PTR *PFNGLCLAMPCOLORPROC)(GLenum target, GLenum clamp); typedef void (GLAD_API_PTR *PFNGLCLEARPROC)(GLbitfield mask); +typedef void (GLAD_API_PTR *PFNGLCLEARBUFFERDATAPROC)(GLenum target, GLenum internalformat, GLenum format, GLenum type, const void * data); +typedef void (GLAD_API_PTR *PFNGLCLEARBUFFERSUBDATAPROC)(GLenum target, GLenum internalformat, GLintptr offset, GLsizeiptr size, GLenum format, GLenum type, const void * data); typedef void (GLAD_API_PTR *PFNGLCLEARBUFFERFIPROC)(GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil); typedef void (GLAD_API_PTR *PFNGLCLEARBUFFERFVPROC)(GLenum buffer, GLint drawbuffer, const GLfloat * value); typedef void (GLAD_API_PTR *PFNGLCLEARBUFFERIVPROC)(GLenum buffer, GLint drawbuffer, const GLint * value); typedef void (GLAD_API_PTR *PFNGLCLEARBUFFERUIVPROC)(GLenum buffer, GLint drawbuffer, const GLuint * value); typedef void (GLAD_API_PTR *PFNGLCLEARCOLORPROC)(GLfloat red, GLfloat green, GLfloat blue, GLfloat alpha); typedef void (GLAD_API_PTR *PFNGLCLEARDEPTHPROC)(GLdouble depth); +typedef void (GLAD_API_PTR *PFNGLCLEARDEPTHFPROC)(GLfloat d); typedef void (GLAD_API_PTR *PFNGLCLEARSTENCILPROC)(GLint s); typedef GLenum (GLAD_API_PTR *PFNGLCLIENTWAITSYNCPROC)(GLsync sync, GLbitfield flags, GLuint64 timeout); typedef void (GLAD_API_PTR *PFNGLCOLORMASKPROC)(GLboolean red, GLboolean green, GLboolean blue, GLboolean alpha); @@ -1121,6 +1624,7 @@ typedef void (GLAD_API_PTR *PFNGLCOMPRESSEDTEXSUBIMAGE1DPROC)(GLenum target, GLi typedef void (GLAD_API_PTR *PFNGLCOMPRESSEDTEXSUBIMAGE2DPROC)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLsizei width, GLsizei height, GLenum format, GLsizei imageSize, const void * data); typedef void (GLAD_API_PTR *PFNGLCOMPRESSEDTEXSUBIMAGE3DPROC)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width, GLsizei height, GLsizei depth, GLenum format, GLsizei imageSize, const void * data); typedef void (GLAD_API_PTR *PFNGLCOPYBUFFERSUBDATAPROC)(GLenum readTarget, GLenum writeTarget, GLintptr readOffset, GLintptr writeOffset, GLsizeiptr size); +typedef void (GLAD_API_PTR *PFNGLCOPYIMAGESUBDATAPROC)(GLuint srcName, GLenum srcTarget, GLint srcLevel, GLint srcX, GLint srcY, GLint srcZ, GLuint dstName, GLenum dstTarget, GLint dstLevel, GLint dstX, GLint dstY, GLint dstZ, GLsizei srcWidth, GLsizei srcHeight, GLsizei srcDepth); typedef void (GLAD_API_PTR *PFNGLCOPYTEXIMAGE1DPROC)(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y, GLsizei width, GLint border); typedef void (GLAD_API_PTR *PFNGLCOPYTEXIMAGE2DPROC)(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y, GLsizei width, GLsizei height, GLint border); typedef void (GLAD_API_PTR *PFNGLCOPYTEXSUBIMAGE1DPROC)(GLenum target, GLint level, GLint xoffset, GLint x, GLint y, GLsizei width); @@ -1128,44 +1632,66 @@ typedef void (GLAD_API_PTR *PFNGLCOPYTEXSUBIMAGE2DPROC)(GLenum target, GLint lev typedef void (GLAD_API_PTR *PFNGLCOPYTEXSUBIMAGE3DPROC)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLint x, GLint y, GLsizei width, GLsizei height); typedef GLuint (GLAD_API_PTR *PFNGLCREATEPROGRAMPROC)(void); typedef GLuint (GLAD_API_PTR *PFNGLCREATESHADERPROC)(GLenum type); +typedef GLuint (GLAD_API_PTR *PFNGLCREATESHADERPROGRAMVPROC)(GLenum type, GLsizei count, const GLchar *const* strings); typedef void (GLAD_API_PTR *PFNGLCULLFACEPROC)(GLenum mode); +typedef void (GLAD_API_PTR *PFNGLDEBUGMESSAGECALLBACKPROC)(GLDEBUGPROC callback, const void * userParam); +typedef void (GLAD_API_PTR *PFNGLDEBUGMESSAGECONTROLPROC)(GLenum source, GLenum type, GLenum severity, GLsizei count, const GLuint * ids, GLboolean enabled); +typedef void (GLAD_API_PTR *PFNGLDEBUGMESSAGEINSERTPROC)(GLenum source, GLenum type, GLuint id, GLenum severity, GLsizei length, const GLchar * buf); typedef void (GLAD_API_PTR *PFNGLDELETEBUFFERSPROC)(GLsizei n, const GLuint * buffers); typedef void (GLAD_API_PTR *PFNGLDELETEFRAMEBUFFERSPROC)(GLsizei n, const GLuint * framebuffers); typedef void (GLAD_API_PTR *PFNGLDELETEPROGRAMPROC)(GLuint program); +typedef void (GLAD_API_PTR *PFNGLDELETEPROGRAMPIPELINESPROC)(GLsizei n, const GLuint * pipelines); typedef void (GLAD_API_PTR *PFNGLDELETEQUERIESPROC)(GLsizei n, const GLuint * ids); typedef void (GLAD_API_PTR *PFNGLDELETERENDERBUFFERSPROC)(GLsizei n, const GLuint * renderbuffers); typedef void (GLAD_API_PTR *PFNGLDELETESAMPLERSPROC)(GLsizei count, const GLuint * samplers); typedef void (GLAD_API_PTR *PFNGLDELETESHADERPROC)(GLuint shader); typedef void (GLAD_API_PTR *PFNGLDELETESYNCPROC)(GLsync sync); typedef void (GLAD_API_PTR *PFNGLDELETETEXTURESPROC)(GLsizei n, const GLuint * textures); +typedef void (GLAD_API_PTR *PFNGLDELETETRANSFORMFEEDBACKSPROC)(GLsizei n, const GLuint * ids); typedef void (GLAD_API_PTR *PFNGLDELETEVERTEXARRAYSPROC)(GLsizei n, const GLuint * arrays); typedef void (GLAD_API_PTR *PFNGLDEPTHFUNCPROC)(GLenum func); typedef void (GLAD_API_PTR *PFNGLDEPTHMASKPROC)(GLboolean flag); typedef void (GLAD_API_PTR *PFNGLDEPTHRANGEPROC)(GLdouble n, GLdouble f); +typedef void (GLAD_API_PTR *PFNGLDEPTHRANGEARRAYVPROC)(GLuint first, GLsizei count, const GLdouble * v); +typedef void (GLAD_API_PTR *PFNGLDEPTHRANGEINDEXEDPROC)(GLuint index, GLdouble n, GLdouble f); +typedef void (GLAD_API_PTR *PFNGLDEPTHRANGEFPROC)(GLfloat n, GLfloat f); typedef void (GLAD_API_PTR *PFNGLDETACHSHADERPROC)(GLuint program, GLuint shader); typedef void (GLAD_API_PTR *PFNGLDISABLEPROC)(GLenum cap); typedef void (GLAD_API_PTR *PFNGLDISABLEVERTEXATTRIBARRAYPROC)(GLuint index); typedef void (GLAD_API_PTR *PFNGLDISABLEIPROC)(GLenum target, GLuint index); +typedef void (GLAD_API_PTR *PFNGLDISPATCHCOMPUTEPROC)(GLuint num_groups_x, GLuint num_groups_y, GLuint num_groups_z); +typedef void (GLAD_API_PTR *PFNGLDISPATCHCOMPUTEINDIRECTPROC)(GLintptr indirect); typedef void (GLAD_API_PTR *PFNGLDRAWARRAYSPROC)(GLenum mode, GLint first, GLsizei count); +typedef void (GLAD_API_PTR *PFNGLDRAWARRAYSINDIRECTPROC)(GLenum mode, const void * indirect); typedef void (GLAD_API_PTR *PFNGLDRAWARRAYSINSTANCEDPROC)(GLenum mode, GLint first, GLsizei count, GLsizei instancecount); +typedef void (GLAD_API_PTR *PFNGLDRAWARRAYSINSTANCEDBASEINSTANCEPROC)(GLenum mode, GLint first, GLsizei count, GLsizei instancecount, GLuint baseinstance); typedef void (GLAD_API_PTR *PFNGLDRAWBUFFERPROC)(GLenum buf); typedef void (GLAD_API_PTR *PFNGLDRAWBUFFERSPROC)(GLsizei n, const GLenum * bufs); typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSPROC)(GLenum mode, GLsizei count, GLenum type, const void * indices); typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSBASEVERTEXPROC)(GLenum mode, GLsizei count, GLenum type, const void * indices, GLint basevertex); +typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSINDIRECTPROC)(GLenum mode, GLenum type, const void * indirect); typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSINSTANCEDPROC)(GLenum mode, GLsizei count, GLenum type, const void * indices, GLsizei instancecount); +typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSINSTANCEDBASEINSTANCEPROC)(GLenum mode, GLsizei count, GLenum type, const void * indices, GLsizei instancecount, GLuint baseinstance); typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSINSTANCEDBASEVERTEXPROC)(GLenum mode, GLsizei count, GLenum type, const void * indices, GLsizei instancecount, GLint basevertex); +typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSINSTANCEDBASEVERTEXBASEINSTANCEPROC)(GLenum mode, GLsizei count, GLenum type, const void * indices, GLsizei instancecount, GLint basevertex, GLuint baseinstance); typedef void (GLAD_API_PTR *PFNGLDRAWRANGEELEMENTSPROC)(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type, const void * indices); typedef void (GLAD_API_PTR *PFNGLDRAWRANGEELEMENTSBASEVERTEXPROC)(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type, const void * indices, GLint basevertex); +typedef void (GLAD_API_PTR *PFNGLDRAWTRANSFORMFEEDBACKPROC)(GLenum mode, GLuint id); +typedef void (GLAD_API_PTR *PFNGLDRAWTRANSFORMFEEDBACKINSTANCEDPROC)(GLenum mode, GLuint id, GLsizei instancecount); +typedef void (GLAD_API_PTR *PFNGLDRAWTRANSFORMFEEDBACKSTREAMPROC)(GLenum mode, GLuint id, GLuint stream); +typedef void (GLAD_API_PTR *PFNGLDRAWTRANSFORMFEEDBACKSTREAMINSTANCEDPROC)(GLenum mode, GLuint id, GLuint stream, GLsizei instancecount); typedef void (GLAD_API_PTR *PFNGLENABLEPROC)(GLenum cap); typedef void (GLAD_API_PTR *PFNGLENABLEVERTEXATTRIBARRAYPROC)(GLuint index); typedef void (GLAD_API_PTR *PFNGLENABLEIPROC)(GLenum target, GLuint index); typedef void (GLAD_API_PTR *PFNGLENDCONDITIONALRENDERPROC)(void); typedef void (GLAD_API_PTR *PFNGLENDQUERYPROC)(GLenum target); +typedef void (GLAD_API_PTR *PFNGLENDQUERYINDEXEDPROC)(GLenum target, GLuint index); typedef void (GLAD_API_PTR *PFNGLENDTRANSFORMFEEDBACKPROC)(void); typedef GLsync (GLAD_API_PTR *PFNGLFENCESYNCPROC)(GLenum condition, GLbitfield flags); typedef void (GLAD_API_PTR *PFNGLFINISHPROC)(void); typedef void (GLAD_API_PTR *PFNGLFLUSHPROC)(void); typedef void (GLAD_API_PTR *PFNGLFLUSHMAPPEDBUFFERRANGEPROC)(GLenum target, GLintptr offset, GLsizeiptr length); +typedef void (GLAD_API_PTR *PFNGLFRAMEBUFFERPARAMETERIPROC)(GLenum target, GLenum pname, GLint param); typedef void (GLAD_API_PTR *PFNGLFRAMEBUFFERRENDERBUFFERPROC)(GLenum target, GLenum attachment, GLenum renderbuffertarget, GLuint renderbuffer); typedef void (GLAD_API_PTR *PFNGLFRAMEBUFFERTEXTUREPROC)(GLenum target, GLenum attachment, GLuint texture, GLint level); typedef void (GLAD_API_PTR *PFNGLFRAMEBUFFERTEXTURE1DPROC)(GLenum target, GLenum attachment, GLenum textarget, GLuint texture, GLint level); @@ -1175,13 +1701,19 @@ typedef void (GLAD_API_PTR *PFNGLFRAMEBUFFERTEXTURELAYERPROC)(GLenum target, GLe typedef void (GLAD_API_PTR *PFNGLFRONTFACEPROC)(GLenum mode); typedef void (GLAD_API_PTR *PFNGLGENBUFFERSPROC)(GLsizei n, GLuint * buffers); typedef void (GLAD_API_PTR *PFNGLGENFRAMEBUFFERSPROC)(GLsizei n, GLuint * framebuffers); +typedef void (GLAD_API_PTR *PFNGLGENPROGRAMPIPELINESPROC)(GLsizei n, GLuint * pipelines); typedef void (GLAD_API_PTR *PFNGLGENQUERIESPROC)(GLsizei n, GLuint * ids); typedef void (GLAD_API_PTR *PFNGLGENRENDERBUFFERSPROC)(GLsizei n, GLuint * renderbuffers); typedef void (GLAD_API_PTR *PFNGLGENSAMPLERSPROC)(GLsizei count, GLuint * samplers); typedef void (GLAD_API_PTR *PFNGLGENTEXTURESPROC)(GLsizei n, GLuint * textures); +typedef void (GLAD_API_PTR *PFNGLGENTRANSFORMFEEDBACKSPROC)(GLsizei n, GLuint * ids); typedef void (GLAD_API_PTR *PFNGLGENVERTEXARRAYSPROC)(GLsizei n, GLuint * arrays); typedef void (GLAD_API_PTR *PFNGLGENERATEMIPMAPPROC)(GLenum target); +typedef void (GLAD_API_PTR *PFNGLGETACTIVEATOMICCOUNTERBUFFERIVPROC)(GLuint program, GLuint bufferIndex, GLenum pname, GLint * params); typedef void (GLAD_API_PTR *PFNGLGETACTIVEATTRIBPROC)(GLuint program, GLuint index, GLsizei bufSize, GLsizei * length, GLint * size, GLenum * type, GLchar * name); +typedef void (GLAD_API_PTR *PFNGLGETACTIVESUBROUTINENAMEPROC)(GLuint program, GLenum shadertype, GLuint index, GLsizei bufSize, GLsizei * length, GLchar * name); +typedef void (GLAD_API_PTR *PFNGLGETACTIVESUBROUTINEUNIFORMNAMEPROC)(GLuint program, GLenum shadertype, GLuint index, GLsizei bufSize, GLsizei * length, GLchar * name); +typedef void (GLAD_API_PTR *PFNGLGETACTIVESUBROUTINEUNIFORMIVPROC)(GLuint program, GLenum shadertype, GLuint index, GLenum pname, GLint * values); typedef void (GLAD_API_PTR *PFNGLGETACTIVEUNIFORMPROC)(GLuint program, GLuint index, GLsizei bufSize, GLsizei * length, GLint * size, GLenum * type, GLchar * name); typedef void (GLAD_API_PTR *PFNGLGETACTIVEUNIFORMBLOCKNAMEPROC)(GLuint program, GLuint uniformBlockIndex, GLsizei bufSize, GLsizei * length, GLchar * uniformBlockName); typedef void (GLAD_API_PTR *PFNGLGETACTIVEUNIFORMBLOCKIVPROC)(GLuint program, GLuint uniformBlockIndex, GLenum pname, GLint * params); @@ -1196,19 +1728,39 @@ typedef void (GLAD_API_PTR *PFNGLGETBUFFERPARAMETERIVPROC)(GLenum target, GLenum typedef void (GLAD_API_PTR *PFNGLGETBUFFERPOINTERVPROC)(GLenum target, GLenum pname, void ** params); typedef void (GLAD_API_PTR *PFNGLGETBUFFERSUBDATAPROC)(GLenum target, GLintptr offset, GLsizeiptr size, void * data); typedef void (GLAD_API_PTR *PFNGLGETCOMPRESSEDTEXIMAGEPROC)(GLenum target, GLint level, void * img); +typedef GLuint (GLAD_API_PTR *PFNGLGETDEBUGMESSAGELOGPROC)(GLuint count, GLsizei bufSize, GLenum * sources, GLenum * types, GLuint * ids, GLenum * severities, GLsizei * lengths, GLchar * messageLog); +typedef void (GLAD_API_PTR *PFNGLGETDOUBLEI_VPROC)(GLenum target, GLuint index, GLdouble * data); typedef void (GLAD_API_PTR *PFNGLGETDOUBLEVPROC)(GLenum pname, GLdouble * data); typedef GLenum (GLAD_API_PTR *PFNGLGETERRORPROC)(void); +typedef void (GLAD_API_PTR *PFNGLGETFLOATI_VPROC)(GLenum target, GLuint index, GLfloat * data); typedef void (GLAD_API_PTR *PFNGLGETFLOATVPROC)(GLenum pname, GLfloat * data); typedef GLint (GLAD_API_PTR *PFNGLGETFRAGDATAINDEXPROC)(GLuint program, const GLchar * name); typedef GLint (GLAD_API_PTR *PFNGLGETFRAGDATALOCATIONPROC)(GLuint program, const GLchar * name); typedef void (GLAD_API_PTR *PFNGLGETFRAMEBUFFERATTACHMENTPARAMETERIVPROC)(GLenum target, GLenum attachment, GLenum pname, GLint * params); +typedef void (GLAD_API_PTR *PFNGLGETFRAMEBUFFERPARAMETERIVPROC)(GLenum target, GLenum pname, GLint * params); typedef void (GLAD_API_PTR *PFNGLGETINTEGER64I_VPROC)(GLenum target, GLuint index, GLint64 * data); typedef void (GLAD_API_PTR *PFNGLGETINTEGER64VPROC)(GLenum pname, GLint64 * data); typedef void (GLAD_API_PTR *PFNGLGETINTEGERI_VPROC)(GLenum target, GLuint index, GLint * data); typedef void (GLAD_API_PTR *PFNGLGETINTEGERVPROC)(GLenum pname, GLint * data); +typedef void (GLAD_API_PTR *PFNGLGETINTERNALFORMATI64VPROC)(GLenum target, GLenum internalformat, GLenum pname, GLsizei count, GLint64 * params); +typedef void (GLAD_API_PTR *PFNGLGETINTERNALFORMATIVPROC)(GLenum target, GLenum internalformat, GLenum pname, GLsizei count, GLint * params); typedef void (GLAD_API_PTR *PFNGLGETMULTISAMPLEFVPROC)(GLenum pname, GLuint index, GLfloat * val); +typedef void (GLAD_API_PTR *PFNGLGETOBJECTLABELPROC)(GLenum identifier, GLuint name, GLsizei bufSize, GLsizei * length, GLchar * label); +typedef void (GLAD_API_PTR *PFNGLGETOBJECTPTRLABELPROC)(const void * ptr, GLsizei bufSize, GLsizei * length, GLchar * label); +typedef void (GLAD_API_PTR *PFNGLGETPOINTERVPROC)(GLenum pname, void ** params); +typedef void (GLAD_API_PTR *PFNGLGETPROGRAMBINARYPROC)(GLuint program, GLsizei bufSize, GLsizei * length, GLenum * binaryFormat, void * binary); typedef void (GLAD_API_PTR *PFNGLGETPROGRAMINFOLOGPROC)(GLuint program, GLsizei bufSize, GLsizei * length, GLchar * infoLog); +typedef void (GLAD_API_PTR *PFNGLGETPROGRAMINTERFACEIVPROC)(GLuint program, GLenum programInterface, GLenum pname, GLint * params); +typedef void (GLAD_API_PTR *PFNGLGETPROGRAMPIPELINEINFOLOGPROC)(GLuint pipeline, GLsizei bufSize, GLsizei * length, GLchar * infoLog); +typedef void (GLAD_API_PTR *PFNGLGETPROGRAMPIPELINEIVPROC)(GLuint pipeline, GLenum pname, GLint * params); +typedef GLuint (GLAD_API_PTR *PFNGLGETPROGRAMRESOURCEINDEXPROC)(GLuint program, GLenum programInterface, const GLchar * name); +typedef GLint (GLAD_API_PTR *PFNGLGETPROGRAMRESOURCELOCATIONPROC)(GLuint program, GLenum programInterface, const GLchar * name); +typedef GLint (GLAD_API_PTR *PFNGLGETPROGRAMRESOURCELOCATIONINDEXPROC)(GLuint program, GLenum programInterface, const GLchar * name); +typedef void (GLAD_API_PTR *PFNGLGETPROGRAMRESOURCENAMEPROC)(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, GLsizei * length, GLchar * name); +typedef void (GLAD_API_PTR *PFNGLGETPROGRAMRESOURCEIVPROC)(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount, const GLenum * props, GLsizei count, GLsizei * length, GLint * params); +typedef void (GLAD_API_PTR *PFNGLGETPROGRAMSTAGEIVPROC)(GLuint program, GLenum shadertype, GLenum pname, GLint * values); typedef void (GLAD_API_PTR *PFNGLGETPROGRAMIVPROC)(GLuint program, GLenum pname, GLint * params); +typedef void (GLAD_API_PTR *PFNGLGETQUERYINDEXEDIVPROC)(GLenum target, GLuint index, GLenum pname, GLint * params); typedef void (GLAD_API_PTR *PFNGLGETQUERYOBJECTI64VPROC)(GLuint id, GLenum pname, GLint64 * params); typedef void (GLAD_API_PTR *PFNGLGETQUERYOBJECTIVPROC)(GLuint id, GLenum pname, GLint * params); typedef void (GLAD_API_PTR *PFNGLGETQUERYOBJECTUI64VPROC)(GLuint id, GLenum pname, GLuint64 * params); @@ -1220,10 +1772,13 @@ typedef void (GLAD_API_PTR *PFNGLGETSAMPLERPARAMETERIUIVPROC)(GLuint sampler, GL typedef void (GLAD_API_PTR *PFNGLGETSAMPLERPARAMETERFVPROC)(GLuint sampler, GLenum pname, GLfloat * params); typedef void (GLAD_API_PTR *PFNGLGETSAMPLERPARAMETERIVPROC)(GLuint sampler, GLenum pname, GLint * params); typedef void (GLAD_API_PTR *PFNGLGETSHADERINFOLOGPROC)(GLuint shader, GLsizei bufSize, GLsizei * length, GLchar * infoLog); +typedef void (GLAD_API_PTR *PFNGLGETSHADERPRECISIONFORMATPROC)(GLenum shadertype, GLenum precisiontype, GLint * range, GLint * precision); typedef void (GLAD_API_PTR *PFNGLGETSHADERSOURCEPROC)(GLuint shader, GLsizei bufSize, GLsizei * length, GLchar * source); typedef void (GLAD_API_PTR *PFNGLGETSHADERIVPROC)(GLuint shader, GLenum pname, GLint * params); typedef const GLubyte * (GLAD_API_PTR *PFNGLGETSTRINGPROC)(GLenum name); typedef const GLubyte * (GLAD_API_PTR *PFNGLGETSTRINGIPROC)(GLenum name, GLuint index); +typedef GLuint (GLAD_API_PTR *PFNGLGETSUBROUTINEINDEXPROC)(GLuint program, GLenum shadertype, const GLchar * name); +typedef GLint (GLAD_API_PTR *PFNGLGETSUBROUTINEUNIFORMLOCATIONPROC)(GLuint program, GLenum shadertype, const GLchar * name); typedef void (GLAD_API_PTR *PFNGLGETSYNCIVPROC)(GLsync sync, GLenum pname, GLsizei count, GLsizei * length, GLint * values); typedef void (GLAD_API_PTR *PFNGLGETTEXIMAGEPROC)(GLenum target, GLint level, GLenum format, GLenum type, void * pixels); typedef void (GLAD_API_PTR *PFNGLGETTEXLEVELPARAMETERFVPROC)(GLenum target, GLint level, GLenum pname, GLfloat * params); @@ -1236,36 +1791,56 @@ typedef void (GLAD_API_PTR *PFNGLGETTRANSFORMFEEDBACKVARYINGPROC)(GLuint program typedef GLuint (GLAD_API_PTR *PFNGLGETUNIFORMBLOCKINDEXPROC)(GLuint program, const GLchar * uniformBlockName); typedef void (GLAD_API_PTR *PFNGLGETUNIFORMINDICESPROC)(GLuint program, GLsizei uniformCount, const GLchar *const* uniformNames, GLuint * uniformIndices); typedef GLint (GLAD_API_PTR *PFNGLGETUNIFORMLOCATIONPROC)(GLuint program, const GLchar * name); +typedef void (GLAD_API_PTR *PFNGLGETUNIFORMSUBROUTINEUIVPROC)(GLenum shadertype, GLint location, GLuint * params); +typedef void (GLAD_API_PTR *PFNGLGETUNIFORMDVPROC)(GLuint program, GLint location, GLdouble * params); typedef void (GLAD_API_PTR *PFNGLGETUNIFORMFVPROC)(GLuint program, GLint location, GLfloat * params); typedef void (GLAD_API_PTR *PFNGLGETUNIFORMIVPROC)(GLuint program, GLint location, GLint * params); typedef void (GLAD_API_PTR *PFNGLGETUNIFORMUIVPROC)(GLuint program, GLint location, GLuint * params); typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBIIVPROC)(GLuint index, GLenum pname, GLint * params); typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBIUIVPROC)(GLuint index, GLenum pname, GLuint * params); +typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBLDVPROC)(GLuint index, GLenum pname, GLdouble * params); typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBPOINTERVPROC)(GLuint index, GLenum pname, void ** pointer); typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBDVPROC)(GLuint index, GLenum pname, GLdouble * params); typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBFVPROC)(GLuint index, GLenum pname, GLfloat * params); typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBIVPROC)(GLuint index, GLenum pname, GLint * params); typedef void (GLAD_API_PTR *PFNGLHINTPROC)(GLenum target, GLenum mode); +typedef void (GLAD_API_PTR *PFNGLINVALIDATEBUFFERDATAPROC)(GLuint buffer); +typedef void (GLAD_API_PTR *PFNGLINVALIDATEBUFFERSUBDATAPROC)(GLuint buffer, GLintptr offset, GLsizeiptr length); +typedef void (GLAD_API_PTR *PFNGLINVALIDATEFRAMEBUFFERPROC)(GLenum target, GLsizei numAttachments, const GLenum * attachments); +typedef void (GLAD_API_PTR *PFNGLINVALIDATESUBFRAMEBUFFERPROC)(GLenum target, GLsizei numAttachments, const GLenum * attachments, GLint x, GLint y, GLsizei width, GLsizei height); +typedef void (GLAD_API_PTR *PFNGLINVALIDATETEXIMAGEPROC)(GLuint texture, GLint level); +typedef void (GLAD_API_PTR *PFNGLINVALIDATETEXSUBIMAGEPROC)(GLuint texture, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width, GLsizei height, GLsizei depth); typedef GLboolean (GLAD_API_PTR *PFNGLISBUFFERPROC)(GLuint buffer); typedef GLboolean (GLAD_API_PTR *PFNGLISENABLEDPROC)(GLenum cap); typedef GLboolean (GLAD_API_PTR *PFNGLISENABLEDIPROC)(GLenum target, GLuint index); typedef GLboolean (GLAD_API_PTR *PFNGLISFRAMEBUFFERPROC)(GLuint framebuffer); typedef GLboolean (GLAD_API_PTR *PFNGLISPROGRAMPROC)(GLuint program); +typedef GLboolean (GLAD_API_PTR *PFNGLISPROGRAMPIPELINEPROC)(GLuint pipeline); typedef GLboolean (GLAD_API_PTR *PFNGLISQUERYPROC)(GLuint id); typedef GLboolean (GLAD_API_PTR *PFNGLISRENDERBUFFERPROC)(GLuint renderbuffer); typedef GLboolean (GLAD_API_PTR *PFNGLISSAMPLERPROC)(GLuint sampler); typedef GLboolean (GLAD_API_PTR *PFNGLISSHADERPROC)(GLuint shader); typedef GLboolean (GLAD_API_PTR *PFNGLISSYNCPROC)(GLsync sync); typedef GLboolean (GLAD_API_PTR *PFNGLISTEXTUREPROC)(GLuint texture); +typedef GLboolean (GLAD_API_PTR *PFNGLISTRANSFORMFEEDBACKPROC)(GLuint id); typedef GLboolean (GLAD_API_PTR *PFNGLISVERTEXARRAYPROC)(GLuint array); typedef void (GLAD_API_PTR *PFNGLLINEWIDTHPROC)(GLfloat width); typedef void (GLAD_API_PTR *PFNGLLINKPROGRAMPROC)(GLuint program); typedef void (GLAD_API_PTR *PFNGLLOGICOPPROC)(GLenum opcode); typedef void * (GLAD_API_PTR *PFNGLMAPBUFFERPROC)(GLenum target, GLenum access); typedef void * (GLAD_API_PTR *PFNGLMAPBUFFERRANGEPROC)(GLenum target, GLintptr offset, GLsizeiptr length, GLbitfield access); +typedef void (GLAD_API_PTR *PFNGLMEMORYBARRIERPROC)(GLbitfield barriers); +typedef void (GLAD_API_PTR *PFNGLMINSAMPLESHADINGPROC)(GLfloat value); typedef void (GLAD_API_PTR *PFNGLMULTIDRAWARRAYSPROC)(GLenum mode, const GLint * first, const GLsizei * count, GLsizei drawcount); +typedef void (GLAD_API_PTR *PFNGLMULTIDRAWARRAYSINDIRECTPROC)(GLenum mode, const void * indirect, GLsizei drawcount, GLsizei stride); typedef void (GLAD_API_PTR *PFNGLMULTIDRAWELEMENTSPROC)(GLenum mode, const GLsizei * count, GLenum type, const void *const* indices, GLsizei drawcount); typedef void (GLAD_API_PTR *PFNGLMULTIDRAWELEMENTSBASEVERTEXPROC)(GLenum mode, const GLsizei * count, GLenum type, const void *const* indices, GLsizei drawcount, const GLint * basevertex); +typedef void (GLAD_API_PTR *PFNGLMULTIDRAWELEMENTSINDIRECTPROC)(GLenum mode, GLenum type, const void * indirect, GLsizei drawcount, GLsizei stride); +typedef void (GLAD_API_PTR *PFNGLOBJECTLABELPROC)(GLenum identifier, GLuint name, GLsizei length, const GLchar * label); +typedef void (GLAD_API_PTR *PFNGLOBJECTPTRLABELPROC)(const void * ptr, GLsizei length, const GLchar * label); +typedef void (GLAD_API_PTR *PFNGLPATCHPARAMETERFVPROC)(GLenum pname, const GLfloat * values); +typedef void (GLAD_API_PTR *PFNGLPATCHPARAMETERIPROC)(GLenum pname, GLint value); +typedef void (GLAD_API_PTR *PFNGLPAUSETRANSFORMFEEDBACKPROC)(void); typedef void (GLAD_API_PTR *PFNGLPIXELSTOREFPROC)(GLenum pname, GLfloat param); typedef void (GLAD_API_PTR *PFNGLPIXELSTOREIPROC)(GLenum pname, GLint param); typedef void (GLAD_API_PTR *PFNGLPOINTPARAMETERFPROC)(GLenum pname, GLfloat param); @@ -1275,13 +1850,69 @@ typedef void (GLAD_API_PTR *PFNGLPOINTPARAMETERIVPROC)(GLenum pname, const GLint typedef void (GLAD_API_PTR *PFNGLPOINTSIZEPROC)(GLfloat size); typedef void (GLAD_API_PTR *PFNGLPOLYGONMODEPROC)(GLenum face, GLenum mode); typedef void (GLAD_API_PTR *PFNGLPOLYGONOFFSETPROC)(GLfloat factor, GLfloat units); +typedef void (GLAD_API_PTR *PFNGLPOPDEBUGGROUPPROC)(void); typedef void (GLAD_API_PTR *PFNGLPRIMITIVERESTARTINDEXPROC)(GLuint index); +typedef void (GLAD_API_PTR *PFNGLPROGRAMBINARYPROC)(GLuint program, GLenum binaryFormat, const void * binary, GLsizei length); +typedef void (GLAD_API_PTR *PFNGLPROGRAMPARAMETERIPROC)(GLuint program, GLenum pname, GLint value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1DPROC)(GLuint program, GLint location, GLdouble v0); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1DVPROC)(GLuint program, GLint location, GLsizei count, const GLdouble * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1FPROC)(GLuint program, GLint location, GLfloat v0); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1FVPROC)(GLuint program, GLint location, GLsizei count, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1IPROC)(GLuint program, GLint location, GLint v0); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1IVPROC)(GLuint program, GLint location, GLsizei count, const GLint * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1UIPROC)(GLuint program, GLint location, GLuint v0); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1UIVPROC)(GLuint program, GLint location, GLsizei count, const GLuint * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2DPROC)(GLuint program, GLint location, GLdouble v0, GLdouble v1); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2DVPROC)(GLuint program, GLint location, GLsizei count, const GLdouble * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2FPROC)(GLuint program, GLint location, GLfloat v0, GLfloat v1); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2FVPROC)(GLuint program, GLint location, GLsizei count, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2IPROC)(GLuint program, GLint location, GLint v0, GLint v1); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2IVPROC)(GLuint program, GLint location, GLsizei count, const GLint * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2UIPROC)(GLuint program, GLint location, GLuint v0, GLuint v1); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2UIVPROC)(GLuint program, GLint location, GLsizei count, const GLuint * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3DPROC)(GLuint program, GLint location, GLdouble v0, GLdouble v1, GLdouble v2); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3DVPROC)(GLuint program, GLint location, GLsizei count, const GLdouble * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3FPROC)(GLuint program, GLint location, GLfloat v0, GLfloat v1, GLfloat v2); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3FVPROC)(GLuint program, GLint location, GLsizei count, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3IPROC)(GLuint program, GLint location, GLint v0, GLint v1, GLint v2); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3IVPROC)(GLuint program, GLint location, GLsizei count, const GLint * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3UIPROC)(GLuint program, GLint location, GLuint v0, GLuint v1, GLuint v2); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3UIVPROC)(GLuint program, GLint location, GLsizei count, const GLuint * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4DPROC)(GLuint program, GLint location, GLdouble v0, GLdouble v1, GLdouble v2, GLdouble v3); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4DVPROC)(GLuint program, GLint location, GLsizei count, const GLdouble * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4FPROC)(GLuint program, GLint location, GLfloat v0, GLfloat v1, GLfloat v2, GLfloat v3); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4FVPROC)(GLuint program, GLint location, GLsizei count, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4IPROC)(GLuint program, GLint location, GLint v0, GLint v1, GLint v2, GLint v3); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4IVPROC)(GLuint program, GLint location, GLsizei count, const GLint * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4UIPROC)(GLuint program, GLint location, GLuint v0, GLuint v1, GLuint v2, GLuint v3); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4UIVPROC)(GLuint program, GLint location, GLsizei count, const GLuint * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX2DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX2FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX2X3DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX2X3FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX2X4DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX2X4FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX3DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX3FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX3X2DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX3X2FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX3X4DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX3X4FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX4DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX4FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX4X2DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX4X2FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX4X3DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); +typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX4X3FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); typedef void (GLAD_API_PTR *PFNGLPROVOKINGVERTEXPROC)(GLenum mode); +typedef void (GLAD_API_PTR *PFNGLPUSHDEBUGGROUPPROC)(GLenum source, GLuint id, GLsizei length, const GLchar * message); typedef void (GLAD_API_PTR *PFNGLQUERYCOUNTERPROC)(GLuint id, GLenum target); typedef void (GLAD_API_PTR *PFNGLREADBUFFERPROC)(GLenum src); typedef void (GLAD_API_PTR *PFNGLREADPIXELSPROC)(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void * pixels); +typedef void (GLAD_API_PTR *PFNGLRELEASESHADERCOMPILERPROC)(void); typedef void (GLAD_API_PTR *PFNGLRENDERBUFFERSTORAGEPROC)(GLenum target, GLenum internalformat, GLsizei width, GLsizei height); typedef void (GLAD_API_PTR *PFNGLRENDERBUFFERSTORAGEMULTISAMPLEPROC)(GLenum target, GLsizei samples, GLenum internalformat, GLsizei width, GLsizei height); +typedef void (GLAD_API_PTR *PFNGLRESUMETRANSFORMFEEDBACKPROC)(void); typedef void (GLAD_API_PTR *PFNGLSAMPLECOVERAGEPROC)(GLfloat value, GLboolean invert); typedef void (GLAD_API_PTR *PFNGLSAMPLEMASKIPROC)(GLuint maskNumber, GLbitfield mask); typedef void (GLAD_API_PTR *PFNGLSAMPLERPARAMETERIIVPROC)(GLuint sampler, GLenum pname, const GLint * param); @@ -1291,7 +1922,12 @@ typedef void (GLAD_API_PTR *PFNGLSAMPLERPARAMETERFVPROC)(GLuint sampler, GLenum typedef void (GLAD_API_PTR *PFNGLSAMPLERPARAMETERIPROC)(GLuint sampler, GLenum pname, GLint param); typedef void (GLAD_API_PTR *PFNGLSAMPLERPARAMETERIVPROC)(GLuint sampler, GLenum pname, const GLint * param); typedef void (GLAD_API_PTR *PFNGLSCISSORPROC)(GLint x, GLint y, GLsizei width, GLsizei height); +typedef void (GLAD_API_PTR *PFNGLSCISSORARRAYVPROC)(GLuint first, GLsizei count, const GLint * v); +typedef void (GLAD_API_PTR *PFNGLSCISSORINDEXEDPROC)(GLuint index, GLint left, GLint bottom, GLsizei width, GLsizei height); +typedef void (GLAD_API_PTR *PFNGLSCISSORINDEXEDVPROC)(GLuint index, const GLint * v); +typedef void (GLAD_API_PTR *PFNGLSHADERBINARYPROC)(GLsizei count, const GLuint * shaders, GLenum binaryFormat, const void * binary, GLsizei length); typedef void (GLAD_API_PTR *PFNGLSHADERSOURCEPROC)(GLuint shader, GLsizei count, const GLchar *const* string, const GLint * length); +typedef void (GLAD_API_PTR *PFNGLSHADERSTORAGEBLOCKBINDINGPROC)(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding); typedef void (GLAD_API_PTR *PFNGLSTENCILFUNCPROC)(GLenum func, GLint ref, GLuint mask); typedef void (GLAD_API_PTR *PFNGLSTENCILFUNCSEPARATEPROC)(GLenum face, GLenum func, GLint ref, GLuint mask); typedef void (GLAD_API_PTR *PFNGLSTENCILMASKPROC)(GLuint mask); @@ -1299,6 +1935,7 @@ typedef void (GLAD_API_PTR *PFNGLSTENCILMASKSEPARATEPROC)(GLenum face, GLuint ma typedef void (GLAD_API_PTR *PFNGLSTENCILOPPROC)(GLenum fail, GLenum zfail, GLenum zpass); typedef void (GLAD_API_PTR *PFNGLSTENCILOPSEPARATEPROC)(GLenum face, GLenum sfail, GLenum dpfail, GLenum dppass); typedef void (GLAD_API_PTR *PFNGLTEXBUFFERPROC)(GLenum target, GLenum internalformat, GLuint buffer); +typedef void (GLAD_API_PTR *PFNGLTEXBUFFERRANGEPROC)(GLenum target, GLenum internalformat, GLuint buffer, GLintptr offset, GLsizeiptr size); typedef void (GLAD_API_PTR *PFNGLTEXIMAGE1DPROC)(GLenum target, GLint level, GLint internalformat, GLsizei width, GLint border, GLenum format, GLenum type, const void * pixels); typedef void (GLAD_API_PTR *PFNGLTEXIMAGE2DPROC)(GLenum target, GLint level, GLint internalformat, GLsizei width, GLsizei height, GLint border, GLenum format, GLenum type, const void * pixels); typedef void (GLAD_API_PTR *PFNGLTEXIMAGE2DMULTISAMPLEPROC)(GLenum target, GLsizei samples, GLenum internalformat, GLsizei width, GLsizei height, GLboolean fixedsamplelocations); @@ -1310,28 +1947,42 @@ typedef void (GLAD_API_PTR *PFNGLTEXPARAMETERFPROC)(GLenum target, GLenum pname, typedef void (GLAD_API_PTR *PFNGLTEXPARAMETERFVPROC)(GLenum target, GLenum pname, const GLfloat * params); typedef void (GLAD_API_PTR *PFNGLTEXPARAMETERIPROC)(GLenum target, GLenum pname, GLint param); typedef void (GLAD_API_PTR *PFNGLTEXPARAMETERIVPROC)(GLenum target, GLenum pname, const GLint * params); +typedef void (GLAD_API_PTR *PFNGLTEXSTORAGE1DPROC)(GLenum target, GLsizei levels, GLenum internalformat, GLsizei width); +typedef void (GLAD_API_PTR *PFNGLTEXSTORAGE2DPROC)(GLenum target, GLsizei levels, GLenum internalformat, GLsizei width, GLsizei height); +typedef void (GLAD_API_PTR *PFNGLTEXSTORAGE2DMULTISAMPLEPROC)(GLenum target, GLsizei samples, GLenum internalformat, GLsizei width, GLsizei height, GLboolean fixedsamplelocations); +typedef void (GLAD_API_PTR *PFNGLTEXSTORAGE3DPROC)(GLenum target, GLsizei levels, GLenum internalformat, GLsizei width, GLsizei height, GLsizei depth); +typedef void (GLAD_API_PTR *PFNGLTEXSTORAGE3DMULTISAMPLEPROC)(GLenum target, GLsizei samples, GLenum internalformat, GLsizei width, GLsizei height, GLsizei depth, GLboolean fixedsamplelocations); typedef void (GLAD_API_PTR *PFNGLTEXSUBIMAGE1DPROC)(GLenum target, GLint level, GLint xoffset, GLsizei width, GLenum format, GLenum type, const void * pixels); typedef void (GLAD_API_PTR *PFNGLTEXSUBIMAGE2DPROC)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLsizei width, GLsizei height, GLenum format, GLenum type, const void * pixels); typedef void (GLAD_API_PTR *PFNGLTEXSUBIMAGE3DPROC)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width, GLsizei height, GLsizei depth, GLenum format, GLenum type, const void * pixels); +typedef void (GLAD_API_PTR *PFNGLTEXTUREVIEWPROC)(GLuint texture, GLenum target, GLuint origtexture, GLenum internalformat, GLuint minlevel, GLuint numlevels, GLuint minlayer, GLuint numlayers); typedef void (GLAD_API_PTR *PFNGLTRANSFORMFEEDBACKVARYINGSPROC)(GLuint program, GLsizei count, const GLchar *const* varyings, GLenum bufferMode); +typedef void (GLAD_API_PTR *PFNGLUNIFORM1DPROC)(GLint location, GLdouble x); +typedef void (GLAD_API_PTR *PFNGLUNIFORM1DVPROC)(GLint location, GLsizei count, const GLdouble * value); typedef void (GLAD_API_PTR *PFNGLUNIFORM1FPROC)(GLint location, GLfloat v0); typedef void (GLAD_API_PTR *PFNGLUNIFORM1FVPROC)(GLint location, GLsizei count, const GLfloat * value); typedef void (GLAD_API_PTR *PFNGLUNIFORM1IPROC)(GLint location, GLint v0); typedef void (GLAD_API_PTR *PFNGLUNIFORM1IVPROC)(GLint location, GLsizei count, const GLint * value); typedef void (GLAD_API_PTR *PFNGLUNIFORM1UIPROC)(GLint location, GLuint v0); typedef void (GLAD_API_PTR *PFNGLUNIFORM1UIVPROC)(GLint location, GLsizei count, const GLuint * value); +typedef void (GLAD_API_PTR *PFNGLUNIFORM2DPROC)(GLint location, GLdouble x, GLdouble y); +typedef void (GLAD_API_PTR *PFNGLUNIFORM2DVPROC)(GLint location, GLsizei count, const GLdouble * value); typedef void (GLAD_API_PTR *PFNGLUNIFORM2FPROC)(GLint location, GLfloat v0, GLfloat v1); typedef void (GLAD_API_PTR *PFNGLUNIFORM2FVPROC)(GLint location, GLsizei count, const GLfloat * value); typedef void (GLAD_API_PTR *PFNGLUNIFORM2IPROC)(GLint location, GLint v0, GLint v1); typedef void (GLAD_API_PTR *PFNGLUNIFORM2IVPROC)(GLint location, GLsizei count, const GLint * value); typedef void (GLAD_API_PTR *PFNGLUNIFORM2UIPROC)(GLint location, GLuint v0, GLuint v1); typedef void (GLAD_API_PTR *PFNGLUNIFORM2UIVPROC)(GLint location, GLsizei count, const GLuint * value); +typedef void (GLAD_API_PTR *PFNGLUNIFORM3DPROC)(GLint location, GLdouble x, GLdouble y, GLdouble z); +typedef void (GLAD_API_PTR *PFNGLUNIFORM3DVPROC)(GLint location, GLsizei count, const GLdouble * value); typedef void (GLAD_API_PTR *PFNGLUNIFORM3FPROC)(GLint location, GLfloat v0, GLfloat v1, GLfloat v2); typedef void (GLAD_API_PTR *PFNGLUNIFORM3FVPROC)(GLint location, GLsizei count, const GLfloat * value); typedef void (GLAD_API_PTR *PFNGLUNIFORM3IPROC)(GLint location, GLint v0, GLint v1, GLint v2); typedef void (GLAD_API_PTR *PFNGLUNIFORM3IVPROC)(GLint location, GLsizei count, const GLint * value); typedef void (GLAD_API_PTR *PFNGLUNIFORM3UIPROC)(GLint location, GLuint v0, GLuint v1, GLuint v2); typedef void (GLAD_API_PTR *PFNGLUNIFORM3UIVPROC)(GLint location, GLsizei count, const GLuint * value); +typedef void (GLAD_API_PTR *PFNGLUNIFORM4DPROC)(GLint location, GLdouble x, GLdouble y, GLdouble z, GLdouble w); +typedef void (GLAD_API_PTR *PFNGLUNIFORM4DVPROC)(GLint location, GLsizei count, const GLdouble * value); typedef void (GLAD_API_PTR *PFNGLUNIFORM4FPROC)(GLint location, GLfloat v0, GLfloat v1, GLfloat v2, GLfloat v3); typedef void (GLAD_API_PTR *PFNGLUNIFORM4FVPROC)(GLint location, GLsizei count, const GLfloat * value); typedef void (GLAD_API_PTR *PFNGLUNIFORM4IPROC)(GLint location, GLint v0, GLint v1, GLint v2, GLint v3); @@ -1339,18 +1990,30 @@ typedef void (GLAD_API_PTR *PFNGLUNIFORM4IVPROC)(GLint location, GLsizei count, typedef void (GLAD_API_PTR *PFNGLUNIFORM4UIPROC)(GLint location, GLuint v0, GLuint v1, GLuint v2, GLuint v3); typedef void (GLAD_API_PTR *PFNGLUNIFORM4UIVPROC)(GLint location, GLsizei count, const GLuint * value); typedef void (GLAD_API_PTR *PFNGLUNIFORMBLOCKBINDINGPROC)(GLuint program, GLuint uniformBlockIndex, GLuint uniformBlockBinding); +typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX2DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX2FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX2X3DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX2X3FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX2X4DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX2X4FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX3DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX3FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX3X2DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX3X2FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX3X4DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX3X4FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX4DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX4FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX4X2DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX4X2FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX4X3DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value); typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX4X3FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value); +typedef void (GLAD_API_PTR *PFNGLUNIFORMSUBROUTINESUIVPROC)(GLenum shadertype, GLsizei count, const GLuint * indices); typedef GLboolean (GLAD_API_PTR *PFNGLUNMAPBUFFERPROC)(GLenum target); typedef void (GLAD_API_PTR *PFNGLUSEPROGRAMPROC)(GLuint program); +typedef void (GLAD_API_PTR *PFNGLUSEPROGRAMSTAGESPROC)(GLuint pipeline, GLbitfield stages, GLuint program); typedef void (GLAD_API_PTR *PFNGLVALIDATEPROGRAMPROC)(GLuint program); +typedef void (GLAD_API_PTR *PFNGLVALIDATEPROGRAMPIPELINEPROC)(GLuint pipeline); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB1DPROC)(GLuint index, GLdouble x); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB1DVPROC)(GLuint index, const GLdouble * v); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB1FPROC)(GLuint index, GLfloat x); @@ -1387,7 +2050,9 @@ typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB4SVPROC)(GLuint index, const GLshor typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB4UBVPROC)(GLuint index, const GLubyte * v); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB4UIVPROC)(GLuint index, const GLuint * v); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB4USVPROC)(GLuint index, const GLushort * v); +typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBBINDINGPROC)(GLuint attribindex, GLuint bindingindex); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBDIVISORPROC)(GLuint index, GLuint divisor); +typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBFORMATPROC)(GLuint attribindex, GLint size, GLenum type, GLboolean normalized, GLuint relativeoffset); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI1IPROC)(GLuint index, GLint x); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI1IVPROC)(GLuint index, const GLint * v); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI1UIPROC)(GLuint index, GLuint x); @@ -1408,7 +2073,18 @@ typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI4UBVPROC)(GLuint index, const GLub typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI4UIPROC)(GLuint index, GLuint x, GLuint y, GLuint z, GLuint w); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI4UIVPROC)(GLuint index, const GLuint * v); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI4USVPROC)(GLuint index, const GLushort * v); +typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBIFORMATPROC)(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBIPOINTERPROC)(GLuint index, GLint size, GLenum type, GLsizei stride, const void * pointer); +typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL1DPROC)(GLuint index, GLdouble x); +typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL1DVPROC)(GLuint index, const GLdouble * v); +typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL2DPROC)(GLuint index, GLdouble x, GLdouble y); +typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL2DVPROC)(GLuint index, const GLdouble * v); +typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL3DPROC)(GLuint index, GLdouble x, GLdouble y, GLdouble z); +typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL3DVPROC)(GLuint index, const GLdouble * v); +typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL4DPROC)(GLuint index, GLdouble x, GLdouble y, GLdouble z, GLdouble w); +typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL4DVPROC)(GLuint index, const GLdouble * v); +typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBLFORMATPROC)(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset); +typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBLPOINTERPROC)(GLuint index, GLint size, GLenum type, GLsizei stride, const void * pointer); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBP1UIPROC)(GLuint index, GLenum type, GLboolean normalized, GLuint value); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBP1UIVPROC)(GLuint index, GLenum type, GLboolean normalized, const GLuint * value); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBP2UIPROC)(GLuint index, GLenum type, GLboolean normalized, GLuint value); @@ -1418,7 +2094,11 @@ typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBP3UIVPROC)(GLuint index, GLenum typ typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBP4UIPROC)(GLuint index, GLenum type, GLboolean normalized, GLuint value); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBP4UIVPROC)(GLuint index, GLenum type, GLboolean normalized, const GLuint * value); typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBPOINTERPROC)(GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride, const void * pointer); +typedef void (GLAD_API_PTR *PFNGLVERTEXBINDINGDIVISORPROC)(GLuint bindingindex, GLuint divisor); typedef void (GLAD_API_PTR *PFNGLVIEWPORTPROC)(GLint x, GLint y, GLsizei width, GLsizei height); +typedef void (GLAD_API_PTR *PFNGLVIEWPORTARRAYVPROC)(GLuint first, GLsizei count, const GLfloat * v); +typedef void (GLAD_API_PTR *PFNGLVIEWPORTINDEXEDFPROC)(GLuint index, GLfloat x, GLfloat y, GLfloat w, GLfloat h); +typedef void (GLAD_API_PTR *PFNGLVIEWPORTINDEXEDFVPROC)(GLuint index, const GLfloat * v); typedef void (GLAD_API_PTR *PFNGLWAITSYNCPROC)(GLsync sync, GLbitfield flags, GLuint64 timeout); typedef struct GladGLContext { @@ -1436,11 +2116,17 @@ typedef struct GladGLContext { int VERSION_3_1; int VERSION_3_2; int VERSION_3_3; + int VERSION_4_0; + int VERSION_4_1; + int VERSION_4_2; + int VERSION_4_3; + PFNGLACTIVESHADERPROGRAMPROC ActiveShaderProgram; PFNGLACTIVETEXTUREPROC ActiveTexture; PFNGLATTACHSHADERPROC AttachShader; PFNGLBEGINCONDITIONALRENDERPROC BeginConditionalRender; PFNGLBEGINQUERYPROC BeginQuery; + PFNGLBEGINQUERYINDEXEDPROC BeginQueryIndexed; PFNGLBEGINTRANSFORMFEEDBACKPROC BeginTransformFeedback; PFNGLBINDATTRIBLOCATIONPROC BindAttribLocation; PFNGLBINDBUFFERPROC BindBuffer; @@ -1449,27 +2135,38 @@ typedef struct GladGLContext { PFNGLBINDFRAGDATALOCATIONPROC BindFragDataLocation; PFNGLBINDFRAGDATALOCATIONINDEXEDPROC BindFragDataLocationIndexed; PFNGLBINDFRAMEBUFFERPROC BindFramebuffer; + PFNGLBINDIMAGETEXTUREPROC BindImageTexture; + PFNGLBINDPROGRAMPIPELINEPROC BindProgramPipeline; PFNGLBINDRENDERBUFFERPROC BindRenderbuffer; PFNGLBINDSAMPLERPROC BindSampler; PFNGLBINDTEXTUREPROC BindTexture; + PFNGLBINDTRANSFORMFEEDBACKPROC BindTransformFeedback; PFNGLBINDVERTEXARRAYPROC BindVertexArray; + PFNGLBINDVERTEXBUFFERPROC BindVertexBuffer; PFNGLBLENDCOLORPROC BlendColor; PFNGLBLENDEQUATIONPROC BlendEquation; PFNGLBLENDEQUATIONSEPARATEPROC BlendEquationSeparate; + PFNGLBLENDEQUATIONSEPARATEIPROC BlendEquationSeparatei; + PFNGLBLENDEQUATIONIPROC BlendEquationi; PFNGLBLENDFUNCPROC BlendFunc; PFNGLBLENDFUNCSEPARATEPROC BlendFuncSeparate; + PFNGLBLENDFUNCSEPARATEIPROC BlendFuncSeparatei; + PFNGLBLENDFUNCIPROC BlendFunci; PFNGLBLITFRAMEBUFFERPROC BlitFramebuffer; PFNGLBUFFERDATAPROC BufferData; PFNGLBUFFERSUBDATAPROC BufferSubData; PFNGLCHECKFRAMEBUFFERSTATUSPROC CheckFramebufferStatus; PFNGLCLAMPCOLORPROC ClampColor; PFNGLCLEARPROC Clear; + PFNGLCLEARBUFFERDATAPROC ClearBufferData; + PFNGLCLEARBUFFERSUBDATAPROC ClearBufferSubData; PFNGLCLEARBUFFERFIPROC ClearBufferfi; PFNGLCLEARBUFFERFVPROC ClearBufferfv; PFNGLCLEARBUFFERIVPROC ClearBufferiv; PFNGLCLEARBUFFERUIVPROC ClearBufferuiv; PFNGLCLEARCOLORPROC ClearColor; PFNGLCLEARDEPTHPROC ClearDepth; + PFNGLCLEARDEPTHFPROC ClearDepthf; PFNGLCLEARSTENCILPROC ClearStencil; PFNGLCLIENTWAITSYNCPROC ClientWaitSync; PFNGLCOLORMASKPROC ColorMask; @@ -1482,6 +2179,7 @@ typedef struct GladGLContext { PFNGLCOMPRESSEDTEXSUBIMAGE2DPROC CompressedTexSubImage2D; PFNGLCOMPRESSEDTEXSUBIMAGE3DPROC CompressedTexSubImage3D; PFNGLCOPYBUFFERSUBDATAPROC CopyBufferSubData; + PFNGLCOPYIMAGESUBDATAPROC CopyImageSubData; PFNGLCOPYTEXIMAGE1DPROC CopyTexImage1D; PFNGLCOPYTEXIMAGE2DPROC CopyTexImage2D; PFNGLCOPYTEXSUBIMAGE1DPROC CopyTexSubImage1D; @@ -1489,44 +2187,66 @@ typedef struct GladGLContext { PFNGLCOPYTEXSUBIMAGE3DPROC CopyTexSubImage3D; PFNGLCREATEPROGRAMPROC CreateProgram; PFNGLCREATESHADERPROC CreateShader; + PFNGLCREATESHADERPROGRAMVPROC CreateShaderProgramv; PFNGLCULLFACEPROC CullFace; + PFNGLDEBUGMESSAGECALLBACKPROC DebugMessageCallback; + PFNGLDEBUGMESSAGECONTROLPROC DebugMessageControl; + PFNGLDEBUGMESSAGEINSERTPROC DebugMessageInsert; PFNGLDELETEBUFFERSPROC DeleteBuffers; PFNGLDELETEFRAMEBUFFERSPROC DeleteFramebuffers; PFNGLDELETEPROGRAMPROC DeleteProgram; + PFNGLDELETEPROGRAMPIPELINESPROC DeleteProgramPipelines; PFNGLDELETEQUERIESPROC DeleteQueries; PFNGLDELETERENDERBUFFERSPROC DeleteRenderbuffers; PFNGLDELETESAMPLERSPROC DeleteSamplers; PFNGLDELETESHADERPROC DeleteShader; PFNGLDELETESYNCPROC DeleteSync; PFNGLDELETETEXTURESPROC DeleteTextures; + PFNGLDELETETRANSFORMFEEDBACKSPROC DeleteTransformFeedbacks; PFNGLDELETEVERTEXARRAYSPROC DeleteVertexArrays; PFNGLDEPTHFUNCPROC DepthFunc; PFNGLDEPTHMASKPROC DepthMask; PFNGLDEPTHRANGEPROC DepthRange; + PFNGLDEPTHRANGEARRAYVPROC DepthRangeArrayv; + PFNGLDEPTHRANGEINDEXEDPROC DepthRangeIndexed; + PFNGLDEPTHRANGEFPROC DepthRangef; PFNGLDETACHSHADERPROC DetachShader; PFNGLDISABLEPROC Disable; PFNGLDISABLEVERTEXATTRIBARRAYPROC DisableVertexAttribArray; PFNGLDISABLEIPROC Disablei; + PFNGLDISPATCHCOMPUTEPROC DispatchCompute; + PFNGLDISPATCHCOMPUTEINDIRECTPROC DispatchComputeIndirect; PFNGLDRAWARRAYSPROC DrawArrays; + PFNGLDRAWARRAYSINDIRECTPROC DrawArraysIndirect; PFNGLDRAWARRAYSINSTANCEDPROC DrawArraysInstanced; + PFNGLDRAWARRAYSINSTANCEDBASEINSTANCEPROC DrawArraysInstancedBaseInstance; PFNGLDRAWBUFFERPROC DrawBuffer; PFNGLDRAWBUFFERSPROC DrawBuffers; PFNGLDRAWELEMENTSPROC DrawElements; PFNGLDRAWELEMENTSBASEVERTEXPROC DrawElementsBaseVertex; + PFNGLDRAWELEMENTSINDIRECTPROC DrawElementsIndirect; PFNGLDRAWELEMENTSINSTANCEDPROC DrawElementsInstanced; + PFNGLDRAWELEMENTSINSTANCEDBASEINSTANCEPROC DrawElementsInstancedBaseInstance; PFNGLDRAWELEMENTSINSTANCEDBASEVERTEXPROC DrawElementsInstancedBaseVertex; + PFNGLDRAWELEMENTSINSTANCEDBASEVERTEXBASEINSTANCEPROC DrawElementsInstancedBaseVertexBaseInstance; PFNGLDRAWRANGEELEMENTSPROC DrawRangeElements; PFNGLDRAWRANGEELEMENTSBASEVERTEXPROC DrawRangeElementsBaseVertex; + PFNGLDRAWTRANSFORMFEEDBACKPROC DrawTransformFeedback; + PFNGLDRAWTRANSFORMFEEDBACKINSTANCEDPROC DrawTransformFeedbackInstanced; + PFNGLDRAWTRANSFORMFEEDBACKSTREAMPROC DrawTransformFeedbackStream; + PFNGLDRAWTRANSFORMFEEDBACKSTREAMINSTANCEDPROC DrawTransformFeedbackStreamInstanced; PFNGLENABLEPROC Enable; PFNGLENABLEVERTEXATTRIBARRAYPROC EnableVertexAttribArray; PFNGLENABLEIPROC Enablei; PFNGLENDCONDITIONALRENDERPROC EndConditionalRender; PFNGLENDQUERYPROC EndQuery; + PFNGLENDQUERYINDEXEDPROC EndQueryIndexed; PFNGLENDTRANSFORMFEEDBACKPROC EndTransformFeedback; PFNGLFENCESYNCPROC FenceSync; PFNGLFINISHPROC Finish; PFNGLFLUSHPROC Flush; PFNGLFLUSHMAPPEDBUFFERRANGEPROC FlushMappedBufferRange; + PFNGLFRAMEBUFFERPARAMETERIPROC FramebufferParameteri; PFNGLFRAMEBUFFERRENDERBUFFERPROC FramebufferRenderbuffer; PFNGLFRAMEBUFFERTEXTUREPROC FramebufferTexture; PFNGLFRAMEBUFFERTEXTURE1DPROC FramebufferTexture1D; @@ -1536,13 +2256,19 @@ typedef struct GladGLContext { PFNGLFRONTFACEPROC FrontFace; PFNGLGENBUFFERSPROC GenBuffers; PFNGLGENFRAMEBUFFERSPROC GenFramebuffers; + PFNGLGENPROGRAMPIPELINESPROC GenProgramPipelines; PFNGLGENQUERIESPROC GenQueries; PFNGLGENRENDERBUFFERSPROC GenRenderbuffers; PFNGLGENSAMPLERSPROC GenSamplers; PFNGLGENTEXTURESPROC GenTextures; + PFNGLGENTRANSFORMFEEDBACKSPROC GenTransformFeedbacks; PFNGLGENVERTEXARRAYSPROC GenVertexArrays; PFNGLGENERATEMIPMAPPROC GenerateMipmap; + PFNGLGETACTIVEATOMICCOUNTERBUFFERIVPROC GetActiveAtomicCounterBufferiv; PFNGLGETACTIVEATTRIBPROC GetActiveAttrib; + PFNGLGETACTIVESUBROUTINENAMEPROC GetActiveSubroutineName; + PFNGLGETACTIVESUBROUTINEUNIFORMNAMEPROC GetActiveSubroutineUniformName; + PFNGLGETACTIVESUBROUTINEUNIFORMIVPROC GetActiveSubroutineUniformiv; PFNGLGETACTIVEUNIFORMPROC GetActiveUniform; PFNGLGETACTIVEUNIFORMBLOCKNAMEPROC GetActiveUniformBlockName; PFNGLGETACTIVEUNIFORMBLOCKIVPROC GetActiveUniformBlockiv; @@ -1557,19 +2283,39 @@ typedef struct GladGLContext { PFNGLGETBUFFERPOINTERVPROC GetBufferPointerv; PFNGLGETBUFFERSUBDATAPROC GetBufferSubData; PFNGLGETCOMPRESSEDTEXIMAGEPROC GetCompressedTexImage; + PFNGLGETDEBUGMESSAGELOGPROC GetDebugMessageLog; + PFNGLGETDOUBLEI_VPROC GetDoublei_v; PFNGLGETDOUBLEVPROC GetDoublev; PFNGLGETERRORPROC GetError; + PFNGLGETFLOATI_VPROC GetFloati_v; PFNGLGETFLOATVPROC GetFloatv; PFNGLGETFRAGDATAINDEXPROC GetFragDataIndex; PFNGLGETFRAGDATALOCATIONPROC GetFragDataLocation; PFNGLGETFRAMEBUFFERATTACHMENTPARAMETERIVPROC GetFramebufferAttachmentParameteriv; + PFNGLGETFRAMEBUFFERPARAMETERIVPROC GetFramebufferParameteriv; PFNGLGETINTEGER64I_VPROC GetInteger64i_v; PFNGLGETINTEGER64VPROC GetInteger64v; PFNGLGETINTEGERI_VPROC GetIntegeri_v; PFNGLGETINTEGERVPROC GetIntegerv; + PFNGLGETINTERNALFORMATI64VPROC GetInternalformati64v; + PFNGLGETINTERNALFORMATIVPROC GetInternalformativ; PFNGLGETMULTISAMPLEFVPROC GetMultisamplefv; + PFNGLGETOBJECTLABELPROC GetObjectLabel; + PFNGLGETOBJECTPTRLABELPROC GetObjectPtrLabel; + PFNGLGETPOINTERVPROC GetPointerv; + PFNGLGETPROGRAMBINARYPROC GetProgramBinary; PFNGLGETPROGRAMINFOLOGPROC GetProgramInfoLog; + PFNGLGETPROGRAMINTERFACEIVPROC GetProgramInterfaceiv; + PFNGLGETPROGRAMPIPELINEINFOLOGPROC GetProgramPipelineInfoLog; + PFNGLGETPROGRAMPIPELINEIVPROC GetProgramPipelineiv; + PFNGLGETPROGRAMRESOURCEINDEXPROC GetProgramResourceIndex; + PFNGLGETPROGRAMRESOURCELOCATIONPROC GetProgramResourceLocation; + PFNGLGETPROGRAMRESOURCELOCATIONINDEXPROC GetProgramResourceLocationIndex; + PFNGLGETPROGRAMRESOURCENAMEPROC GetProgramResourceName; + PFNGLGETPROGRAMRESOURCEIVPROC GetProgramResourceiv; + PFNGLGETPROGRAMSTAGEIVPROC GetProgramStageiv; PFNGLGETPROGRAMIVPROC GetProgramiv; + PFNGLGETQUERYINDEXEDIVPROC GetQueryIndexediv; PFNGLGETQUERYOBJECTI64VPROC GetQueryObjecti64v; PFNGLGETQUERYOBJECTIVPROC GetQueryObjectiv; PFNGLGETQUERYOBJECTUI64VPROC GetQueryObjectui64v; @@ -1581,10 +2327,13 @@ typedef struct GladGLContext { PFNGLGETSAMPLERPARAMETERFVPROC GetSamplerParameterfv; PFNGLGETSAMPLERPARAMETERIVPROC GetSamplerParameteriv; PFNGLGETSHADERINFOLOGPROC GetShaderInfoLog; + PFNGLGETSHADERPRECISIONFORMATPROC GetShaderPrecisionFormat; PFNGLGETSHADERSOURCEPROC GetShaderSource; PFNGLGETSHADERIVPROC GetShaderiv; PFNGLGETSTRINGPROC GetString; PFNGLGETSTRINGIPROC GetStringi; + PFNGLGETSUBROUTINEINDEXPROC GetSubroutineIndex; + PFNGLGETSUBROUTINEUNIFORMLOCATIONPROC GetSubroutineUniformLocation; PFNGLGETSYNCIVPROC GetSynciv; PFNGLGETTEXIMAGEPROC GetTexImage; PFNGLGETTEXLEVELPARAMETERFVPROC GetTexLevelParameterfv; @@ -1597,36 +2346,56 @@ typedef struct GladGLContext { PFNGLGETUNIFORMBLOCKINDEXPROC GetUniformBlockIndex; PFNGLGETUNIFORMINDICESPROC GetUniformIndices; PFNGLGETUNIFORMLOCATIONPROC GetUniformLocation; + PFNGLGETUNIFORMSUBROUTINEUIVPROC GetUniformSubroutineuiv; + PFNGLGETUNIFORMDVPROC GetUniformdv; PFNGLGETUNIFORMFVPROC GetUniformfv; PFNGLGETUNIFORMIVPROC GetUniformiv; PFNGLGETUNIFORMUIVPROC GetUniformuiv; PFNGLGETVERTEXATTRIBIIVPROC GetVertexAttribIiv; PFNGLGETVERTEXATTRIBIUIVPROC GetVertexAttribIuiv; + PFNGLGETVERTEXATTRIBLDVPROC GetVertexAttribLdv; PFNGLGETVERTEXATTRIBPOINTERVPROC GetVertexAttribPointerv; PFNGLGETVERTEXATTRIBDVPROC GetVertexAttribdv; PFNGLGETVERTEXATTRIBFVPROC GetVertexAttribfv; PFNGLGETVERTEXATTRIBIVPROC GetVertexAttribiv; PFNGLHINTPROC Hint; + PFNGLINVALIDATEBUFFERDATAPROC InvalidateBufferData; + PFNGLINVALIDATEBUFFERSUBDATAPROC InvalidateBufferSubData; + PFNGLINVALIDATEFRAMEBUFFERPROC InvalidateFramebuffer; + PFNGLINVALIDATESUBFRAMEBUFFERPROC InvalidateSubFramebuffer; + PFNGLINVALIDATETEXIMAGEPROC InvalidateTexImage; + PFNGLINVALIDATETEXSUBIMAGEPROC InvalidateTexSubImage; PFNGLISBUFFERPROC IsBuffer; PFNGLISENABLEDPROC IsEnabled; PFNGLISENABLEDIPROC IsEnabledi; PFNGLISFRAMEBUFFERPROC IsFramebuffer; PFNGLISPROGRAMPROC IsProgram; + PFNGLISPROGRAMPIPELINEPROC IsProgramPipeline; PFNGLISQUERYPROC IsQuery; PFNGLISRENDERBUFFERPROC IsRenderbuffer; PFNGLISSAMPLERPROC IsSampler; PFNGLISSHADERPROC IsShader; PFNGLISSYNCPROC IsSync; PFNGLISTEXTUREPROC IsTexture; + PFNGLISTRANSFORMFEEDBACKPROC IsTransformFeedback; PFNGLISVERTEXARRAYPROC IsVertexArray; PFNGLLINEWIDTHPROC LineWidth; PFNGLLINKPROGRAMPROC LinkProgram; PFNGLLOGICOPPROC LogicOp; PFNGLMAPBUFFERPROC MapBuffer; PFNGLMAPBUFFERRANGEPROC MapBufferRange; + PFNGLMEMORYBARRIERPROC MemoryBarrier; + PFNGLMINSAMPLESHADINGPROC MinSampleShading; PFNGLMULTIDRAWARRAYSPROC MultiDrawArrays; + PFNGLMULTIDRAWARRAYSINDIRECTPROC MultiDrawArraysIndirect; PFNGLMULTIDRAWELEMENTSPROC MultiDrawElements; PFNGLMULTIDRAWELEMENTSBASEVERTEXPROC MultiDrawElementsBaseVertex; + PFNGLMULTIDRAWELEMENTSINDIRECTPROC MultiDrawElementsIndirect; + PFNGLOBJECTLABELPROC ObjectLabel; + PFNGLOBJECTPTRLABELPROC ObjectPtrLabel; + PFNGLPATCHPARAMETERFVPROC PatchParameterfv; + PFNGLPATCHPARAMETERIPROC PatchParameteri; + PFNGLPAUSETRANSFORMFEEDBACKPROC PauseTransformFeedback; PFNGLPIXELSTOREFPROC PixelStoref; PFNGLPIXELSTOREIPROC PixelStorei; PFNGLPOINTPARAMETERFPROC PointParameterf; @@ -1636,13 +2405,69 @@ typedef struct GladGLContext { PFNGLPOINTSIZEPROC PointSize; PFNGLPOLYGONMODEPROC PolygonMode; PFNGLPOLYGONOFFSETPROC PolygonOffset; + PFNGLPOPDEBUGGROUPPROC PopDebugGroup; PFNGLPRIMITIVERESTARTINDEXPROC PrimitiveRestartIndex; + PFNGLPROGRAMBINARYPROC ProgramBinary; + PFNGLPROGRAMPARAMETERIPROC ProgramParameteri; + PFNGLPROGRAMUNIFORM1DPROC ProgramUniform1d; + PFNGLPROGRAMUNIFORM1DVPROC ProgramUniform1dv; + PFNGLPROGRAMUNIFORM1FPROC ProgramUniform1f; + PFNGLPROGRAMUNIFORM1FVPROC ProgramUniform1fv; + PFNGLPROGRAMUNIFORM1IPROC ProgramUniform1i; + PFNGLPROGRAMUNIFORM1IVPROC ProgramUniform1iv; + PFNGLPROGRAMUNIFORM1UIPROC ProgramUniform1ui; + PFNGLPROGRAMUNIFORM1UIVPROC ProgramUniform1uiv; + PFNGLPROGRAMUNIFORM2DPROC ProgramUniform2d; + PFNGLPROGRAMUNIFORM2DVPROC ProgramUniform2dv; + PFNGLPROGRAMUNIFORM2FPROC ProgramUniform2f; + PFNGLPROGRAMUNIFORM2FVPROC ProgramUniform2fv; + PFNGLPROGRAMUNIFORM2IPROC ProgramUniform2i; + PFNGLPROGRAMUNIFORM2IVPROC ProgramUniform2iv; + PFNGLPROGRAMUNIFORM2UIPROC ProgramUniform2ui; + PFNGLPROGRAMUNIFORM2UIVPROC ProgramUniform2uiv; + PFNGLPROGRAMUNIFORM3DPROC ProgramUniform3d; + PFNGLPROGRAMUNIFORM3DVPROC ProgramUniform3dv; + PFNGLPROGRAMUNIFORM3FPROC ProgramUniform3f; + PFNGLPROGRAMUNIFORM3FVPROC ProgramUniform3fv; + PFNGLPROGRAMUNIFORM3IPROC ProgramUniform3i; + PFNGLPROGRAMUNIFORM3IVPROC ProgramUniform3iv; + PFNGLPROGRAMUNIFORM3UIPROC ProgramUniform3ui; + PFNGLPROGRAMUNIFORM3UIVPROC ProgramUniform3uiv; + PFNGLPROGRAMUNIFORM4DPROC ProgramUniform4d; + PFNGLPROGRAMUNIFORM4DVPROC ProgramUniform4dv; + PFNGLPROGRAMUNIFORM4FPROC ProgramUniform4f; + PFNGLPROGRAMUNIFORM4FVPROC ProgramUniform4fv; + PFNGLPROGRAMUNIFORM4IPROC ProgramUniform4i; + PFNGLPROGRAMUNIFORM4IVPROC ProgramUniform4iv; + PFNGLPROGRAMUNIFORM4UIPROC ProgramUniform4ui; + PFNGLPROGRAMUNIFORM4UIVPROC ProgramUniform4uiv; + PFNGLPROGRAMUNIFORMMATRIX2DVPROC ProgramUniformMatrix2dv; + PFNGLPROGRAMUNIFORMMATRIX2FVPROC ProgramUniformMatrix2fv; + PFNGLPROGRAMUNIFORMMATRIX2X3DVPROC ProgramUniformMatrix2x3dv; + PFNGLPROGRAMUNIFORMMATRIX2X3FVPROC ProgramUniformMatrix2x3fv; + PFNGLPROGRAMUNIFORMMATRIX2X4DVPROC ProgramUniformMatrix2x4dv; + PFNGLPROGRAMUNIFORMMATRIX2X4FVPROC ProgramUniformMatrix2x4fv; + PFNGLPROGRAMUNIFORMMATRIX3DVPROC ProgramUniformMatrix3dv; + PFNGLPROGRAMUNIFORMMATRIX3FVPROC ProgramUniformMatrix3fv; + PFNGLPROGRAMUNIFORMMATRIX3X2DVPROC ProgramUniformMatrix3x2dv; + PFNGLPROGRAMUNIFORMMATRIX3X2FVPROC ProgramUniformMatrix3x2fv; + PFNGLPROGRAMUNIFORMMATRIX3X4DVPROC ProgramUniformMatrix3x4dv; + PFNGLPROGRAMUNIFORMMATRIX3X4FVPROC ProgramUniformMatrix3x4fv; + PFNGLPROGRAMUNIFORMMATRIX4DVPROC ProgramUniformMatrix4dv; + PFNGLPROGRAMUNIFORMMATRIX4FVPROC ProgramUniformMatrix4fv; + PFNGLPROGRAMUNIFORMMATRIX4X2DVPROC ProgramUniformMatrix4x2dv; + PFNGLPROGRAMUNIFORMMATRIX4X2FVPROC ProgramUniformMatrix4x2fv; + PFNGLPROGRAMUNIFORMMATRIX4X3DVPROC ProgramUniformMatrix4x3dv; + PFNGLPROGRAMUNIFORMMATRIX4X3FVPROC ProgramUniformMatrix4x3fv; PFNGLPROVOKINGVERTEXPROC ProvokingVertex; + PFNGLPUSHDEBUGGROUPPROC PushDebugGroup; PFNGLQUERYCOUNTERPROC QueryCounter; PFNGLREADBUFFERPROC ReadBuffer; PFNGLREADPIXELSPROC ReadPixels; + PFNGLRELEASESHADERCOMPILERPROC ReleaseShaderCompiler; PFNGLRENDERBUFFERSTORAGEPROC RenderbufferStorage; PFNGLRENDERBUFFERSTORAGEMULTISAMPLEPROC RenderbufferStorageMultisample; + PFNGLRESUMETRANSFORMFEEDBACKPROC ResumeTransformFeedback; PFNGLSAMPLECOVERAGEPROC SampleCoverage; PFNGLSAMPLEMASKIPROC SampleMaski; PFNGLSAMPLERPARAMETERIIVPROC SamplerParameterIiv; @@ -1652,7 +2477,12 @@ typedef struct GladGLContext { PFNGLSAMPLERPARAMETERIPROC SamplerParameteri; PFNGLSAMPLERPARAMETERIVPROC SamplerParameteriv; PFNGLSCISSORPROC Scissor; + PFNGLSCISSORARRAYVPROC ScissorArrayv; + PFNGLSCISSORINDEXEDPROC ScissorIndexed; + PFNGLSCISSORINDEXEDVPROC ScissorIndexedv; + PFNGLSHADERBINARYPROC ShaderBinary; PFNGLSHADERSOURCEPROC ShaderSource; + PFNGLSHADERSTORAGEBLOCKBINDINGPROC ShaderStorageBlockBinding; PFNGLSTENCILFUNCPROC StencilFunc; PFNGLSTENCILFUNCSEPARATEPROC StencilFuncSeparate; PFNGLSTENCILMASKPROC StencilMask; @@ -1660,6 +2490,7 @@ typedef struct GladGLContext { PFNGLSTENCILOPPROC StencilOp; PFNGLSTENCILOPSEPARATEPROC StencilOpSeparate; PFNGLTEXBUFFERPROC TexBuffer; + PFNGLTEXBUFFERRANGEPROC TexBufferRange; PFNGLTEXIMAGE1DPROC TexImage1D; PFNGLTEXIMAGE2DPROC TexImage2D; PFNGLTEXIMAGE2DMULTISAMPLEPROC TexImage2DMultisample; @@ -1671,28 +2502,42 @@ typedef struct GladGLContext { PFNGLTEXPARAMETERFVPROC TexParameterfv; PFNGLTEXPARAMETERIPROC TexParameteri; PFNGLTEXPARAMETERIVPROC TexParameteriv; + PFNGLTEXSTORAGE1DPROC TexStorage1D; + PFNGLTEXSTORAGE2DPROC TexStorage2D; + PFNGLTEXSTORAGE2DMULTISAMPLEPROC TexStorage2DMultisample; + PFNGLTEXSTORAGE3DPROC TexStorage3D; + PFNGLTEXSTORAGE3DMULTISAMPLEPROC TexStorage3DMultisample; PFNGLTEXSUBIMAGE1DPROC TexSubImage1D; PFNGLTEXSUBIMAGE2DPROC TexSubImage2D; PFNGLTEXSUBIMAGE3DPROC TexSubImage3D; + PFNGLTEXTUREVIEWPROC TextureView; PFNGLTRANSFORMFEEDBACKVARYINGSPROC TransformFeedbackVaryings; + PFNGLUNIFORM1DPROC Uniform1d; + PFNGLUNIFORM1DVPROC Uniform1dv; PFNGLUNIFORM1FPROC Uniform1f; PFNGLUNIFORM1FVPROC Uniform1fv; PFNGLUNIFORM1IPROC Uniform1i; PFNGLUNIFORM1IVPROC Uniform1iv; PFNGLUNIFORM1UIPROC Uniform1ui; PFNGLUNIFORM1UIVPROC Uniform1uiv; + PFNGLUNIFORM2DPROC Uniform2d; + PFNGLUNIFORM2DVPROC Uniform2dv; PFNGLUNIFORM2FPROC Uniform2f; PFNGLUNIFORM2FVPROC Uniform2fv; PFNGLUNIFORM2IPROC Uniform2i; PFNGLUNIFORM2IVPROC Uniform2iv; PFNGLUNIFORM2UIPROC Uniform2ui; PFNGLUNIFORM2UIVPROC Uniform2uiv; + PFNGLUNIFORM3DPROC Uniform3d; + PFNGLUNIFORM3DVPROC Uniform3dv; PFNGLUNIFORM3FPROC Uniform3f; PFNGLUNIFORM3FVPROC Uniform3fv; PFNGLUNIFORM3IPROC Uniform3i; PFNGLUNIFORM3IVPROC Uniform3iv; PFNGLUNIFORM3UIPROC Uniform3ui; PFNGLUNIFORM3UIVPROC Uniform3uiv; + PFNGLUNIFORM4DPROC Uniform4d; + PFNGLUNIFORM4DVPROC Uniform4dv; PFNGLUNIFORM4FPROC Uniform4f; PFNGLUNIFORM4FVPROC Uniform4fv; PFNGLUNIFORM4IPROC Uniform4i; @@ -1700,18 +2545,30 @@ typedef struct GladGLContext { PFNGLUNIFORM4UIPROC Uniform4ui; PFNGLUNIFORM4UIVPROC Uniform4uiv; PFNGLUNIFORMBLOCKBINDINGPROC UniformBlockBinding; + PFNGLUNIFORMMATRIX2DVPROC UniformMatrix2dv; PFNGLUNIFORMMATRIX2FVPROC UniformMatrix2fv; + PFNGLUNIFORMMATRIX2X3DVPROC UniformMatrix2x3dv; PFNGLUNIFORMMATRIX2X3FVPROC UniformMatrix2x3fv; + PFNGLUNIFORMMATRIX2X4DVPROC UniformMatrix2x4dv; PFNGLUNIFORMMATRIX2X4FVPROC UniformMatrix2x4fv; + PFNGLUNIFORMMATRIX3DVPROC UniformMatrix3dv; PFNGLUNIFORMMATRIX3FVPROC UniformMatrix3fv; + PFNGLUNIFORMMATRIX3X2DVPROC UniformMatrix3x2dv; PFNGLUNIFORMMATRIX3X2FVPROC UniformMatrix3x2fv; + PFNGLUNIFORMMATRIX3X4DVPROC UniformMatrix3x4dv; PFNGLUNIFORMMATRIX3X4FVPROC UniformMatrix3x4fv; + PFNGLUNIFORMMATRIX4DVPROC UniformMatrix4dv; PFNGLUNIFORMMATRIX4FVPROC UniformMatrix4fv; + PFNGLUNIFORMMATRIX4X2DVPROC UniformMatrix4x2dv; PFNGLUNIFORMMATRIX4X2FVPROC UniformMatrix4x2fv; + PFNGLUNIFORMMATRIX4X3DVPROC UniformMatrix4x3dv; PFNGLUNIFORMMATRIX4X3FVPROC UniformMatrix4x3fv; + PFNGLUNIFORMSUBROUTINESUIVPROC UniformSubroutinesuiv; PFNGLUNMAPBUFFERPROC UnmapBuffer; PFNGLUSEPROGRAMPROC UseProgram; + PFNGLUSEPROGRAMSTAGESPROC UseProgramStages; PFNGLVALIDATEPROGRAMPROC ValidateProgram; + PFNGLVALIDATEPROGRAMPIPELINEPROC ValidateProgramPipeline; PFNGLVERTEXATTRIB1DPROC VertexAttrib1d; PFNGLVERTEXATTRIB1DVPROC VertexAttrib1dv; PFNGLVERTEXATTRIB1FPROC VertexAttrib1f; @@ -1748,7 +2605,9 @@ typedef struct GladGLContext { PFNGLVERTEXATTRIB4UBVPROC VertexAttrib4ubv; PFNGLVERTEXATTRIB4UIVPROC VertexAttrib4uiv; PFNGLVERTEXATTRIB4USVPROC VertexAttrib4usv; + PFNGLVERTEXATTRIBBINDINGPROC VertexAttribBinding; PFNGLVERTEXATTRIBDIVISORPROC VertexAttribDivisor; + PFNGLVERTEXATTRIBFORMATPROC VertexAttribFormat; PFNGLVERTEXATTRIBI1IPROC VertexAttribI1i; PFNGLVERTEXATTRIBI1IVPROC VertexAttribI1iv; PFNGLVERTEXATTRIBI1UIPROC VertexAttribI1ui; @@ -1769,7 +2628,18 @@ typedef struct GladGLContext { PFNGLVERTEXATTRIBI4UIPROC VertexAttribI4ui; PFNGLVERTEXATTRIBI4UIVPROC VertexAttribI4uiv; PFNGLVERTEXATTRIBI4USVPROC VertexAttribI4usv; + PFNGLVERTEXATTRIBIFORMATPROC VertexAttribIFormat; PFNGLVERTEXATTRIBIPOINTERPROC VertexAttribIPointer; + PFNGLVERTEXATTRIBL1DPROC VertexAttribL1d; + PFNGLVERTEXATTRIBL1DVPROC VertexAttribL1dv; + PFNGLVERTEXATTRIBL2DPROC VertexAttribL2d; + PFNGLVERTEXATTRIBL2DVPROC VertexAttribL2dv; + PFNGLVERTEXATTRIBL3DPROC VertexAttribL3d; + PFNGLVERTEXATTRIBL3DVPROC VertexAttribL3dv; + PFNGLVERTEXATTRIBL4DPROC VertexAttribL4d; + PFNGLVERTEXATTRIBL4DVPROC VertexAttribL4dv; + PFNGLVERTEXATTRIBLFORMATPROC VertexAttribLFormat; + PFNGLVERTEXATTRIBLPOINTERPROC VertexAttribLPointer; PFNGLVERTEXATTRIBP1UIPROC VertexAttribP1ui; PFNGLVERTEXATTRIBP1UIVPROC VertexAttribP1uiv; PFNGLVERTEXATTRIBP2UIPROC VertexAttribP2ui; @@ -1779,7 +2649,11 @@ typedef struct GladGLContext { PFNGLVERTEXATTRIBP4UIPROC VertexAttribP4ui; PFNGLVERTEXATTRIBP4UIVPROC VertexAttribP4uiv; PFNGLVERTEXATTRIBPOINTERPROC VertexAttribPointer; + PFNGLVERTEXBINDINGDIVISORPROC VertexBindingDivisor; PFNGLVIEWPORTPROC Viewport; + PFNGLVIEWPORTARRAYVPROC ViewportArrayv; + PFNGLVIEWPORTINDEXEDFPROC ViewportIndexedf; + PFNGLVIEWPORTINDEXEDFVPROC ViewportIndexedfv; PFNGLWAITSYNCPROC WaitSync; void* glad_loader_handle; diff --git a/vendor/glad/include/glad/glad.h b/vendor/glad/include/glad/glad.h deleted file mode 100644 index f70d5b73f..000000000 --- a/vendor/glad/include/glad/glad.h +++ /dev/null @@ -1 +0,0 @@ -#include diff --git a/vendor/glad/src/gl.c b/vendor/glad/src/gl.c index ad49f387a..3eaf35450 100644 --- a/vendor/glad/src/gl.c +++ b/vendor/glad/src/gl.c @@ -90,6 +90,7 @@ static void glad_gl_load_GL_VERSION_1_1(GladGLContext *context, GLADuserptrloadf context->DrawArrays = (PFNGLDRAWARRAYSPROC) load(userptr, "glDrawArrays"); context->DrawElements = (PFNGLDRAWELEMENTSPROC) load(userptr, "glDrawElements"); context->GenTextures = (PFNGLGENTEXTURESPROC) load(userptr, "glGenTextures"); + context->GetPointerv = (PFNGLGETPOINTERVPROC) load(userptr, "glGetPointerv"); context->IsTexture = (PFNGLISTEXTUREPROC) load(userptr, "glIsTexture"); context->PolygonOffset = (PFNGLPOLYGONOFFSETPROC) load(userptr, "glPolygonOffset"); context->TexSubImage1D = (PFNGLTEXSUBIMAGE1DPROC) load(userptr, "glTexSubImage1D"); @@ -411,39 +412,229 @@ static void glad_gl_load_GL_VERSION_3_3(GladGLContext *context, GLADuserptrloadf context->VertexAttribP4ui = (PFNGLVERTEXATTRIBP4UIPROC) load(userptr, "glVertexAttribP4ui"); context->VertexAttribP4uiv = (PFNGLVERTEXATTRIBP4UIVPROC) load(userptr, "glVertexAttribP4uiv"); } +static void glad_gl_load_GL_VERSION_4_0(GladGLContext *context, GLADuserptrloadfunc load, void* userptr) { + if(!context->VERSION_4_0) return; + context->BeginQueryIndexed = (PFNGLBEGINQUERYINDEXEDPROC) load(userptr, "glBeginQueryIndexed"); + context->BindTransformFeedback = (PFNGLBINDTRANSFORMFEEDBACKPROC) load(userptr, "glBindTransformFeedback"); + context->BlendEquationSeparatei = (PFNGLBLENDEQUATIONSEPARATEIPROC) load(userptr, "glBlendEquationSeparatei"); + context->BlendEquationi = (PFNGLBLENDEQUATIONIPROC) load(userptr, "glBlendEquationi"); + context->BlendFuncSeparatei = (PFNGLBLENDFUNCSEPARATEIPROC) load(userptr, "glBlendFuncSeparatei"); + context->BlendFunci = (PFNGLBLENDFUNCIPROC) load(userptr, "glBlendFunci"); + context->DeleteTransformFeedbacks = (PFNGLDELETETRANSFORMFEEDBACKSPROC) load(userptr, "glDeleteTransformFeedbacks"); + context->DrawArraysIndirect = (PFNGLDRAWARRAYSINDIRECTPROC) load(userptr, "glDrawArraysIndirect"); + context->DrawElementsIndirect = (PFNGLDRAWELEMENTSINDIRECTPROC) load(userptr, "glDrawElementsIndirect"); + context->DrawTransformFeedback = (PFNGLDRAWTRANSFORMFEEDBACKPROC) load(userptr, "glDrawTransformFeedback"); + context->DrawTransformFeedbackStream = (PFNGLDRAWTRANSFORMFEEDBACKSTREAMPROC) load(userptr, "glDrawTransformFeedbackStream"); + context->EndQueryIndexed = (PFNGLENDQUERYINDEXEDPROC) load(userptr, "glEndQueryIndexed"); + context->GenTransformFeedbacks = (PFNGLGENTRANSFORMFEEDBACKSPROC) load(userptr, "glGenTransformFeedbacks"); + context->GetActiveSubroutineName = (PFNGLGETACTIVESUBROUTINENAMEPROC) load(userptr, "glGetActiveSubroutineName"); + context->GetActiveSubroutineUniformName = (PFNGLGETACTIVESUBROUTINEUNIFORMNAMEPROC) load(userptr, "glGetActiveSubroutineUniformName"); + context->GetActiveSubroutineUniformiv = (PFNGLGETACTIVESUBROUTINEUNIFORMIVPROC) load(userptr, "glGetActiveSubroutineUniformiv"); + context->GetProgramStageiv = (PFNGLGETPROGRAMSTAGEIVPROC) load(userptr, "glGetProgramStageiv"); + context->GetQueryIndexediv = (PFNGLGETQUERYINDEXEDIVPROC) load(userptr, "glGetQueryIndexediv"); + context->GetSubroutineIndex = (PFNGLGETSUBROUTINEINDEXPROC) load(userptr, "glGetSubroutineIndex"); + context->GetSubroutineUniformLocation = (PFNGLGETSUBROUTINEUNIFORMLOCATIONPROC) load(userptr, "glGetSubroutineUniformLocation"); + context->GetUniformSubroutineuiv = (PFNGLGETUNIFORMSUBROUTINEUIVPROC) load(userptr, "glGetUniformSubroutineuiv"); + context->GetUniformdv = (PFNGLGETUNIFORMDVPROC) load(userptr, "glGetUniformdv"); + context->IsTransformFeedback = (PFNGLISTRANSFORMFEEDBACKPROC) load(userptr, "glIsTransformFeedback"); + context->MinSampleShading = (PFNGLMINSAMPLESHADINGPROC) load(userptr, "glMinSampleShading"); + context->PatchParameterfv = (PFNGLPATCHPARAMETERFVPROC) load(userptr, "glPatchParameterfv"); + context->PatchParameteri = (PFNGLPATCHPARAMETERIPROC) load(userptr, "glPatchParameteri"); + context->PauseTransformFeedback = (PFNGLPAUSETRANSFORMFEEDBACKPROC) load(userptr, "glPauseTransformFeedback"); + context->ResumeTransformFeedback = (PFNGLRESUMETRANSFORMFEEDBACKPROC) load(userptr, "glResumeTransformFeedback"); + context->Uniform1d = (PFNGLUNIFORM1DPROC) load(userptr, "glUniform1d"); + context->Uniform1dv = (PFNGLUNIFORM1DVPROC) load(userptr, "glUniform1dv"); + context->Uniform2d = (PFNGLUNIFORM2DPROC) load(userptr, "glUniform2d"); + context->Uniform2dv = (PFNGLUNIFORM2DVPROC) load(userptr, "glUniform2dv"); + context->Uniform3d = (PFNGLUNIFORM3DPROC) load(userptr, "glUniform3d"); + context->Uniform3dv = (PFNGLUNIFORM3DVPROC) load(userptr, "glUniform3dv"); + context->Uniform4d = (PFNGLUNIFORM4DPROC) load(userptr, "glUniform4d"); + context->Uniform4dv = (PFNGLUNIFORM4DVPROC) load(userptr, "glUniform4dv"); + context->UniformMatrix2dv = (PFNGLUNIFORMMATRIX2DVPROC) load(userptr, "glUniformMatrix2dv"); + context->UniformMatrix2x3dv = (PFNGLUNIFORMMATRIX2X3DVPROC) load(userptr, "glUniformMatrix2x3dv"); + context->UniformMatrix2x4dv = (PFNGLUNIFORMMATRIX2X4DVPROC) load(userptr, "glUniformMatrix2x4dv"); + context->UniformMatrix3dv = (PFNGLUNIFORMMATRIX3DVPROC) load(userptr, "glUniformMatrix3dv"); + context->UniformMatrix3x2dv = (PFNGLUNIFORMMATRIX3X2DVPROC) load(userptr, "glUniformMatrix3x2dv"); + context->UniformMatrix3x4dv = (PFNGLUNIFORMMATRIX3X4DVPROC) load(userptr, "glUniformMatrix3x4dv"); + context->UniformMatrix4dv = (PFNGLUNIFORMMATRIX4DVPROC) load(userptr, "glUniformMatrix4dv"); + context->UniformMatrix4x2dv = (PFNGLUNIFORMMATRIX4X2DVPROC) load(userptr, "glUniformMatrix4x2dv"); + context->UniformMatrix4x3dv = (PFNGLUNIFORMMATRIX4X3DVPROC) load(userptr, "glUniformMatrix4x3dv"); + context->UniformSubroutinesuiv = (PFNGLUNIFORMSUBROUTINESUIVPROC) load(userptr, "glUniformSubroutinesuiv"); +} +static void glad_gl_load_GL_VERSION_4_1(GladGLContext *context, GLADuserptrloadfunc load, void* userptr) { + if(!context->VERSION_4_1) return; + context->ActiveShaderProgram = (PFNGLACTIVESHADERPROGRAMPROC) load(userptr, "glActiveShaderProgram"); + context->BindProgramPipeline = (PFNGLBINDPROGRAMPIPELINEPROC) load(userptr, "glBindProgramPipeline"); + context->ClearDepthf = (PFNGLCLEARDEPTHFPROC) load(userptr, "glClearDepthf"); + context->CreateShaderProgramv = (PFNGLCREATESHADERPROGRAMVPROC) load(userptr, "glCreateShaderProgramv"); + context->DeleteProgramPipelines = (PFNGLDELETEPROGRAMPIPELINESPROC) load(userptr, "glDeleteProgramPipelines"); + context->DepthRangeArrayv = (PFNGLDEPTHRANGEARRAYVPROC) load(userptr, "glDepthRangeArrayv"); + context->DepthRangeIndexed = (PFNGLDEPTHRANGEINDEXEDPROC) load(userptr, "glDepthRangeIndexed"); + context->DepthRangef = (PFNGLDEPTHRANGEFPROC) load(userptr, "glDepthRangef"); + context->GenProgramPipelines = (PFNGLGENPROGRAMPIPELINESPROC) load(userptr, "glGenProgramPipelines"); + context->GetDoublei_v = (PFNGLGETDOUBLEI_VPROC) load(userptr, "glGetDoublei_v"); + context->GetFloati_v = (PFNGLGETFLOATI_VPROC) load(userptr, "glGetFloati_v"); + context->GetProgramBinary = (PFNGLGETPROGRAMBINARYPROC) load(userptr, "glGetProgramBinary"); + context->GetProgramPipelineInfoLog = (PFNGLGETPROGRAMPIPELINEINFOLOGPROC) load(userptr, "glGetProgramPipelineInfoLog"); + context->GetProgramPipelineiv = (PFNGLGETPROGRAMPIPELINEIVPROC) load(userptr, "glGetProgramPipelineiv"); + context->GetShaderPrecisionFormat = (PFNGLGETSHADERPRECISIONFORMATPROC) load(userptr, "glGetShaderPrecisionFormat"); + context->GetVertexAttribLdv = (PFNGLGETVERTEXATTRIBLDVPROC) load(userptr, "glGetVertexAttribLdv"); + context->IsProgramPipeline = (PFNGLISPROGRAMPIPELINEPROC) load(userptr, "glIsProgramPipeline"); + context->ProgramBinary = (PFNGLPROGRAMBINARYPROC) load(userptr, "glProgramBinary"); + context->ProgramParameteri = (PFNGLPROGRAMPARAMETERIPROC) load(userptr, "glProgramParameteri"); + context->ProgramUniform1d = (PFNGLPROGRAMUNIFORM1DPROC) load(userptr, "glProgramUniform1d"); + context->ProgramUniform1dv = (PFNGLPROGRAMUNIFORM1DVPROC) load(userptr, "glProgramUniform1dv"); + context->ProgramUniform1f = (PFNGLPROGRAMUNIFORM1FPROC) load(userptr, "glProgramUniform1f"); + context->ProgramUniform1fv = (PFNGLPROGRAMUNIFORM1FVPROC) load(userptr, "glProgramUniform1fv"); + context->ProgramUniform1i = (PFNGLPROGRAMUNIFORM1IPROC) load(userptr, "glProgramUniform1i"); + context->ProgramUniform1iv = (PFNGLPROGRAMUNIFORM1IVPROC) load(userptr, "glProgramUniform1iv"); + context->ProgramUniform1ui = (PFNGLPROGRAMUNIFORM1UIPROC) load(userptr, "glProgramUniform1ui"); + context->ProgramUniform1uiv = (PFNGLPROGRAMUNIFORM1UIVPROC) load(userptr, "glProgramUniform1uiv"); + context->ProgramUniform2d = (PFNGLPROGRAMUNIFORM2DPROC) load(userptr, "glProgramUniform2d"); + context->ProgramUniform2dv = (PFNGLPROGRAMUNIFORM2DVPROC) load(userptr, "glProgramUniform2dv"); + context->ProgramUniform2f = (PFNGLPROGRAMUNIFORM2FPROC) load(userptr, "glProgramUniform2f"); + context->ProgramUniform2fv = (PFNGLPROGRAMUNIFORM2FVPROC) load(userptr, "glProgramUniform2fv"); + context->ProgramUniform2i = (PFNGLPROGRAMUNIFORM2IPROC) load(userptr, "glProgramUniform2i"); + context->ProgramUniform2iv = (PFNGLPROGRAMUNIFORM2IVPROC) load(userptr, "glProgramUniform2iv"); + context->ProgramUniform2ui = (PFNGLPROGRAMUNIFORM2UIPROC) load(userptr, "glProgramUniform2ui"); + context->ProgramUniform2uiv = (PFNGLPROGRAMUNIFORM2UIVPROC) load(userptr, "glProgramUniform2uiv"); + context->ProgramUniform3d = (PFNGLPROGRAMUNIFORM3DPROC) load(userptr, "glProgramUniform3d"); + context->ProgramUniform3dv = (PFNGLPROGRAMUNIFORM3DVPROC) load(userptr, "glProgramUniform3dv"); + context->ProgramUniform3f = (PFNGLPROGRAMUNIFORM3FPROC) load(userptr, "glProgramUniform3f"); + context->ProgramUniform3fv = (PFNGLPROGRAMUNIFORM3FVPROC) load(userptr, "glProgramUniform3fv"); + context->ProgramUniform3i = (PFNGLPROGRAMUNIFORM3IPROC) load(userptr, "glProgramUniform3i"); + context->ProgramUniform3iv = (PFNGLPROGRAMUNIFORM3IVPROC) load(userptr, "glProgramUniform3iv"); + context->ProgramUniform3ui = (PFNGLPROGRAMUNIFORM3UIPROC) load(userptr, "glProgramUniform3ui"); + context->ProgramUniform3uiv = (PFNGLPROGRAMUNIFORM3UIVPROC) load(userptr, "glProgramUniform3uiv"); + context->ProgramUniform4d = (PFNGLPROGRAMUNIFORM4DPROC) load(userptr, "glProgramUniform4d"); + context->ProgramUniform4dv = (PFNGLPROGRAMUNIFORM4DVPROC) load(userptr, "glProgramUniform4dv"); + context->ProgramUniform4f = (PFNGLPROGRAMUNIFORM4FPROC) load(userptr, "glProgramUniform4f"); + context->ProgramUniform4fv = (PFNGLPROGRAMUNIFORM4FVPROC) load(userptr, "glProgramUniform4fv"); + context->ProgramUniform4i = (PFNGLPROGRAMUNIFORM4IPROC) load(userptr, "glProgramUniform4i"); + context->ProgramUniform4iv = (PFNGLPROGRAMUNIFORM4IVPROC) load(userptr, "glProgramUniform4iv"); + context->ProgramUniform4ui = (PFNGLPROGRAMUNIFORM4UIPROC) load(userptr, "glProgramUniform4ui"); + context->ProgramUniform4uiv = (PFNGLPROGRAMUNIFORM4UIVPROC) load(userptr, "glProgramUniform4uiv"); + context->ProgramUniformMatrix2dv = (PFNGLPROGRAMUNIFORMMATRIX2DVPROC) load(userptr, "glProgramUniformMatrix2dv"); + context->ProgramUniformMatrix2fv = (PFNGLPROGRAMUNIFORMMATRIX2FVPROC) load(userptr, "glProgramUniformMatrix2fv"); + context->ProgramUniformMatrix2x3dv = (PFNGLPROGRAMUNIFORMMATRIX2X3DVPROC) load(userptr, "glProgramUniformMatrix2x3dv"); + context->ProgramUniformMatrix2x3fv = (PFNGLPROGRAMUNIFORMMATRIX2X3FVPROC) load(userptr, "glProgramUniformMatrix2x3fv"); + context->ProgramUniformMatrix2x4dv = (PFNGLPROGRAMUNIFORMMATRIX2X4DVPROC) load(userptr, "glProgramUniformMatrix2x4dv"); + context->ProgramUniformMatrix2x4fv = (PFNGLPROGRAMUNIFORMMATRIX2X4FVPROC) load(userptr, "glProgramUniformMatrix2x4fv"); + context->ProgramUniformMatrix3dv = (PFNGLPROGRAMUNIFORMMATRIX3DVPROC) load(userptr, "glProgramUniformMatrix3dv"); + context->ProgramUniformMatrix3fv = (PFNGLPROGRAMUNIFORMMATRIX3FVPROC) load(userptr, "glProgramUniformMatrix3fv"); + context->ProgramUniformMatrix3x2dv = (PFNGLPROGRAMUNIFORMMATRIX3X2DVPROC) load(userptr, "glProgramUniformMatrix3x2dv"); + context->ProgramUniformMatrix3x2fv = (PFNGLPROGRAMUNIFORMMATRIX3X2FVPROC) load(userptr, "glProgramUniformMatrix3x2fv"); + context->ProgramUniformMatrix3x4dv = (PFNGLPROGRAMUNIFORMMATRIX3X4DVPROC) load(userptr, "glProgramUniformMatrix3x4dv"); + context->ProgramUniformMatrix3x4fv = (PFNGLPROGRAMUNIFORMMATRIX3X4FVPROC) load(userptr, "glProgramUniformMatrix3x4fv"); + context->ProgramUniformMatrix4dv = (PFNGLPROGRAMUNIFORMMATRIX4DVPROC) load(userptr, "glProgramUniformMatrix4dv"); + context->ProgramUniformMatrix4fv = (PFNGLPROGRAMUNIFORMMATRIX4FVPROC) load(userptr, "glProgramUniformMatrix4fv"); + context->ProgramUniformMatrix4x2dv = (PFNGLPROGRAMUNIFORMMATRIX4X2DVPROC) load(userptr, "glProgramUniformMatrix4x2dv"); + context->ProgramUniformMatrix4x2fv = (PFNGLPROGRAMUNIFORMMATRIX4X2FVPROC) load(userptr, "glProgramUniformMatrix4x2fv"); + context->ProgramUniformMatrix4x3dv = (PFNGLPROGRAMUNIFORMMATRIX4X3DVPROC) load(userptr, "glProgramUniformMatrix4x3dv"); + context->ProgramUniformMatrix4x3fv = (PFNGLPROGRAMUNIFORMMATRIX4X3FVPROC) load(userptr, "glProgramUniformMatrix4x3fv"); + context->ReleaseShaderCompiler = (PFNGLRELEASESHADERCOMPILERPROC) load(userptr, "glReleaseShaderCompiler"); + context->ScissorArrayv = (PFNGLSCISSORARRAYVPROC) load(userptr, "glScissorArrayv"); + context->ScissorIndexed = (PFNGLSCISSORINDEXEDPROC) load(userptr, "glScissorIndexed"); + context->ScissorIndexedv = (PFNGLSCISSORINDEXEDVPROC) load(userptr, "glScissorIndexedv"); + context->ShaderBinary = (PFNGLSHADERBINARYPROC) load(userptr, "glShaderBinary"); + context->UseProgramStages = (PFNGLUSEPROGRAMSTAGESPROC) load(userptr, "glUseProgramStages"); + context->ValidateProgramPipeline = (PFNGLVALIDATEPROGRAMPIPELINEPROC) load(userptr, "glValidateProgramPipeline"); + context->VertexAttribL1d = (PFNGLVERTEXATTRIBL1DPROC) load(userptr, "glVertexAttribL1d"); + context->VertexAttribL1dv = (PFNGLVERTEXATTRIBL1DVPROC) load(userptr, "glVertexAttribL1dv"); + context->VertexAttribL2d = (PFNGLVERTEXATTRIBL2DPROC) load(userptr, "glVertexAttribL2d"); + context->VertexAttribL2dv = (PFNGLVERTEXATTRIBL2DVPROC) load(userptr, "glVertexAttribL2dv"); + context->VertexAttribL3d = (PFNGLVERTEXATTRIBL3DPROC) load(userptr, "glVertexAttribL3d"); + context->VertexAttribL3dv = (PFNGLVERTEXATTRIBL3DVPROC) load(userptr, "glVertexAttribL3dv"); + context->VertexAttribL4d = (PFNGLVERTEXATTRIBL4DPROC) load(userptr, "glVertexAttribL4d"); + context->VertexAttribL4dv = (PFNGLVERTEXATTRIBL4DVPROC) load(userptr, "glVertexAttribL4dv"); + context->VertexAttribLPointer = (PFNGLVERTEXATTRIBLPOINTERPROC) load(userptr, "glVertexAttribLPointer"); + context->ViewportArrayv = (PFNGLVIEWPORTARRAYVPROC) load(userptr, "glViewportArrayv"); + context->ViewportIndexedf = (PFNGLVIEWPORTINDEXEDFPROC) load(userptr, "glViewportIndexedf"); + context->ViewportIndexedfv = (PFNGLVIEWPORTINDEXEDFVPROC) load(userptr, "glViewportIndexedfv"); +} +static void glad_gl_load_GL_VERSION_4_2(GladGLContext *context, GLADuserptrloadfunc load, void* userptr) { + if(!context->VERSION_4_2) return; + context->BindImageTexture = (PFNGLBINDIMAGETEXTUREPROC) load(userptr, "glBindImageTexture"); + context->DrawArraysInstancedBaseInstance = (PFNGLDRAWARRAYSINSTANCEDBASEINSTANCEPROC) load(userptr, "glDrawArraysInstancedBaseInstance"); + context->DrawElementsInstancedBaseInstance = (PFNGLDRAWELEMENTSINSTANCEDBASEINSTANCEPROC) load(userptr, "glDrawElementsInstancedBaseInstance"); + context->DrawElementsInstancedBaseVertexBaseInstance = (PFNGLDRAWELEMENTSINSTANCEDBASEVERTEXBASEINSTANCEPROC) load(userptr, "glDrawElementsInstancedBaseVertexBaseInstance"); + context->DrawTransformFeedbackInstanced = (PFNGLDRAWTRANSFORMFEEDBACKINSTANCEDPROC) load(userptr, "glDrawTransformFeedbackInstanced"); + context->DrawTransformFeedbackStreamInstanced = (PFNGLDRAWTRANSFORMFEEDBACKSTREAMINSTANCEDPROC) load(userptr, "glDrawTransformFeedbackStreamInstanced"); + context->GetActiveAtomicCounterBufferiv = (PFNGLGETACTIVEATOMICCOUNTERBUFFERIVPROC) load(userptr, "glGetActiveAtomicCounterBufferiv"); + context->GetInternalformativ = (PFNGLGETINTERNALFORMATIVPROC) load(userptr, "glGetInternalformativ"); + context->MemoryBarrier = (PFNGLMEMORYBARRIERPROC) load(userptr, "glMemoryBarrier"); + context->TexStorage1D = (PFNGLTEXSTORAGE1DPROC) load(userptr, "glTexStorage1D"); + context->TexStorage2D = (PFNGLTEXSTORAGE2DPROC) load(userptr, "glTexStorage2D"); + context->TexStorage3D = (PFNGLTEXSTORAGE3DPROC) load(userptr, "glTexStorage3D"); +} +static void glad_gl_load_GL_VERSION_4_3(GladGLContext *context, GLADuserptrloadfunc load, void* userptr) { + if(!context->VERSION_4_3) return; + context->BindVertexBuffer = (PFNGLBINDVERTEXBUFFERPROC) load(userptr, "glBindVertexBuffer"); + context->ClearBufferData = (PFNGLCLEARBUFFERDATAPROC) load(userptr, "glClearBufferData"); + context->ClearBufferSubData = (PFNGLCLEARBUFFERSUBDATAPROC) load(userptr, "glClearBufferSubData"); + context->CopyImageSubData = (PFNGLCOPYIMAGESUBDATAPROC) load(userptr, "glCopyImageSubData"); + context->DebugMessageCallback = (PFNGLDEBUGMESSAGECALLBACKPROC) load(userptr, "glDebugMessageCallback"); + context->DebugMessageControl = (PFNGLDEBUGMESSAGECONTROLPROC) load(userptr, "glDebugMessageControl"); + context->DebugMessageInsert = (PFNGLDEBUGMESSAGEINSERTPROC) load(userptr, "glDebugMessageInsert"); + context->DispatchCompute = (PFNGLDISPATCHCOMPUTEPROC) load(userptr, "glDispatchCompute"); + context->DispatchComputeIndirect = (PFNGLDISPATCHCOMPUTEINDIRECTPROC) load(userptr, "glDispatchComputeIndirect"); + context->FramebufferParameteri = (PFNGLFRAMEBUFFERPARAMETERIPROC) load(userptr, "glFramebufferParameteri"); + context->GetDebugMessageLog = (PFNGLGETDEBUGMESSAGELOGPROC) load(userptr, "glGetDebugMessageLog"); + context->GetFramebufferParameteriv = (PFNGLGETFRAMEBUFFERPARAMETERIVPROC) load(userptr, "glGetFramebufferParameteriv"); + context->GetInternalformati64v = (PFNGLGETINTERNALFORMATI64VPROC) load(userptr, "glGetInternalformati64v"); + context->GetObjectLabel = (PFNGLGETOBJECTLABELPROC) load(userptr, "glGetObjectLabel"); + context->GetObjectPtrLabel = (PFNGLGETOBJECTPTRLABELPROC) load(userptr, "glGetObjectPtrLabel"); + context->GetPointerv = (PFNGLGETPOINTERVPROC) load(userptr, "glGetPointerv"); + context->GetProgramInterfaceiv = (PFNGLGETPROGRAMINTERFACEIVPROC) load(userptr, "glGetProgramInterfaceiv"); + context->GetProgramResourceIndex = (PFNGLGETPROGRAMRESOURCEINDEXPROC) load(userptr, "glGetProgramResourceIndex"); + context->GetProgramResourceLocation = (PFNGLGETPROGRAMRESOURCELOCATIONPROC) load(userptr, "glGetProgramResourceLocation"); + context->GetProgramResourceLocationIndex = (PFNGLGETPROGRAMRESOURCELOCATIONINDEXPROC) load(userptr, "glGetProgramResourceLocationIndex"); + context->GetProgramResourceName = (PFNGLGETPROGRAMRESOURCENAMEPROC) load(userptr, "glGetProgramResourceName"); + context->GetProgramResourceiv = (PFNGLGETPROGRAMRESOURCEIVPROC) load(userptr, "glGetProgramResourceiv"); + context->InvalidateBufferData = (PFNGLINVALIDATEBUFFERDATAPROC) load(userptr, "glInvalidateBufferData"); + context->InvalidateBufferSubData = (PFNGLINVALIDATEBUFFERSUBDATAPROC) load(userptr, "glInvalidateBufferSubData"); + context->InvalidateFramebuffer = (PFNGLINVALIDATEFRAMEBUFFERPROC) load(userptr, "glInvalidateFramebuffer"); + context->InvalidateSubFramebuffer = (PFNGLINVALIDATESUBFRAMEBUFFERPROC) load(userptr, "glInvalidateSubFramebuffer"); + context->InvalidateTexImage = (PFNGLINVALIDATETEXIMAGEPROC) load(userptr, "glInvalidateTexImage"); + context->InvalidateTexSubImage = (PFNGLINVALIDATETEXSUBIMAGEPROC) load(userptr, "glInvalidateTexSubImage"); + context->MultiDrawArraysIndirect = (PFNGLMULTIDRAWARRAYSINDIRECTPROC) load(userptr, "glMultiDrawArraysIndirect"); + context->MultiDrawElementsIndirect = (PFNGLMULTIDRAWELEMENTSINDIRECTPROC) load(userptr, "glMultiDrawElementsIndirect"); + context->ObjectLabel = (PFNGLOBJECTLABELPROC) load(userptr, "glObjectLabel"); + context->ObjectPtrLabel = (PFNGLOBJECTPTRLABELPROC) load(userptr, "glObjectPtrLabel"); + context->PopDebugGroup = (PFNGLPOPDEBUGGROUPPROC) load(userptr, "glPopDebugGroup"); + context->PushDebugGroup = (PFNGLPUSHDEBUGGROUPPROC) load(userptr, "glPushDebugGroup"); + context->ShaderStorageBlockBinding = (PFNGLSHADERSTORAGEBLOCKBINDINGPROC) load(userptr, "glShaderStorageBlockBinding"); + context->TexBufferRange = (PFNGLTEXBUFFERRANGEPROC) load(userptr, "glTexBufferRange"); + context->TexStorage2DMultisample = (PFNGLTEXSTORAGE2DMULTISAMPLEPROC) load(userptr, "glTexStorage2DMultisample"); + context->TexStorage3DMultisample = (PFNGLTEXSTORAGE3DMULTISAMPLEPROC) load(userptr, "glTexStorage3DMultisample"); + context->TextureView = (PFNGLTEXTUREVIEWPROC) load(userptr, "glTextureView"); + context->VertexAttribBinding = (PFNGLVERTEXATTRIBBINDINGPROC) load(userptr, "glVertexAttribBinding"); + context->VertexAttribFormat = (PFNGLVERTEXATTRIBFORMATPROC) load(userptr, "glVertexAttribFormat"); + context->VertexAttribIFormat = (PFNGLVERTEXATTRIBIFORMATPROC) load(userptr, "glVertexAttribIFormat"); + context->VertexAttribLFormat = (PFNGLVERTEXATTRIBLFORMATPROC) load(userptr, "glVertexAttribLFormat"); + context->VertexBindingDivisor = (PFNGLVERTEXBINDINGDIVISORPROC) load(userptr, "glVertexBindingDivisor"); +} -#if defined(GL_ES_VERSION_3_0) || defined(GL_VERSION_3_0) -#define GLAD_GL_IS_SOME_NEW_VERSION 1 -#else -#define GLAD_GL_IS_SOME_NEW_VERSION 0 -#endif - -static int glad_gl_get_extensions(GladGLContext *context, int version, const char **out_exts, unsigned int *out_num_exts_i, char ***out_exts_i) { -#if GLAD_GL_IS_SOME_NEW_VERSION - if(GLAD_VERSION_MAJOR(version) < 3) { -#else - GLAD_UNUSED(version); - GLAD_UNUSED(out_num_exts_i); - GLAD_UNUSED(out_exts_i); -#endif - if (context->GetString == NULL) { - return 0; +static void glad_gl_free_extensions(char **exts_i) { + if (exts_i != NULL) { + unsigned int index; + for(index = 0; exts_i[index]; index++) { + free((void *) (exts_i[index])); } - *out_exts = (const char *)context->GetString(GL_EXTENSIONS); -#if GLAD_GL_IS_SOME_NEW_VERSION - } else { + free((void *)exts_i); + exts_i = NULL; + } +} +static int glad_gl_get_extensions(GladGLContext *context, const char **out_exts, char ***out_exts_i) { +#if defined(GL_ES_VERSION_3_0) || defined(GL_VERSION_3_0) + if (context->GetStringi != NULL && context->GetIntegerv != NULL) { unsigned int index = 0; unsigned int num_exts_i = 0; char **exts_i = NULL; - if (context->GetStringi == NULL || context->GetIntegerv == NULL) { - return 0; - } context->GetIntegerv(GL_NUM_EXTENSIONS, (int*) &num_exts_i); - if (num_exts_i > 0) { - exts_i = (char **) malloc(num_exts_i * (sizeof *exts_i)); - } + exts_i = (char **) malloc((num_exts_i + 1) * (sizeof *exts_i)); if (exts_i == NULL) { return 0; } @@ -452,31 +643,40 @@ static int glad_gl_get_extensions(GladGLContext *context, int version, const cha size_t len = strlen(gl_str_tmp) + 1; char *local_str = (char*) malloc(len * sizeof(char)); - if(local_str != NULL) { - memcpy(local_str, gl_str_tmp, len * sizeof(char)); + if(local_str == NULL) { + exts_i[index] = NULL; + glad_gl_free_extensions(exts_i); + return 0; } + memcpy(local_str, gl_str_tmp, len * sizeof(char)); exts_i[index] = local_str; } + exts_i[index] = NULL; - *out_num_exts_i = num_exts_i; *out_exts_i = exts_i; + + return 1; } +#else + GLAD_UNUSED(out_exts_i); #endif + if (context->GetString == NULL) { + return 0; + } + *out_exts = (const char *)context->GetString(GL_EXTENSIONS); return 1; } -static void glad_gl_free_extensions(char **exts_i, unsigned int num_exts_i) { - if (exts_i != NULL) { +static int glad_gl_has_extension(const char *exts, char **exts_i, const char *ext) { + if(exts_i) { unsigned int index; - for(index = 0; index < num_exts_i; index++) { - free((void *) (exts_i[index])); + for(index = 0; exts_i[index]; index++) { + const char *e = exts_i[index]; + if(strcmp(e, ext) == 0) { + return 1; + } } - free((void *)exts_i); - exts_i = NULL; - } -} -static int glad_gl_has_extension(int version, const char *exts, unsigned int num_exts_i, char **exts_i, const char *ext) { - if(GLAD_VERSION_MAJOR(version) < 3 || !GLAD_GL_IS_SOME_NEW_VERSION) { + } else { const char *extensions; const char *loc; const char *terminator; @@ -496,14 +696,6 @@ static int glad_gl_has_extension(int version, const char *exts, unsigned int num } extensions = terminator; } - } else { - unsigned int index; - for(index = 0; index < num_exts_i; index++) { - const char *e = exts_i[index]; - if(strcmp(e, ext) == 0) { - return 1; - } - } } return 0; } @@ -512,15 +704,14 @@ static GLADapiproc glad_gl_get_proc_from_userptr(void *userptr, const char* name return (GLAD_GNUC_EXTENSION (GLADapiproc (*)(const char *name)) userptr)(name); } -static int glad_gl_find_extensions_gl(GladGLContext *context, int version) { +static int glad_gl_find_extensions_gl(GladGLContext *context) { const char *exts = NULL; - unsigned int num_exts_i = 0; char **exts_i = NULL; - if (!glad_gl_get_extensions(context, version, &exts, &num_exts_i, &exts_i)) return 0; + if (!glad_gl_get_extensions(context, &exts, &exts_i)) return 0; - GLAD_UNUSED(glad_gl_has_extension); + GLAD_UNUSED(&glad_gl_has_extension); - glad_gl_free_extensions(exts_i, num_exts_i); + glad_gl_free_extensions(exts_i); return 1; } @@ -561,6 +752,10 @@ static int glad_gl_find_core_gl(GladGLContext *context) { context->VERSION_3_1 = (major == 3 && minor >= 1) || major > 3; context->VERSION_3_2 = (major == 3 && minor >= 2) || major > 3; context->VERSION_3_3 = (major == 3 && minor >= 3) || major > 3; + context->VERSION_4_0 = (major == 4 && minor >= 0) || major > 4; + context->VERSION_4_1 = (major == 4 && minor >= 1) || major > 4; + context->VERSION_4_2 = (major == 4 && minor >= 2) || major > 4; + context->VERSION_4_3 = (major == 4 && minor >= 3) || major > 4; return GLAD_MAKE_VERSION(major, minor); } @@ -570,7 +765,6 @@ int gladLoadGLContextUserPtr(GladGLContext *context, GLADuserptrloadfunc load, v context->GetString = (PFNGLGETSTRINGPROC) load(userptr, "glGetString"); if(context->GetString == NULL) return 0; - if(context->GetString(GL_VERSION) == NULL) return 0; version = glad_gl_find_core_gl(context); glad_gl_load_GL_VERSION_1_0(context, load, userptr); @@ -585,8 +779,12 @@ int gladLoadGLContextUserPtr(GladGLContext *context, GLADuserptrloadfunc load, v glad_gl_load_GL_VERSION_3_1(context, load, userptr); glad_gl_load_GL_VERSION_3_2(context, load, userptr); glad_gl_load_GL_VERSION_3_3(context, load, userptr); + glad_gl_load_GL_VERSION_4_0(context, load, userptr); + glad_gl_load_GL_VERSION_4_1(context, load, userptr); + glad_gl_load_GL_VERSION_4_2(context, load, userptr); + glad_gl_load_GL_VERSION_4_3(context, load, userptr); - if (!glad_gl_find_extensions_gl(context, version)) return 0; + if (!glad_gl_find_extensions_gl(context)) return 0;