diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 0e376c780..179b5f222 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -19,11 +19,13 @@ # discussion by the author. Maintainers can denounce users by commenting # "!denounce" or "!denounce [username]" on a discussion. 00-kat +aalhendi abudvytis aindriu80 alanmoyano alexfeijoo44 andrejdaskalov +atomk balazs-szucs bennettp123 benodiwal @@ -36,6 +38,7 @@ brentschroeter charliie-dev chernetskyi craziestowl +curtismoncoq d-dudas daiimus damyanbogoev @@ -60,6 +63,7 @@ jacobsandlund jake-stewart jcollie johnslavik +josephmart jparise juniqlim kawarimidoll @@ -68,8 +72,10 @@ khipp kirwiisp kjvdven kloneets +koranir kristina8888 kristofersoler +laxystem liby lonsagisawa mahnokropotkinvich @@ -81,7 +87,10 @@ mikailmm misairuzame mitchellh miupa +mrmage mtak +natesmyth +neo773 nicosuave nwehg oshdubh @@ -94,9 +103,9 @@ piedrahitac pluiedev pouwerkerk priyans-hu -prsweet qwerasd205 reo101 +rgehan rmengelbrecht rmunn rockorager @@ -104,6 +113,7 @@ rpfaeffle secrus silveirapf slsrepo +sunshine-syz tdslot ticclick tnagatomi diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index d9f73197d..4cf128d43 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -130,10 +130,20 @@ jobs: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + - uses: DeterminateSystems/nix-installer-action@main with: determinate: true @@ -174,7 +184,9 @@ jobs: - name: Build Ghostty.app run: | cd macos - xcodebuild -target Ghostty -configuration Release + xcodebuild -target Ghostty -configuration Release \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # Add all our metadata to Info.plist so we can reference it later. - name: Update Info.plist @@ -219,6 +231,7 @@ jobs: /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/PlugIns/DockTilePlugin.plugin" # Codesign the app bundle /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 3eb4296f7..fb26e964e 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -37,6 +37,11 @@ jobs: with: # Important so that build number generation works fetch-depth: 0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix - uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1 with: nix_path: nixpkgs=channel:nixos-unstable @@ -219,6 +224,8 @@ jobs: GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -226,6 +233,14 @@ jobs: # Important so that build number generation works fetch-depth: 0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -263,7 +278,9 @@ jobs: - name: Build Ghostty.app run: | cd macos - xcodebuild -target Ghostty -configuration Release + xcodebuild -target Ghostty -configuration Release \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. @@ -309,6 +326,7 @@ jobs: /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/PlugIns/DockTilePlugin.plugin" # Codesign the app bundle /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app @@ -462,6 +480,8 @@ jobs: GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -469,6 +489,14 @@ jobs: # Important so that build number generation works fetch-depth: 0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -506,7 +534,9 @@ jobs: - name: Build Ghostty.app run: | cd macos - xcodebuild -target Ghostty -configuration Release + xcodebuild -target Ghostty -configuration Release \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. @@ -552,6 +582,7 @@ jobs: /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/PlugIns/DockTilePlugin.plugin" # Codesign the app bundle /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app @@ -646,6 +677,8 @@ jobs: GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -653,6 +686,14 @@ jobs: # Important so that build number generation works fetch-depth: 0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -690,7 +731,9 @@ jobs: - name: Build Ghostty.app run: | cd macos - xcodebuild -target Ghostty -configuration Release + xcodebuild -target Ghostty -configuration Release \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. @@ -736,6 +779,7 @@ jobs: /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/PlugIns/DockTilePlugin.plugin" # Codesign the app bundle /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf171d42a..ff085ef81 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,22 +22,29 @@ jobs: # signaling that all other jobs can be skipped entirely. skip: ${{ steps.determine.outputs.skip }} # Path-based filters to gate specific linter/formatter jobs. - actions_pins: ${{ steps.filter.outputs.actions_pins }} - blueprints: ${{ steps.filter.outputs.blueprints }} - macos: ${{ steps.filter.outputs.macos }} - nix: ${{ steps.filter.outputs.nix }} - shell: ${{ steps.filter.outputs.shell }} - zig: ${{ steps.filter.outputs.zig }} + actions_pins: ${{ steps.filter_any.outputs.actions_pins }} + blueprints: ${{ steps.filter_any.outputs.blueprints }} + macos: ${{ steps.filter_any.outputs.macos }} + nix: ${{ steps.filter_any.outputs.nix }} + shell: ${{ steps.filter_any.outputs.shell }} + zig: ${{ steps.filter_any.outputs.zig }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - id: filter + id: filter_every with: + token: ${{ secrets.GITHUB_TOKEN }} + predicate-quantifier: "every" filters: | code: - '**' - '!.github/VOUCHED.td' + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter_any + with: + token: ${{ secrets.GITHUB_TOKEN }} + filters: | macos: - '.swiftlint.yml' - 'macos/**' @@ -64,7 +71,7 @@ jobs: - id: determine name: Determine skip run: | - if [ "${{ steps.filter.outputs.code }}" = "false" ]; then + if [ "${{ steps.filter_every.outputs.code }}" = "false" ]; then echo "skip=true" >> "$GITHUB_OUTPUT" else echo "skip=false" >> "$GITHUB_OUTPUT" @@ -81,6 +88,7 @@ jobs: - build-examples - build-flatpak - build-libghostty-vt + - build-libghostty-vt-android - build-libghostty-vt-macos - build-linux - build-linux-libghostty @@ -322,10 +330,21 @@ jobs: target: [aarch64-macos, x86_64-macos, aarch64-ios] runs-on: namespace-profile-ghostty-macos-tahoe needs: test + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -343,6 +362,54 @@ jobs: nix develop -c zig build lib-vt \ -Dtarget=${{ matrix.target }} + # lib-vt requires the Android NDK for Android builds + build-libghostty-vt-android: + strategy: + matrix: + target: + [aarch64-linux-android, x86_64-linux-android, arm-linux-androideabi] + runs-on: namespace-profile-ghostty-sm + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + ANDROID_NDK_VERSION: r29 + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Setup Android NDK + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1.6.0 + id: setup-ndk + with: + ndk-version: r29 + add-to-path: false + link-to-sdk: false + local-cache: true + + - name: Build + run: | + nix develop -c zig build lib-vt \ + -Dtarget=${{ matrix.target }} + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + build-linux: strategy: fail-fast: false @@ -534,10 +601,21 @@ jobs: build-macos: runs-on: namespace-profile-ghostty-macos-tahoe needs: test + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -566,21 +644,38 @@ jobs: # 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 + run: | + cd macos + xcodebuild -target Ghostty \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # Build the iOS target without code signing just to verify it works. - name: Build Ghostty iOS run: | cd macos - xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" + xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES build-macos-freetype: runs-on: namespace-profile-ghostty-macos-tahoe needs: test + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -843,10 +938,21 @@ jobs: test-macos: runs-on: namespace-profile-ghostty-macos-tahoe needs: test + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -1001,6 +1107,14 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -1204,7 +1318,7 @@ jobs: needs: [test, build-dist] steps: - name: Install and configure Namespace CLI - uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10 + uses: namespacelabs/nscloud-setup@f378676225212387f1283f4da878712af2c4cd60 # v0.0.11 - name: Configure Namespace powered Buildx uses: namespacelabs/nscloud-setup-buildx-action@f5814dcf37a16cce0624d5bec2ab879654294aa0 # v0.0.22 diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml index 60c56fe8f..3fa3bb542 100644 --- a/.github/workflows/vouch-check-issue.yml +++ b/.github/workflows/vouch-check-issue.yml @@ -14,7 +14,7 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: mitchellh/vouch/action/check-issue@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/check-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: issue-number: ${{ github.event.issue.number }} auto-close: true diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml index aaf9176b3..0efb6208c 100644 --- a/.github/workflows/vouch-check-pr.yml +++ b/.github/workflows/vouch-check-pr.yml @@ -14,7 +14,7 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: mitchellh/vouch/action/check-pr@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: pr-number: ${{ github.event.pull_request.number }} auto-close: true diff --git a/.github/workflows/vouch-manage-by-discussion.yml b/.github/workflows/vouch-manage-by-discussion.yml index 93e7a1343..cf7c092e2 100644 --- a/.github/workflows/vouch-manage-by-discussion.yml +++ b/.github/workflows/vouch-manage-by-discussion.yml @@ -22,7 +22,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-discussion@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/manage-by-discussion@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: discussion-number: ${{ github.event.discussion.number }} comment-node-id: ${{ github.event.comment.node_id }} diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml index acea8f4fd..6f85520bd 100644 --- a/.github/workflows/vouch-manage-by-issue.yml +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -22,7 +22,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-issue@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: repo: ${{ github.repository }} issue-id: ${{ github.event.issue.number }} diff --git a/.github/workflows/vouch-sync-codeowners.yml b/.github/workflows/vouch-sync-codeowners.yml index fe1977a66..fac06a372 100644 --- a/.github/workflows/vouch-sync-codeowners.yml +++ b/.github/workflows/vouch-sync-codeowners.yml @@ -23,7 +23,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/sync-codeowners@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/sync-codeowners@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: repo: ${{ github.repository }} pull-request: "true" diff --git a/.gitignore b/.gitignore index e521f8851..40a04dbae 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ zig-cache/ .zig-cache/ zig-out/ +/build.zig.zon.bak /result* /.nixos-test-history example/*.wasm diff --git a/build.zig.zon b/build.zig.zon index fad3500d5..e7a8747f7 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -21,7 +21,7 @@ }, .z2d = .{ // vancluever/z2d - .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz", + .url = "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz", .hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ", .lazy = true, }, @@ -39,7 +39,7 @@ }, .uucode = .{ // jacobsandlund/uucode - .url = "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz", + .url = "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz", .hash = "uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9", }, .zig_wayland = .{ @@ -115,9 +115,10 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, + .android_ndk = .{ .path = "./pkg/android-ndk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz", - .hash = "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz", + .hash = "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF", .lazy = true, }, }, diff --git a/build.zig.zon.bak b/build.zig.zon.bak deleted file mode 100644 index 191ae7fa9..000000000 --- a/build.zig.zon.bak +++ /dev/null @@ -1,124 +0,0 @@ -.{ - .name = .ghostty, - .version = "1.3.0-dev", - .paths = .{""}, - .fingerprint = 0x64407a2a0b4147e5, - .minimum_zig_version = "0.15.2", - .dependencies = .{ - // Zig libs - - .libxev = .{ - // mitchellh/libxev - .url = "https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz", - .hash = "libxev-0.0.0-86vtc4IcEwCqEYxEYoN_3KXmc6A9VLcm22aVImfvecYs", - .lazy = true, - }, - .vaxis = .{ - // rockorager/libvaxis - .url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", - .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", - .lazy = true, - }, - .z2d = .{ - // vancluever/z2d - .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", - .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", - .lazy = true, - }, - .zig_objc = .{ - // mitchellh/zig-objc - .url = "https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz", - .hash = "zig_objc-0.0.0-Ir_Sp5gTAQCvxxR7oVIrPXxXwsfKgVP7_wqoOQrZjFeK", - .lazy = true, - }, - .zig_js = .{ - // mitchellh/zig-js - .url = "https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz", - .hash = "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi", - .lazy = true, - }, - .uucode = .{ - // jacobsandlund/uucode - .url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", - .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", - }, - .zig_wayland = .{ - // codeberg ifreund/zig-wayland - .url = "https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz", - .hash = "wayland-0.5.0-dev-lQa1khrMAQDJDwYFKpdH3HizherB7sHo5dKMECfvxQHe", - .lazy = true, - }, - .zf = .{ - // natecraddock/zf - .url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", - .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", - .lazy = true, - }, - .gobject = .{ - // https://github.com/ghostty-org/zig-gobject based on zig_gobject - // Temporary until we generate them at build time automatically. - .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", - .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", - .lazy = true, - }, - - // C libs - .cimgui = .{ .path = "./pkg/cimgui", .lazy = true }, - .fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true }, - .freetype = .{ .path = "./pkg/freetype", .lazy = true }, - .gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true }, - .harfbuzz = .{ .path = "./pkg/harfbuzz", .lazy = true }, - .highway = .{ .path = "./pkg/highway", .lazy = true }, - .libintl = .{ .path = "./pkg/libintl", .lazy = true }, - .libpng = .{ .path = "./pkg/libpng", .lazy = true }, - .macos = .{ .path = "./pkg/macos", .lazy = true }, - .oniguruma = .{ .path = "./pkg/oniguruma", .lazy = true }, - .opengl = .{ .path = "./pkg/opengl", .lazy = true }, - .sentry = .{ .path = "./pkg/sentry", .lazy = true }, - .simdutf = .{ .path = "./pkg/simdutf", .lazy = true }, - .utfcpp = .{ .path = "./pkg/utfcpp", .lazy = true }, - .wuffs = .{ .path = "./pkg/wuffs", .lazy = true }, - .zlib = .{ .path = "./pkg/zlib", .lazy = true }, - - // Shader translation - .glslang = .{ .path = "./pkg/glslang", .lazy = true }, - .spirv_cross = .{ .path = "./pkg/spirv-cross", .lazy = true }, - - // Wayland - .wayland = .{ - .url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz", - .hash = "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t", - .lazy = true, - }, - .wayland_protocols = .{ - .url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz", - .hash = "N-V-__8AAKw-DAAaV8bOAAGqA0-oD7o-HNIlPFYKRXSPT03S", - .lazy = true, - }, - .plasma_wayland_protocols = .{ - .url = "https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566.tar.gz", - .hash = "N-V-__8AAKYZBAB-CFHBKs3u4JkeiT4BMvyHu3Y5aaWF3Bbs", - .lazy = true, - }, - - // Fonts - .jetbrains_mono = .{ - .url = "https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz", - .hash = "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x", - .lazy = true, - }, - .nerd_fonts_symbols_only = .{ - .url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz", - .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s", - .lazy = true, - }, - - // Other - .apple_sdk = .{ .path = "./pkg/apple-sdk" }, - .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", - .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", - .lazy = true, - }, - }, -} diff --git a/build.zig.zon.json b/build.zig.zon.json index b30fdeddb..4a88e2017 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z": { + "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz", - "hash": "sha256-xN+3iQaN3uIJ/BzkgFxLojgHqeuz1htNcVjcjWR7Qjg=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz", + "hash": "sha256-FCALuGoMgUq2lgnVALKAs5a20uuDXt8Gdt5KeJwKqP0=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", @@ -121,7 +121,7 @@ }, "uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9": { "name": "uucode", - "url": "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz", + "url": "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz", "hash": "sha256-0KvuD0+L1urjwFF3fhbnxC2JZKqqAVWRxOVlcD9GX5U=" }, "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { @@ -146,7 +146,7 @@ }, "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ": { "name": "z2d", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz", "hash": "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c=" }, "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": { diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 65c8c555b..53e1b6c02 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -171,11 +171,11 @@ in }; } { - name = "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z"; + name = "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz"; - hash = "sha256-xN+3iQaN3uIJ/BzkgFxLojgHqeuz1htNcVjcjWR7Qjg="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz"; + hash = "sha256-FCALuGoMgUq2lgnVALKAs5a20uuDXt8Gdt5KeJwKqP0="; }; } { @@ -278,7 +278,7 @@ in name = "uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9"; path = fetchZigArtifact { name = "uucode"; - url = "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz"; + url = "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz"; hash = "sha256-0KvuD0+L1urjwFF3fhbnxC2JZKqqAVWRxOVlcD9GX5U="; }; } @@ -318,7 +318,7 @@ in name = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ"; path = fetchZigArtifact { name = "z2d"; - url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz"; + url = "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz"; hash = "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c="; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 1477ff010..4ac9e6592 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz -https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz @@ -21,16 +21,16 @@ https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e.tar.gz https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz +https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz +https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz -https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz -https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz diff --git a/flake.nix b/flake.nix index d892dbd2f..e063f2d70 100644 --- a/flake.nix +++ b/flake.nix @@ -80,6 +80,7 @@ packageOverrides = pyfinal: pyprev: { blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {}; ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {}; + wcwidth = pyfinal.callPackage ./nix/pkgs/wcwidth.nix {}; }; }; }; diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 38a7e6dbd..e58ecd448 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz", - "dest": "vendor/p/N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z", - "sha256": "c4dfb789068ddee209fc1ce4805c4ba23807a9ebb3d61b4d7158dc8d647b4238" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz", + "dest": "vendor/p/N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF", + "sha256": "14200bb86a0c814ab69609d500b280b396b6d2eb835edf0676de4a789c0aa8fd" }, { "type": "archive", @@ -145,7 +145,7 @@ }, { "type": "archive", - "url": "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz", + "url": "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz", "dest": "vendor/p/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9", "sha256": "d0abee0f4f8bd6eae3c051777e16e7c42d8964aaaa015591c4e565703f465f95" }, @@ -175,7 +175,7 @@ }, { "type": "archive", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz", "dest": "vendor/p/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ", "sha256": "69f21da2efd5ee0937fe55c4d09e48afc4fb2f91a01ef167c8c275ae046797f7" }, diff --git a/macos/.swiftlint.yml b/macos/.swiftlint.yml index 4cbaf0fac..d2b371cc1 100644 --- a/macos/.swiftlint.yml +++ b/macos/.swiftlint.yml @@ -11,18 +11,12 @@ disabled_rules: - function_body_length - line_length - nesting + - no_fallthrough_only - todo - trailing_comma - trailing_newline - type_body_length - # TODO - - for_where - - force_cast - - multiple_closures_with_trailing_closure - - no_fallthrough_only - - switch_case_alignment - identifier_name: min_length: 1 allowed_symbols: ["_"] diff --git a/macos/AGENTS.md b/macos/AGENTS.md new file mode 100644 index 000000000..6321808b8 --- /dev/null +++ b/macos/AGENTS.md @@ -0,0 +1,3 @@ +# macOS Ghostty Application + +- Use `swiftlint` for formatting and linting Swift code. diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index 5960dc0e7..4896681b9 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -2,6 +2,8 @@ + NSDockTilePlugIn + DockTilePlugin.plugin CFBundleDocumentTypes diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 49d8132e8..8871343c3 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 29C15B1D2CDC3B2900520DD4 /* bat in Resources */ = {isa = PBXBuildFile; fileRef = 29C15B1C2CDC3B2000520DD4 /* bat */; }; 55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; }; 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; + 819324582F24E78800A9ED8F /* DockTilePlugin.plugin in Copy DockTilePlugin */ = {isa = PBXBuildFile; fileRef = 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 819324642F24FF2100A9ED8F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; A51BFC272B30F1B800E92F16 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A51BFC262B30F1B800E92F16 /* Sparkle */; }; A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; @@ -35,6 +37,13 @@ remoteGlobalIDString = A5B30530299BEAAA0047F10C; remoteInfo = Ghostty; }; + 819324672F2502FB00A9ED8F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A5B30529299BEAAA0047F10C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8193244C2F24E6C000A9ED8F; + remoteInfo = DockTilePlugin; + }; A54F45F72E1F047A0046BD5C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = A5B30529299BEAAA0047F10C /* Project object */; @@ -44,12 +53,27 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 819324572F24E74E00A9ED8F /* Copy DockTilePlugin */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 819324582F24E78800A9ED8F /* DockTilePlugin.plugin in Copy DockTilePlugin */, + ); + name = "Copy DockTilePlugin"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 29C15B1C2CDC3B2000520DD4 /* bat */ = {isa = PBXFileReference; lastKnownFileType = folder; name = bat; path = "../zig-out/share/bat"; sourceTree = ""; }; 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = ""; }; 55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = ""; }; 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DockTilePlugin.plugin; sourceTree = BUILT_PRODUCTS_DIR; }; 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = ""; }; A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = ""; }; @@ -70,6 +94,22 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "Features/Custom App Icon/AppIcon.swift", + "Features/Custom App Icon/ColorizedGhosttyIcon.swift", + "Features/Custom App Icon/DockTilePlugin.swift", + "Features/Custom App Icon/Extensions/Notification+AppIcon.swift", + "Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift", + Ghostty/Ghostty.ConfigTypes.swift, + Ghostty/GhosttyPackageMeta.swift, + Helpers/CrossKit.swift, + "Helpers/Extensions/NSImage+Extension.swift", + "Helpers/Extensions/OSColor+Extension.swift", + ); + target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */; + }; 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -80,6 +120,7 @@ Features/About/About.xib, Features/About/AboutController.swift, Features/About/AboutView.swift, + Features/About/AboutViewModel.swift, Features/About/CyclingIconView.swift, "Features/App Intents/CloseTerminalIntent.swift", "Features/App Intents/CommandPaletteIntent.swift", @@ -96,11 +137,15 @@ Features/ClipboardConfirmation/ClipboardConfirmation.xib, Features/ClipboardConfirmation/ClipboardConfirmationController.swift, Features/ClipboardConfirmation/ClipboardConfirmationView.swift, - "Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift", - "Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift", - "Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift", "Features/Command Palette/CommandPalette.swift", "Features/Command Palette/TerminalCommandPalette.swift", + "Features/Custom App Icon/AppIcon.swift", + "Features/Custom App Icon/ColorizedGhosttyIcon.swift", + "Features/Custom App Icon/ColorizedGhosttyIconImage.swift", + "Features/Custom App Icon/ColorizedGhosttyIconView.swift", + "Features/Custom App Icon/DockTilePlugin.swift", + "Features/Custom App Icon/Extensions/Notification+AppIcon.swift", + "Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift", "Features/Global Keybinds/GlobalEventTap.swift", Features/QuickTerminal/QuickTerminal.xib, Features/QuickTerminal/QuickTerminalController.swift, @@ -190,6 +235,7 @@ Helpers/Private/CGS.swift, Helpers/Private/Dock.swift, Helpers/TabGroupCloseCoordinator.swift, + Helpers/TabTitleEditor.swift, Helpers/VibrantLayer.m, Helpers/Weak.swift, ); @@ -199,6 +245,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( App/iOS/iOSApp.swift, + "Features/Custom App Icon/DockTilePlugin.swift", "Ghostty/Surface View/SurfaceView_UIKit.swift", ); target = A5B30530299BEAAA0047F10C /* Ghostty */; @@ -207,7 +254,7 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 810ACCA02E9D3302004F8F92 /* GhosttyUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyUITests; sourceTree = ""; }; - 81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = ""; }; + 81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = ""; }; A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -219,6 +266,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8193244A2F24E6C000A9ED8F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A54F45F02E1F047A0046BD5C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -289,6 +343,7 @@ A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */, A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */, 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */, + 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */, ); name = Products; sourceTree = ""; @@ -328,6 +383,25 @@ productReference = 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + 8193244C2F24E6C000A9ED8F /* DockTilePlugin */ = { + isa = PBXNativeTarget; + buildConfigurationList = 819324512F24E6C000A9ED8F /* Build configuration list for PBXNativeTarget "DockTilePlugin" */; + buildPhases = ( + 819324492F24E6C000A9ED8F /* Sources */, + 8193244A2F24E6C000A9ED8F /* Frameworks */, + 8193244B2F24E6C000A9ED8F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = DockTilePlugin; + packageProductDependencies = ( + ); + productName = DockTilePlugin; + productReference = 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */; + productType = "com.apple.product-type.bundle"; + }; A54F45F22E1F047A0046BD5C /* GhosttyTests */ = { isa = PBXNativeTarget; buildConfigurationList = A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */; @@ -359,10 +433,12 @@ A5B3052D299BEAAA0047F10C /* Sources */, A5B3052E299BEAAA0047F10C /* Frameworks */, A5B3052F299BEAAA0047F10C /* Resources */, + 819324572F24E74E00A9ED8F /* Copy DockTilePlugin */, ); buildRules = ( ); dependencies = ( + 819324682F2502FB00A9ED8F /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 81F82BC72E82815D001EDFA7 /* Sources */, @@ -409,6 +485,9 @@ CreatedOnToolsVersion = 26.1; TestTargetID = A5B30530299BEAAA0047F10C; }; + 8193244C2F24E6C000A9ED8F = { + CreatedOnToolsVersion = 26.2; + }; A54F45F22E1F047A0046BD5C = { CreatedOnToolsVersion = 26.0; TestTargetID = A5B30530299BEAAA0047F10C; @@ -440,6 +519,7 @@ targets = ( A5B30530299BEAAA0047F10C /* Ghostty */, A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */, + 8193244C2F24E6C000A9ED8F /* DockTilePlugin */, A54F45F22E1F047A0046BD5C /* GhosttyTests */, 810ACC9E2E9D3301004F8F92 /* GhosttyUITests */, ); @@ -454,6 +534,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8193244B2F24E6C000A9ED8F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 819324642F24FF2100A9ED8F /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; A54F45F12E1F047A0046BD5C /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -522,6 +610,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 819324492F24E6C000A9ED8F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A54F45EF2E1F047A0046BD5C /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -551,6 +646,11 @@ target = A5B30530299BEAAA0047F10C /* Ghostty */; targetProxy = 810ACCA52E9D3302004F8F92 /* PBXContainerItemProxy */; }; + 819324682F2502FB00A9ED8F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */; + targetProxy = 819324672F2502FB00A9ED8F /* PBXContainerItemProxy */; + }; A54F45F82E1F047A0046BD5C /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A5B30530299BEAAA0047F10C /* Ghostty */; @@ -683,7 +783,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; @@ -706,6 +806,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; @@ -728,6 +829,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; @@ -736,6 +838,93 @@ }; name = ReleaseLocal; }; + 8193244E2F24E6C000A9ED8F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DOCK_TILE_PLUGIN DEBUG"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + WRAPPER_EXTENSION = plugin; + }; + name = Debug; + }; + 8193244F2F24E6C000A9ED8F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DOCK_TILE_PLUGIN; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + WRAPPER_EXTENSION = plugin; + }; + name = Release; + }; + 819324502F24E6C000A9ED8F /* ReleaseLocal */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DOCK_TILE_PLUGIN; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + WRAPPER_EXTENSION = plugin; + }; + name = ReleaseLocal; + }; A54F45F92E1F047A0046BD5C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1162,6 +1351,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = ReleaseLocal; }; + 819324512F24E6C000A9ED8F /* Build configuration list for PBXNativeTarget "DockTilePlugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8193244E2F24E6C000A9ED8F /* Debug */, + 8193244F2F24E6C000A9ED8F /* Release */, + 819324502F24E6C000A9ED8F /* ReleaseLocal */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = ReleaseLocal; + }; A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/macos/Sources/App/macOS/AppDelegate+Ghostty.swift b/macos/Sources/App/macOS/AppDelegate+Ghostty.swift index 66b95e06e..fc9a49067 100644 --- a/macos/Sources/App/macOS/AppDelegate+Ghostty.swift +++ b/macos/Sources/App/macOS/AppDelegate+Ghostty.swift @@ -11,10 +11,8 @@ extension AppDelegate: Ghostty.Delegate { continue } - for surface in controller.surfaceTree { - if surface.id == id { - return surface - } + for surface in controller.surfaceTree where surface.id == id { + return surface } } diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 1aa597a25..028d4506c 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -380,13 +380,7 @@ class AppDelegate: NSObject, if let why = event.attributeDescriptor(forKeyword: keyword) { switch why.typeCodeValue { - case kAEShutDown: - fallthrough - - case kAERestart: - fallthrough - - case kAEReallyLogOut: + case kAEShutDown, kAERestart, kAEReallyLogOut: return .terminateNow default: @@ -934,9 +928,8 @@ class AppDelegate: NSObject, } else { GlobalEventTap.shared.disable() } - Task { - await updateAppIcon(from: config) - } + + updateAppIcon(from: config) } /// Sync the appearance of our app with the theme specified in the config. @@ -944,81 +937,15 @@ class AppDelegate: NSObject, NSApplication.shared.appearance = .init(ghosttyConfig: config) } - // Using AppIconActor to ensure this work - // happens synchronously in the background - @AppIconActor - private func updateAppIcon(from config: Ghostty.Config) async { - var appIcon: NSImage? - var appIconName: String? = config.macosIcon.rawValue - - switch config.macosIcon { - case let icon where icon.assetName != nil: - appIcon = NSImage(named: icon.assetName!)! - - case .custom: - if let userIcon = NSImage(contentsOfFile: config.macosCustomIcon) { - appIcon = userIcon - appIconName = config.macosCustomIcon - } else { - appIcon = nil // Revert back to official icon if invalid location - appIconName = nil // Discard saved icon name - } - - case .customStyle: - // Discard saved icon name - // if no valid colours were found - appIconName = nil - guard let ghostColor = config.macosIconGhostColor else { break } - guard let screenColors = config.macosIconScreenColor else { break } - guard let icon = ColorizedGhosttyIcon( - screenColors: screenColors, - ghostColor: ghostColor, - frame: config.macosIconFrame - ).makeImage() else { break } - appIcon = icon - let colorStrings = ([ghostColor] + screenColors).compactMap(\.hexString) - appIconName = (colorStrings + [config.macosIconFrame.rawValue]) - .joined(separator: "_") - - default: - // Discard saved icon name - appIconName = nil + private func updateAppIcon(from config: Ghostty.Config) { + // Since this is called after `DockTilePlugin` has been running, + // clean it up here to trigger a correct update of the current config. + UserDefaults.standard.removeObject(forKey: "CustomGhosttyIcon") + DispatchQueue.global().async { + UserDefaults.standard.appIcon = AppIcon(config: config) + DistributedNotificationCenter.default() + .postNotificationName(.ghosttyIconDidChange, object: nil, userInfo: nil, deliverImmediately: true) } - - // Only change the icon if it has actually changed from the current one, - // or if the app build has changed (e.g. after an update that reset the icon) - let cachedIconName = UserDefaults.standard.string(forKey: "CustomGhosttyIcon") - let cachedIconBuild = UserDefaults.standard.string(forKey: "CustomGhosttyIconBuild") - let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String - let buildChanged = cachedIconBuild != currentBuild - - guard cachedIconName != appIconName || buildChanged else { -#if DEBUG - if appIcon == nil { - await MainActor.run { - // Changing the app bundle's icon will corrupt code signing. - // We only use the default blueprint icon for the dock, - // so developers don't need to clean and re-build every time. - NSApplication.shared.applicationIconImage = NSImage(named: "BlueprintImage") - } - } -#endif - return - } - // make it immutable, so Swift 6 won't complain - let newIcon = appIcon - - let appPath = Bundle.main.bundlePath - guard NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: []) else { return } - NSWorkspace.shared.noteFileSystemChanged(appPath) - - await MainActor.run { - self.appIcon = newIcon - NSApplication.shared.applicationIconImage = newIcon - } - - UserDefaults.standard.set(appIconName, forKey: "CustomGhosttyIcon") - UserDefaults.standard.set(currentBuild, forKey: "CustomGhosttyIconBuild") } // MARK: - Restorable State @@ -1082,10 +1009,8 @@ class AppDelegate: NSObject, func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? { for c in TerminalController.all { - for view in c.surfaceTree { - if view.id == uuid { - return view - } + for view in c.surfaceTree where view.id == uuid { + return view } } @@ -1357,8 +1282,3 @@ private enum QuickTerminalState { /// Controller has been initialized. case initialized(QuickTerminalController) } - -@globalActor -private actor AppIconActor: GlobalActor { - static let shared = AppIconActor() -} diff --git a/macos/Sources/Features/About/AboutController.swift b/macos/Sources/Features/About/AboutController.swift index 2f494f12c..6f4cccf6d 100644 --- a/macos/Sources/Features/About/AboutController.swift +++ b/macos/Sources/Features/About/AboutController.swift @@ -5,19 +5,21 @@ import SwiftUI class AboutController: NSWindowController, NSWindowDelegate { static let shared: AboutController = AboutController() + private let viewModel = AboutViewModel() override var windowNibName: NSNib.Name? { "About" } override func windowDidLoad() { guard let window = window else { return } window.center() window.isMovableByWindowBackground = true - window.contentView = NSHostingView(rootView: AboutView()) + window.contentView = NSHostingView(rootView: AboutView().environmentObject(viewModel)) } // MARK: - Functions func show() { window?.makeKeyAndOrderFront(nil) + viewModel.startCyclingIcons() } func hide() { @@ -38,4 +40,8 @@ class AboutController: NSWindowController, NSWindowDelegate { @objc func cancel(_ sender: Any?) { close() } + + func windowWillClose(_ notification: Notification) { + viewModel.stopCyclingIcons() + } } diff --git a/macos/Sources/Features/About/AboutViewModel.swift b/macos/Sources/Features/About/AboutViewModel.swift new file mode 100644 index 000000000..dc0d38c21 --- /dev/null +++ b/macos/Sources/Features/About/AboutViewModel.swift @@ -0,0 +1,40 @@ +import Combine + +class AboutViewModel: ObservableObject { + @Published var currentIcon: Ghostty.MacOSIcon? + @Published var isHovering: Bool = false + + private var timerCancellable: AnyCancellable? + + private let icons: [Ghostty.MacOSIcon] = [ + .official, + .blueprint, + .chalkboard, + .microchip, + .glass, + .holographic, + .paper, + .retro, + .xray, + ] + + func startCyclingIcons() { + timerCancellable = Timer.publish(every: 3, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + guard let self, !isHovering else { return } + advanceToNextIcon() + } + } + + func stopCyclingIcons() { + timerCancellable = nil + currentIcon = nil + } + + func advanceToNextIcon() { + let currentIndex = currentIcon.flatMap(icons.firstIndex(of:)) ?? 0 + let nextIndex = icons.indexWrapping(after: currentIndex) + currentIcon = icons[nextIndex] + } +} diff --git a/macos/Sources/Features/About/CyclingIconView.swift b/macos/Sources/Features/About/CyclingIconView.swift index 4274278e0..c2a860ff7 100644 --- a/macos/Sources/Features/About/CyclingIconView.swift +++ b/macos/Sources/Features/About/CyclingIconView.swift @@ -1,50 +1,38 @@ import SwiftUI import GhosttyKit +import Combine /// A view that cycles through Ghostty's official icon variants. struct CyclingIconView: View { - @State private var currentIcon: Ghostty.MacOSIcon = .official - @State private var isHovering: Bool = false - - private let icons: [Ghostty.MacOSIcon] = [ - .official, - .blueprint, - .chalkboard, - .microchip, - .glass, - .holographic, - .paper, - .retro, - .xray, - ] - private let timerPublisher = Timer.publish(every: 3, on: .main, in: .common) + @EnvironmentObject var viewModel: AboutViewModel var body: some View { ZStack { - iconView(for: currentIcon) - .id(currentIcon) + iconView(for: viewModel.currentIcon) + .id(viewModel.currentIcon) } - .animation(.easeInOut(duration: 0.5), value: currentIcon) + .animation(.easeInOut(duration: 0.5), value: viewModel.currentIcon) .frame(height: 128) - .onReceive(timerPublisher.autoconnect()) { _ in - if !isHovering { - advanceToNextIcon() - } - } .onHover { hovering in - isHovering = hovering + viewModel.isHovering = hovering } .onTapGesture { - advanceToNextIcon() + viewModel.advanceToNextIcon() + } + .contextMenu { + if let currentIcon = viewModel.currentIcon { + Button("Copy Icon Config") { + NSPasteboard.general.setString("macos-icon = \(currentIcon.rawValue)", forType: .string) + } + } } - .help("macos-icon = \(currentIcon.rawValue)") .accessibilityLabel("Ghostty Application Icon") .accessibilityHint("Click to cycle through icon variants") } @ViewBuilder - private func iconView(for icon: Ghostty.MacOSIcon) -> some View { - let iconImage: Image = switch icon.assetName { + private func iconView(for icon: Ghostty.MacOSIcon?) -> some View { + let iconImage: Image = switch icon?.assetName { case let assetName?: Image(assetName) case nil: ghosttyIconImage() } @@ -53,10 +41,4 @@ struct CyclingIconView: View { .resizable() .aspectRatio(contentMode: .fit) } - - private func advanceToNextIcon() { - let currentIndex = icons.firstIndex(of: currentIcon) ?? 0 - let nextIndex = icons.indexWrapping(after: currentIndex) - currentIcon = icons[nextIndex] - } } diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift deleted file mode 100644 index e58699cff..000000000 --- a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Cocoa - -struct ColorizedGhosttyIcon { - /// The colors that make up the gradient of the screen. - let screenColors: [NSColor] - - /// The color of the ghost. - let ghostColor: NSColor - - /// The frame type to use - let frame: Ghostty.MacOSIconFrame - - /// Make a custom colorized ghostty icon. - func makeImage() -> NSImage? { - // All of our layers (not in order) - guard let screen = NSImage(named: "CustomIconScreen") else { return nil } - guard let screenMask = NSImage(named: "CustomIconScreenMask") else { return nil } - guard let ghost = NSImage(named: "CustomIconGhost") else { return nil } - guard let crt = NSImage(named: "CustomIconCRT") else { return nil } - guard let gloss = NSImage(named: "CustomIconGloss") else { return nil } - - let baseName = switch frame { - case .aluminum: "CustomIconBaseAluminum" - case .beige: "CustomIconBaseBeige" - case .chrome: "CustomIconBaseChrome" - case .plastic: "CustomIconBasePlastic" - } - guard let base = NSImage(named: baseName) else { return nil } - - // Apply our color in various ways to our layers. - // NOTE: These functions are not built-in, they're implemented as an extension - // to NSImage in NSImage+Extension.swift. - guard let screenGradient = screenMask.gradient(colors: screenColors) else { return nil } - guard let tintedGhost = ghost.tint(color: ghostColor) else { return nil } - - // Combine our layers using the proper blending modes - return.combine(images: [ - base, - screen, - screenGradient, - ghost, - tintedGhost, - crt, - gloss, - ], blendingModes: [ - .normal, - .normal, - .color, - .normal, - .color, - .overlay, - .normal, - ]) - } -} diff --git a/macos/Sources/Features/Custom App Icon/AppIcon.swift b/macos/Sources/Features/Custom App Icon/AppIcon.swift new file mode 100644 index 000000000..13c6b83a1 --- /dev/null +++ b/macos/Sources/Features/Custom App Icon/AppIcon.swift @@ -0,0 +1,86 @@ +import AppKit +import System + +/// The icon style for the Ghostty App. +enum AppIcon: Equatable, Codable { + case official + case blueprint + case chalkboard + case glass + case holographic + case microchip + case paper + case retro + case xray + /// Save full image data to avoid sandboxing issues + case custom(_ iconFile: Data) + case customStyle(_ icon: ColorizedGhosttyIcon) + +#if !DOCK_TILE_PLUGIN + init?(config: Ghostty.Config) { + switch config.macosIcon { + case .official: + return nil + case .blueprint: + self = .blueprint + case .chalkboard: + self = .chalkboard + case .glass: + self = .glass + case .holographic: + self = .holographic + case .microchip: + self = .microchip + case .paper: + self = .paper + case .retro: + self = .retro + case .xray: + self = .xray + case .custom: + if let data = try? Data(contentsOf: URL(filePath: config.macosCustomIcon, relativeTo: nil)) { + self = .custom(data) + } else { + return nil + } + case .customStyle: + // Discard saved icon name + // if no valid colours were found + guard + let ghostColor = config.macosIconGhostColor, + let screenColors = config.macosIconScreenColor + else { + return nil + } + self = .customStyle(ColorizedGhosttyIcon(screenColors: screenColors, ghostColor: ghostColor, frame: config.macosIconFrame)) + } + } +#endif + + func image(in bundle: Bundle) -> NSImage? { + switch self { + case .official: + return nil + case .blueprint: + return bundle.image(forResource: "BlueprintImage")! + case .chalkboard: + return bundle.image(forResource: "ChalkboardImage")! + case .glass: + return bundle.image(forResource: "GlassImage")! + case .holographic: + return bundle.image(forResource: "HolographicImage")! + case .microchip: + return bundle.image(forResource: "MicrochipImage")! + case .paper: + return bundle.image(forResource: "PaperImage")! + case .retro: + return bundle.image(forResource: "RetroImage")! + case .xray: + return bundle.image(forResource: "XrayImage")! + case let .custom(file): + return NSImage(data: file) + case let .customStyle(customIcon): + return customIcon.makeImage(in: bundle) + } + } +} diff --git a/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift new file mode 100644 index 000000000..99d684369 --- /dev/null +++ b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift @@ -0,0 +1,115 @@ +import Cocoa + +struct ColorizedGhosttyIcon { + /// The colors that make up the gradient of the screen. + let screenColors: [NSColor] + + /// The color of the ghost. + let ghostColor: NSColor + + /// The frame type to use + let frame: Ghostty.MacOSIconFrame + + /// Make a custom colorized ghostty icon. + func makeImage(in bundle: Bundle) -> NSImage? { + // All of our layers (not in order) + guard let screen = bundle.image(forResource: "CustomIconScreen") else { return nil } + guard let screenMask = bundle.image(forResource: "CustomIconScreenMask") else { return nil } + guard let ghost = bundle.image(forResource: "CustomIconGhost") else { return nil } + guard let crt = bundle.image(forResource: "CustomIconCRT") else { return nil } + guard let gloss = bundle.image(forResource: "CustomIconGloss") else { return nil } + + let baseName = switch frame { + case .aluminum: "CustomIconBaseAluminum" + case .beige: "CustomIconBaseBeige" + case .chrome: "CustomIconBaseChrome" + case .plastic: "CustomIconBasePlastic" + } + guard let base = bundle.image(forResource: baseName) else { return nil } + + // Apply our color in various ways to our layers. + // NOTE: These functions are not built-in, they're implemented as an extension + // to NSImage in NSImage+Extension.swift. + guard let screenGradient = screenMask.gradient(colors: screenColors) else { return nil } + guard let tintedGhost = ghost.tint(color: ghostColor) else { return nil } + + // Combine our layers using the proper blending modes + return.combine(images: [ + base, + screen, + screenGradient, + ghost, + tintedGhost, + crt, + gloss, + ], blendingModes: [ + .normal, + .normal, + .color, + .normal, + .color, + .overlay, + .normal, + ]) + } +} + +// MARK: Codable + +extension ColorizedGhosttyIcon: Codable { + private enum CodingKeys: String, CodingKey { + case version + case screenColors + case ghostColor + case frame + + static let currentVersion: Int = 1 + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // If no version exists then this is the legacy v0 format. + let version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 0 + guard version == 0 || version == CodingKeys.currentVersion else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unsupported ColorizedGhosttyIcon version: \(version)" + ) + ) + } + + let screenColorHexes = try container.decode([String].self, forKey: .screenColors) + let screenColors = screenColorHexes.compactMap(NSColor.init(hex:)) + let ghostColorHex = try container.decode(String.self, forKey: .ghostColor) + guard let ghostColor = NSColor(hex: ghostColorHex) else { + throw DecodingError.dataCorruptedError( + forKey: .ghostColor, + in: container, + debugDescription: "Failed to decode ghost color from \(ghostColorHex)" + ) + } + let frame = try container.decode(Ghostty.MacOSIconFrame.self, forKey: .frame) + self.init(screenColors: screenColors, ghostColor: ghostColor, frame: frame) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(CodingKeys.currentVersion, forKey: .version) + try container.encode(screenColors.compactMap(\.hexString), forKey: .screenColors) + try container.encode(ghostColor.hexString, forKey: .ghostColor) + try container.encode(frame, forKey: .frame) + } + +} + +// MARK: Equatable + +extension ColorizedGhosttyIcon: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.frame == rhs.frame && + lhs.screenColors.compactMap(\.hexString) == rhs.screenColors.compactMap(\.hexString) && + lhs.ghostColor.hexString == rhs.ghostColor.hexString + } +} diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconImage.swift similarity index 100% rename from macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift rename to macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconImage.swift diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconView.swift similarity index 89% rename from macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift rename to macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconView.swift index 8fbebfdc8..7271c595f 100644 --- a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift +++ b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconView.swift @@ -8,6 +8,6 @@ struct ColorizedGhosttyIconView: View { screenColors: [.purple, .blue], ghostColor: .yellow, frame: .aluminum - ).makeImage()!) + ).makeImage(in: .main)!) } } diff --git a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift new file mode 100644 index 000000000..6c5abc198 --- /dev/null +++ b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift @@ -0,0 +1,118 @@ +import AppKit + +class DockTilePlugin: NSObject, NSDockTilePlugIn { + // WARNING: An instance of this class is alive as long as Ghostty's icon is + // in the doc (running or not!), so keep any state and processing to a + // minimum to respect resource usage. + + private let pluginBundle = Bundle(for: DockTilePlugin.self) + + // Separate defaults based on debug vs release builds so we can test icons + // without messing up releases. + #if DEBUG + private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty.debug") + #else + private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty") + #endif + + private var iconChangeObserver: Any? + + /// The path to the Ghostty.app, determined based on the bundle path of this plugin. + var ghosttyAppPath: String { + var url = pluginBundle.bundleURL + // Remove "/Contents/PlugIns/DockTilePlugIn.bundle" from the bundle URL to reach Ghostty.app. + while url.lastPathComponent != "Ghostty.app", !url.lastPathComponent.isEmpty { + url.deleteLastPathComponent() + } + return url.path + } + + /// The primary NSDockTilePlugin function. + func setDockTile(_ dockTile: NSDockTile?) { + // If no dock tile or no access to Ghostty defaults, we can't do anything. + guard let dockTile, let ghosttyUserDefaults else { + iconChangeObserver = nil + return + } + + // Try to restore the previous icon on launch. + iconDidChange(ghosttyUserDefaults.appIcon, dockTile: dockTile) + + // Setup a new observer for when the icon changes so we can update. This message + // is sent by the primary Ghostty app. + iconChangeObserver = DistributedNotificationCenter + .default() + .publisher(for: .ghosttyIconDidChange) + .map { [weak self] _ in self?.ghosttyUserDefaults?.appIcon } + .receive(on: DispatchQueue.global()) + .sink { [weak self] newIcon in self?.iconDidChange(newIcon, dockTile: dockTile) } + } + + private func iconDidChange(_ newIcon: AppIcon?, dockTile: NSDockTile) { + guard let appIcon = newIcon?.image(in: pluginBundle) else { + resetIcon(dockTile: dockTile) + return + } + + let appBundlePath = self.ghosttyAppPath + NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath) + NSWorkspace.shared.noteFileSystemChanged(appBundlePath) + + dockTile.setIcon(appIcon) + } + + /// Reset the application icon and dock tile icon to the default. + private func resetIcon(dockTile: NSDockTile) { + let appBundlePath = self.ghosttyAppPath + let appIcon: NSImage + if #available(macOS 26.0, *) { + // Reset to the default (glassy) icon. + NSWorkspace.shared.setIcon(nil, forFile: appBundlePath) + + #if DEBUG + // Use the `Blueprint` icon to distinguish Debug from Release builds. + appIcon = pluginBundle.image(forResource: "BlueprintImage")! + #else + // Get the composed icon from the app bundle. + if let iconRep = NSWorkspace.shared.icon(forFile: appBundlePath) + .bestRepresentation( + for: CGRect(origin: .zero, size: dockTile.size), + context: nil, + hints: nil + ) { + appIcon = NSImage(size: dockTile.size) + appIcon.addRepresentation(iconRep) + } else { + // If something unexpected happens on macOS 26, + // fall back to a bundled icon. + appIcon = pluginBundle.image(forResource: "AppIconImage")! + } + #endif + } else { + // Use the bundled icon to keep the corner radius consistent with pre-Tahoe apps. + appIcon = pluginBundle.image(forResource: "AppIconImage")! + NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath) + } + + // Notify Finder/Dock so icon caches refresh immediately. + NSWorkspace.shared.noteFileSystemChanged(appBundlePath) + dockTile.setIcon(appIcon) + } +} + +private extension NSDockTile { + func setIcon(_ newIcon: NSImage) { + // Update the Dock tile on the main thread. + DispatchQueue.main.async { + let iconView = NSImageView(frame: CGRect(origin: .zero, size: self.size)) + iconView.wantsLayer = true + iconView.image = newIcon + self.contentView = iconView + self.display() + } + } +} + +// This is required because of the DispatchQueue call above. This doesn't +// feel right but I don't know a better way to solve this. +extension NSDockTile: @unchecked @retroactive Sendable {} diff --git a/macos/Sources/Features/Custom App Icon/Extensions/Notification+AppIcon.swift b/macos/Sources/Features/Custom App Icon/Extensions/Notification+AppIcon.swift new file mode 100644 index 000000000..e492f1a77 --- /dev/null +++ b/macos/Sources/Features/Custom App Icon/Extensions/Notification+AppIcon.swift @@ -0,0 +1,5 @@ +import AppKit + +extension Notification.Name { + static let ghosttyIconDidChange = Notification.Name("com.mitchellh.ghostty.iconDidChange") +} diff --git a/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift b/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift new file mode 100644 index 000000000..d15644c93 --- /dev/null +++ b/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift @@ -0,0 +1,29 @@ +import AppKit + +extension UserDefaults { + private static let customIconKeyOld = "CustomGhosttyIcon" + private static let customIconKeyNew = "CustomGhosttyIcon2" + + var appIcon: AppIcon? { + get { + // Always remove our old pre-docktileplugin values. + defer { + removeObject(forKey: Self.customIconKeyOld) + } + + // Check if we have the new key for our dock tile plugin format. + guard let data = data(forKey: Self.customIconKeyNew) else { + return nil + } + return try? JSONDecoder().decode(AppIcon.self, from: data) + } + + set { + guard let newData = try? JSONEncoder().encode(newValue) else { + return + } + + set(newData, forKey: Self.customIconKeyNew) + } + } +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift index 9f544b7e6..176cbf160 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift @@ -7,14 +7,14 @@ enum QuickTerminalSpaceBehavior { init?(fromGhosttyConfig string: String) { switch string { - case "move": - self = .move + case "move": + self = .move - case "remain": - self = .remain + case "remain": + self = .remain - default: - return nil + default: + return nil } } @@ -25,12 +25,12 @@ enum QuickTerminalSpaceBehavior { ] switch self { - case .move: - // We want this to move the window to the active space. - return NSWindow.CollectionBehavior([.canJoinAllSpaces] + commonBehavior) - case .remain: - // We want this to remain the window in the current space. - return NSWindow.CollectionBehavior([.moveToActiveSpace] + commonBehavior) + case .move: + // We want this to move the window to the active space. + return NSWindow.CollectionBehavior([.canJoinAllSpaces] + commonBehavior) + case .remain: + // We want this to remain the window in the current space. + return NSWindow.CollectionBehavior([.moveToActiveSpace] + commonBehavior) } } } diff --git a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift index 8d1332174..ebf5b5138 100644 --- a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift +++ b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift @@ -2,8 +2,8 @@ import SwiftUI struct SecureInputOverlay: View { // Animations - @State private var shadowAngle: Angle = .degrees(0) - @State private var shadowWidth: CGFloat = 6 + @State private var gradientAngle: Angle = .degrees(0) + @State private var gradientOpacity: CGFloat = 0.5 // Popover explainer text @State private var isPopover = false @@ -20,18 +20,32 @@ struct SecureInputOverlay: View { .foregroundColor(.primary) .padding(5) .background( - RoundedRectangle(cornerRadius: 12) + Rectangle() .fill(.background) - .innerShadow( - using: RoundedRectangle(cornerRadius: 12), - stroke: AngularGradient( - gradient: Gradient(colors: [.cyan, .blue, .yellow, .blue, .cyan]), - center: .center, - angle: shadowAngle - ), - width: shadowWidth + .overlay( + Rectangle() + .fill( + AngularGradient( + gradient: Gradient( + colors: [.cyan, .blue, .yellow, .blue, .cyan] + ), + center: .center, + angle: gradientAngle + ) + ) + .blur(radius: 4, opaque: true) + .mask( + RadialGradient( + colors: [.clear, .black], + center: .center, + startRadius: 0, + endRadius: 25 + ) + ) + .opacity(gradientOpacity) ) ) + .mask(RoundedRectangle(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.gray, lineWidth: 1) @@ -57,11 +71,11 @@ struct SecureInputOverlay: View { } .onAppear { withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: false)) { - shadowAngle = .degrees(360) + gradientAngle = .degrees(360) } withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: true)) { - shadowWidth = 12 + gradientOpacity = 1 } } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1cff80c52..9f65d35ce 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1265,6 +1265,17 @@ class BaseTerminalController: NSWindowController, } @IBAction func changeTabTitle(_ sender: Any) { + if let targetWindow = window { + let inlineHostWindow = + targetWindow.tabbedWindows? + .first(where: { $0.tabBarView != nil }) as? TerminalWindow + ?? (targetWindow as? TerminalWindow) + + if let inlineHostWindow, inlineHostWindow.beginInlineTabTitleEdit(for: targetWindow) { + return + } + } + promptTabTitle() } diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index a42d4c2f6..aab51f6bd 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -131,11 +131,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // Find the focused surface in surfaceTree if let focusedStr = state.focusedSurface { var foundView: Ghostty.SurfaceView? - for view in c.surfaceTree { - if view.id.uuidString == focusedStr { - foundView = view - break - } + for view in c.surfaceTree where view.id.uuidString == focusedStr { + foundView = view + break } if let view = foundView { diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift index c65dca1d2..ef4aff5b9 100644 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -6,9 +6,8 @@ import SwiftUI class TerminalViewContainer: NSView { private let terminalView: NSView - /// Glass effect view for liquid glass background when transparency is enabled + /// Combined glass effect and inactive tint overlay view private var glassEffectView: NSView? - private var glassTopConstraint: NSLayoutConstraint? private var derivedConfig: DerivedConfig init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) { @@ -27,6 +26,10 @@ class TerminalViewContainer: NSView { fatalError("init(coder:) has not been implemented") } + deinit { + NotificationCenter.default.removeObserver(self) + } + /// To make ``TerminalController/DefaultSize/contentIntrinsicSize`` /// work in ``TerminalController/windowDidLoad()``, /// we override this to provide the correct size. @@ -50,6 +53,20 @@ class TerminalViewContainer: NSView { name: .ghosttyConfigDidChange, object: nil ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidBecomeKey(_:)), + name: NSWindow.didBecomeKeyNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidResignKey(_:)), + name: NSWindow.didResignKeyNotification, + object: nil + ) } override func viewDidMoveToWindow() { @@ -72,36 +89,139 @@ class TerminalViewContainer: NSView { derivedConfig = newValue DispatchQueue.main.async(execute: updateGlassEffectIfNeeded) } + + @objc private func windowDidBecomeKey(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + window == self.window else { return } + updateGlassTintOverlay(isKeyWindow: true) + } + + @objc private func windowDidResignKey(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + window == self.window else { return } + updateGlassTintOverlay(isKeyWindow: false) + } } // MARK: Glass +/// An `NSView` that contains a liquid glass background effect and +/// an inactive-window tint overlay. +#if compiler(>=6.2) +@available(macOS 26.0, *) +private class TerminalGlassView: NSView { + private let glassEffectView: NSGlassEffectView + private var glassTopConstraint: NSLayoutConstraint? + private let tintOverlay: NSView + private var tintTopConstraint: NSLayoutConstraint? + + init(topOffset: CGFloat) { + self.glassEffectView = NSGlassEffectView() + self.tintOverlay = NSView() + super.init(frame: .zero) + + translatesAutoresizingMaskIntoConstraints = false + + // Glass effect view fills this view. + glassEffectView.translatesAutoresizingMaskIntoConstraints = false + addSubview(glassEffectView) + glassTopConstraint = glassEffectView.topAnchor.constraint( + equalTo: topAnchor, + constant: topOffset + ) + if let glassTopConstraint { + NSLayoutConstraint.activate([ + glassTopConstraint, + glassEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), + glassEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), + glassEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + + // Tint overlay sits above the glass effect. + tintOverlay.translatesAutoresizingMaskIntoConstraints = false + tintOverlay.wantsLayer = true + tintOverlay.alphaValue = 0 + addSubview(tintOverlay, positioned: .above, relativeTo: glassEffectView) + tintTopConstraint = tintOverlay.topAnchor.constraint( + equalTo: topAnchor, + constant: topOffset + ) + if let tintTopConstraint { + NSLayoutConstraint.activate([ + tintTopConstraint, + tintOverlay.leadingAnchor.constraint(equalTo: leadingAnchor), + tintOverlay.bottomAnchor.constraint(equalTo: bottomAnchor), + tintOverlay.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Configures the glass effect style, tint color, corner radius, and + /// updates the inactive tint overlay based on window key status. + func configure( + style: NSGlassEffectView.Style, + backgroundColor: NSColor, + backgroundOpacity: Double, + cornerRadius: CGFloat?, + isKeyWindow: Bool + ) { + glassEffectView.style = style + glassEffectView.tintColor = backgroundColor.withAlphaComponent(backgroundOpacity) + if let cornerRadius { + glassEffectView.cornerRadius = cornerRadius + } + updateKeyStatus(isKeyWindow, backgroundColor: backgroundColor) + } + + /// Updates the top inset offset for both the glass effect and tint overlay. + /// Call this when the safe area insets change (e.g., during layout). + func updateTopInset(_ offset: CGFloat) { + glassTopConstraint?.constant = offset + tintTopConstraint?.constant = offset + } + + /// Updates the tint overlay visibility based on window key status. + func updateKeyStatus(_ isKeyWindow: Bool, backgroundColor: NSColor) { + let tint = tintProperties(for: backgroundColor) + tintOverlay.layer?.backgroundColor = tint.color.cgColor + tintOverlay.alphaValue = isKeyWindow ? 0 : tint.opacity + } + + /// Computes a saturation-boosted tint color and opacity for the inactive overlay. + private func tintProperties(for color: NSColor) -> (color: NSColor, opacity: CGFloat) { + let isLight = color.isLightColor + let vibrant = color.adjustingSaturation(by: 1.2) + let overlayOpacity: CGFloat = isLight ? 0.35 : 0.85 + return (vibrant, overlayOpacity) + } +} +#endif // compiler(>=6.2) + private extension TerminalViewContainer { #if compiler(>=6.2) @available(macOS 26.0, *) - func addGlassEffectViewIfNeeded() -> NSGlassEffectView? { - if let existed = glassEffectView as? NSGlassEffectView { + func addGlassEffectViewIfNeeded() -> TerminalGlassView? { + if let existed = glassEffectView as? TerminalGlassView { updateGlassEffectTopInsetIfNeeded() return existed } guard let themeFrameView = window?.contentView?.superview else { return nil } - let effectView = NSGlassEffectView() + let effectView = TerminalGlassView(topOffset: -themeFrameView.safeAreaInsets.top) addSubview(effectView, positioned: .below, relativeTo: terminalView) - effectView.translatesAutoresizingMaskIntoConstraints = false - glassTopConstraint = effectView.topAnchor.constraint( - equalTo: topAnchor, - constant: -themeFrameView.safeAreaInsets.top - ) - if let glassTopConstraint { - NSLayoutConstraint.activate([ - glassTopConstraint, - effectView.leadingAnchor.constraint(equalTo: leadingAnchor), - effectView.bottomAnchor.constraint(equalTo: bottomAnchor), - effectView.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - } + NSLayoutConstraint.activate([ + effectView.topAnchor.constraint(equalTo: topAnchor), + effectView.leadingAnchor.constraint(equalTo: leadingAnchor), + effectView.bottomAnchor.constraint(equalTo: bottomAnchor), + effectView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) glassEffectView = effectView return effectView } @@ -112,26 +232,35 @@ private extension TerminalViewContainer { guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else { glassEffectView?.removeFromSuperview() glassEffectView = nil - glassTopConstraint = nil return } guard let effectView = addGlassEffectViewIfNeeded() else { return } + + let style: NSGlassEffectView.Style switch derivedConfig.backgroundBlur { case .macosGlassRegular: - effectView.style = NSGlassEffectView.Style.regular + style = NSGlassEffectView.Style.regular case .macosGlassClear: - effectView.style = NSGlassEffectView.Style.clear + style = NSGlassEffectView.Style.clear default: - break + style = NSGlassEffectView.Style.regular } let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor) - effectView.tintColor = backgroundColor - .withAlphaComponent(derivedConfig.backgroundOpacity) - if let window, window.responds(to: Selector(("_cornerRadius"))), let cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat { - effectView.cornerRadius = cornerRadius + + var cornerRadius: CGFloat? + if let window, window.responds(to: Selector(("_cornerRadius"))) { + cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat } + + effectView.configure( + style: style, + backgroundColor: backgroundColor, + backgroundOpacity: derivedConfig.backgroundOpacity, + cornerRadius: cornerRadius, + isKeyWindow: window?.isKeyWindow ?? true + ) #endif // compiler(>=6.2) } @@ -142,7 +271,16 @@ private extension TerminalViewContainer { } guard glassEffectView != nil else { return } guard let themeFrameView = window?.contentView?.superview else { return } - glassTopConstraint?.constant = -themeFrameView.safeAreaInsets.top + (glassEffectView as? TerminalGlassView)?.updateTopInset(-themeFrameView.safeAreaInsets.top) +#endif // compiler(>=6.2) + } + + func updateGlassTintOverlay(isKeyWindow: Bool) { +#if compiler(>=6.2) + guard #available(macOS 26.0, *) else { return } + guard glassEffectView != nil else { return } + let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor) + (glassEffectView as? TerminalGlassView)?.updateKeyStatus(isKeyWindow, backgroundColor: backgroundColor) #endif // compiler(>=6.2) } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index cde8d2747..62835e286 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -37,6 +37,12 @@ class TerminalWindow: NSWindow { /// Sets up our tab context menu private var tabMenuObserver: NSObjectProtocol? + /// Handles inline tab title editing for this host window. + private lazy var tabTitleEditor = TabTitleEditor( + hostWindow: self, + delegate: self + ) + /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. var supportsUpdateAccessory: Bool { @@ -174,7 +180,16 @@ class TerminalWindow: NSWindow { override var canBecomeKey: Bool { return true } override var canBecomeMain: Bool { return true } + override func sendEvent(_ event: NSEvent) { + if tabTitleEditor.handleDoubleClick(event) { + return + } + + super.sendEvent(event) + } + override func close() { + tabTitleEditor.finishEditing(commit: true) NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) super.close() } @@ -207,6 +222,21 @@ class TerminalWindow: NSWindow { viewModel.isMainWindow = false } + @discardableResult + func beginInlineTabTitleEdit(for targetWindow: NSWindow) -> Bool { + tabTitleEditor.beginEditing(for: targetWindow) + } + + @objc private func renameTabFromContextMenu(_ sender: NSMenuItem) { + let targetWindow = sender.representedObject as? NSWindow ?? self + if beginInlineTabTitleEdit(for: targetWindow) { + return + } + + guard let targetController = targetWindow.windowController as? BaseTerminalController else { return } + targetController.promptTabTitle() + } + override func mergeAllWindows(_ sender: Any?) { super.mergeAllWindows(sender) @@ -731,10 +761,11 @@ extension TerminalWindow { separator.identifier = Self.tabColorSeparatorIdentifier menu.addItem(separator) - // Change Title... - let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") + // Rename Tab... + let changeTitleItem = NSMenuItem(title: "Rename Tab...", action: #selector(TerminalWindow.renameTabFromContextMenu(_:)), keyEquivalent: "") changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier - changeTitleItem.target = target + changeTitleItem.target = self + changeTitleItem.representedObject = target?.window changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line") menu.addItem(changeTitleItem) @@ -760,3 +791,42 @@ private func makeTabColorPaletteView( hostingView.frame.size = hostingView.intrinsicContentSize return hostingView } + +// MARK: - Inline Tab Title Editing + +extension TerminalWindow: TabTitleEditorDelegate { + func tabTitleEditor( + _ editor: TabTitleEditor, + canRenameTabFor targetWindow: NSWindow + ) -> Bool { + targetWindow.windowController is BaseTerminalController + } + + func tabTitleEditor( + _ editor: TabTitleEditor, + titleFor targetWindow: NSWindow + ) -> String { + guard let targetController = targetWindow.windowController as? BaseTerminalController else { + return targetWindow.title + } + + return targetController.titleOverride ?? targetWindow.title + } + + func tabTitleEditor( + _ editor: TabTitleEditor, + didCommitTitle editedTitle: String, + for targetWindow: NSWindow + ) { + guard let targetController = targetWindow.windowController as? BaseTerminalController else { return } + targetController.titleOverride = editedTitle.isEmpty ? nil : editedTitle + } + + func tabTitleEditor( + _ editor: TabTitleEditor, + performFallbackRenameFor targetWindow: NSWindow + ) { + guard let targetController = targetWindow.windowController as? BaseTerminalController else { return } + targetController.promptTabTitle() + } +} diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index 53dfbe842..b14cde1ac 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -47,7 +47,7 @@ struct UpdatePill: View { } else { showPopover.toggle() } - }) { + }, label: { HStack(spacing: 6) { UpdateBadge(model: model) .frame(width: 14, height: 14) @@ -66,7 +66,7 @@ struct UpdatePill: View { ) .foregroundColor(model.foregroundColor) .contentShape(Capsule()) - } + }) .buttonStyle(.plain) .help(model.text) .accessibilityLabel(model.text) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 42d4368e7..89b2f18f1 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -694,7 +694,10 @@ extension Ghostty { if let candidate = URL(string: action.url), candidate.scheme != nil { url = candidate } else { - url = URL(filePath: action.url) + // Expand ~ to the user's home directory so that file paths + // like ~/Documents/file.txt resolve correctly. + let expandedPath = NSString(string: action.url).standardizingPath + url = URL(filePath: expandedPath) } switch action.kind { diff --git a/macos/Sources/Ghostty/Ghostty.ConfigTypes.swift b/macos/Sources/Ghostty/Ghostty.ConfigTypes.swift new file mode 100644 index 000000000..8c559fad2 --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.ConfigTypes.swift @@ -0,0 +1,43 @@ +// This file contains the configuration types for Ghostty so that alternate targets +// can get typed information without depending on all the dependencies of GhosttyKit. + +extension Ghostty { + /// macos-icon + enum MacOSIcon: String, Sendable { + case official + case blueprint + case chalkboard + case glass + case holographic + case microchip + case paper + case retro + case xray + case custom + case customStyle = "custom-style" + + /// Bundled asset name for built-in icons + var assetName: String? { + switch self { + case .official: return nil + case .blueprint: return "BlueprintImage" + case .chalkboard: return "ChalkboardImage" + case .microchip: return "MicrochipImage" + case .glass: return "GlassImage" + case .holographic: return "HolographicImage" + case .paper: return "PaperImage" + case .retro: return "RetroImage" + case .xray: return "XrayImage" + case .custom, .customStyle: return nil + } + } + } + + /// macos-icon-frame + enum MacOSIconFrame: String, Codable { + case aluminum + case beige + case plastic + case chrome + } +} diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/GhosttyPackage.swift similarity index 91% rename from macos/Sources/Ghostty/Package.swift rename to macos/Sources/Ghostty/GhosttyPackage.swift index 1e92eb8a1..03211862f 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/GhosttyPackage.swift @@ -2,23 +2,6 @@ import os import SwiftUI import GhosttyKit -struct Ghostty { - // The primary logger used by the GhosttyKit libraries. - static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: "ghostty" - ) - - // All the notifications that will be emitted will be put here. - struct Notification {} - - // The user notification category identifier - static let userNotificationCategory = "com.mitchellh.ghostty.userNotification" - - // The user notification "Show" action - static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show" -} - // MARK: C Extensions /// A command is fully self-contained so it is Sendable. @@ -28,6 +11,14 @@ extension ghostty_command_s: @unchecked @retroactive Sendable {} /// may be unsafe but the value itself is safe to send across threads. extension ghostty_surface_t: @unchecked @retroactive Sendable {} +extension Ghostty { + // The user notification category identifier + static let userNotificationCategory = "com.mitchellh.ghostty.userNotification" + + // The user notification "Show" action + static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show" +} + // MARK: Build Info extension Ghostty { @@ -317,45 +308,6 @@ extension Ghostty { } } - /// macos-icon - enum MacOSIcon: String, Sendable { - case official - case blueprint - case chalkboard - case glass - case holographic - case microchip - case paper - case retro - case xray - case custom - case customStyle = "custom-style" - - /// Bundled asset name for built-in icons - var assetName: String? { - switch self { - case .official: return nil - case .blueprint: return "BlueprintImage" - case .chalkboard: return "ChalkboardImage" - case .microchip: return "MicrochipImage" - case .glass: return "GlassImage" - case .holographic: return "HolographicImage" - case .paper: return "PaperImage" - case .retro: return "RetroImage" - case .xray: return "XrayImage" - case .custom, .customStyle: return nil - } - } - } - - /// macos-icon-frame - enum MacOSIconFrame: String { - case aluminum - case beige - case plastic - case chrome - } - /// Enum for the macos-window-buttons config option enum MacOSWindowButtons: String { case visible diff --git a/macos/Sources/Ghostty/GhosttyPackageMeta.swift b/macos/Sources/Ghostty/GhosttyPackageMeta.swift new file mode 100644 index 000000000..8e035c323 --- /dev/null +++ b/macos/Sources/Ghostty/GhosttyPackageMeta.swift @@ -0,0 +1,16 @@ +import Foundation +import os + +// This defines the minimal information required so all other files can do +// `extension Ghostty` to add more to it. This purposely has minimal +// dependencies so things like our dock tile plugin can use it. +enum Ghostty { + // The primary logger used by the GhosttyKit libraries. + static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: "ghostty" + ) + + // All the notifications that will be emitted will be put here. + struct Notification {} +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index ff751df10..a8555e938 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -1,41 +1,37 @@ -import AppKit import SwiftUI extension Ghostty { /// A grab handle overlay at the top of the surface for dragging the window. - /// Only appears when hovering in the top region of the surface. struct SurfaceGrabHandle: View { - private let handleHeight: CGFloat = 10 - - let surfaceView: SurfaceView + @ObservedObject var surfaceView: SurfaceView @State private var isHovering: Bool = false @State private var isDragging: Bool = false - var body: some View { - VStack(spacing: 0) { - Rectangle() - .fill(Color.primary.opacity(isHovering || isDragging ? 0.15 : 0)) - .frame(height: handleHeight) - .overlay(alignment: .center) { - if isHovering || isDragging { - Image(systemName: "ellipsis") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.primary.opacity(0.5)) - } - } - .contentShape(Rectangle()) - .overlay { - SurfaceDragSource( - surfaceView: surfaceView, - isDragging: $isDragging, - isHovering: $isHovering - ) - } + private var ellipsisVisible: Bool { + surfaceView.mouseOverSurface && surfaceView.cursorVisible + } - Spacer() + var body: some View { + ZStack { + SurfaceDragSource( + surfaceView: surfaceView, + isDragging: $isDragging, + isHovering: $isHovering + ) + .frame(width: 80, height: 12) + .contentShape(Rectangle()) + + if ellipsisVisible { + Image(systemName: "ellipsis") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.primary.opacity(isHovering ? 0.8 : 0.3)) + .offset(y: -3) + .allowsHitTesting(false) + .transition(.opacity) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 221bc4c37..fb5a1a864 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -454,18 +454,18 @@ extension Ghostty { guard let surface = surfaceView.surface else { return } let action = "navigate_search:next" ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) - }) { + }, label: { Image(systemName: "chevron.up") - } + }) .buttonStyle(SearchButtonStyle()) Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:previous" ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) - }) { + }, label: { Image(systemName: "chevron.down") - } + }) .buttonStyle(SearchButtonStyle()) Button(action: onClose) { diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 6b3bfbfb4..e45480a20 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -116,6 +116,12 @@ extension Ghostty { // Whether the pointer should be visible or not @Published private(set) var pointerStyle: CursorStyle = .horizontalText + // Whether the mouse is currently over this surface + @Published private(set) var mouseOverSurface: Bool = false + + // Whether the cursor is currently visible (not hidden by typing, etc.) + @Published private(set) var cursorVisible: Bool = true + /// The configuration derived from the Ghostty config so we don't need to rely on references. @Published private(set) var derivedConfig: DerivedConfig @@ -533,6 +539,7 @@ extension Ghostty { } func setCursorVisibility(_ visible: Bool) { + cursorVisible = visible // Technically this action could be called anytime we want to // change the mouse visibility but at the time of writing this // mouse-hide-while-typing is the only use case so this is the @@ -910,6 +917,7 @@ extension Ghostty { } override func mouseEntered(with event: NSEvent) { + mouseOverSurface = true super.mouseEntered(with: event) guard let surfaceModel else { return } @@ -928,6 +936,7 @@ extension Ghostty { } override func mouseExited(with event: NSEvent) { + mouseOverSurface = false guard let surfaceModel else { return } // If the mouse is being dragged then we don't have to emit diff --git a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift index 63cf02ed4..ed2177325 100644 --- a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift @@ -24,6 +24,14 @@ extension NSColor { appleColorList?.allKeys.map { $0.lowercased() } ?? [] } + /// Returns a new color with its saturation multiplied by the given factor, clamped to [0, 1]. + func adjustingSaturation(by factor: CGFloat) -> NSColor { + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + let hsbColor = self.usingColorSpace(.sRGB) ?? self + hsbColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + return NSColor(hue: h, saturation: min(max(s * factor, 0), 1), brightness: b, alpha: a) + } + /// Calculates the perceptual distance to another color in RGB space. func distance(to other: NSColor) -> Double { guard let a = self.usingColorSpace(.sRGB), diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index 030de0d1d..2546caa38 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -52,10 +52,8 @@ extension NSView { return true } - for subview in subviews { - if subview.contains(view) { - return true - } + for subview in subviews where subview.contains(view) { + return true } return false @@ -67,10 +65,8 @@ extension NSView { return true } - for subview in subviews { - if subview.contains(className: name) { - return true - } + for subview in subviews where subview.contains(className: name) { + return true } return false diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index 4821e5b46..018530cef 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -76,25 +76,33 @@ extension NSWindow { titlebarView?.firstDescendant(withClassName: "NSTabBar") } - /// Returns the index of the tab button at the given screen point, if any. - func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? { + /// Returns tab button views in visual order from left to right. + func tabButtonsInVisualOrder() -> [NSView] { + guard let tabBarView else { return [] } + return tabBarView + .descendants(withClassName: "NSTabButton") + .sorted { $0.frame.minX < $1.frame.minX } + } + + /// Returns the visual tab index and matching tab button at the given screen point. + func tabButtonHit(atScreenPoint screenPoint: NSPoint) -> (index: Int, tabButton: NSView)? { guard let tabBarView else { return nil } let locationInWindow = convertPoint(fromScreen: screenPoint) let locationInTabBar = tabBarView.convert(locationInWindow, from: nil) guard tabBarView.bounds.contains(locationInTabBar) else { return nil } - // Find all tab buttons and sort by x position to get visual order. - // The view hierarchy order doesn't match the visual tab order. - let tabItemViews = tabBarView.descendants(withClassName: "NSTabButton") - .sorted { $0.frame.origin.x < $1.frame.origin.x } - - for (index, tabItemView) in tabItemViews.enumerated() { - let locationInTab = tabItemView.convert(locationInWindow, from: nil) - if tabItemView.bounds.contains(locationInTab) { - return index + for (index, tabButton) in tabButtonsInVisualOrder().enumerated() { + let locationInTabButton = tabButton.convert(locationInWindow, from: nil) + if tabButton.bounds.contains(locationInTabButton) { + return (index, tabButton) } } return nil } + + /// Returns the index of the tab button at the given screen point, if any. + func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? { + tabButtonHit(atScreenPoint: screenPoint)?.index + } } diff --git a/macos/Sources/Helpers/Extensions/OSColor+Extension.swift b/macos/Sources/Helpers/Extensions/OSColor+Extension.swift index 54b3e1fab..67246bcf5 100644 --- a/macos/Sources/Helpers/Extensions/OSColor+Extension.swift +++ b/macos/Sources/Helpers/Extensions/OSColor+Extension.swift @@ -1,5 +1,7 @@ import Foundation +#if !DOCK_TILE_PLUGIN import GhosttyKit +#endif extension OSColor { var isLightColor: Bool { @@ -92,7 +94,7 @@ extension OSColor { } // MARK: Ghostty Types - +#if !DOCK_TILE_PLUGIN extension OSColor { /// Create a color from a Ghostty color. convenience init(ghostty: ghostty_config_color_s) { @@ -102,3 +104,4 @@ extension OSColor { self.init(red: red, green: green, blue: blue, alpha: 1) } } +#endif diff --git a/macos/Sources/Helpers/TabTitleEditor.swift b/macos/Sources/Helpers/TabTitleEditor.swift new file mode 100644 index 000000000..c1784112e --- /dev/null +++ b/macos/Sources/Helpers/TabTitleEditor.swift @@ -0,0 +1,336 @@ +import AppKit + +/// Delegate used by ``TabTitleEditor`` to resolve tab-specific behavior. +protocol TabTitleEditorDelegate: AnyObject { + /// Returns whether inline rename should be allowed for the given tab window. + func tabTitleEditor( + _ editor: TabTitleEditor, + canRenameTabFor targetWindow: NSWindow + ) -> Bool + + /// Returns the current title value to seed into the inline editor. + func tabTitleEditor( + _ editor: TabTitleEditor, + titleFor targetWindow: NSWindow + ) -> String + + /// Called when inline editing commits a title for a target tab window. + func tabTitleEditor( + _ editor: TabTitleEditor, + didCommitTitle editedTitle: String, + for targetWindow: NSWindow + ) + + /// Called when inline editing could not start and the host should show a fallback flow. + func tabTitleEditor( + _ editor: TabTitleEditor, + performFallbackRenameFor targetWindow: NSWindow + ) +} + +/// Handles inline tab title editing for native AppKit window tabs. +final class TabTitleEditor: NSObject, NSTextFieldDelegate { + /// Host window containing the tab bar where editing occurs. + private weak var hostWindow: NSWindow? + /// Delegate that provides and commits title data for target tab windows. + private weak var delegate: TabTitleEditorDelegate? + + /// Active inline editor view, if editing is in progress. + private weak var inlineTitleEditor: NSTextField? + /// Tab window currently being edited. + private weak var inlineTitleTargetWindow: NSWindow? + /// Original hidden state for title labels that are temporarily hidden while editing. + private var hiddenLabels: [(label: NSTextField, wasHidden: Bool)] = [] + /// Original button title state restored once editing finishes. + private var buttonState: (button: NSButton, title: String, attributedTitle: NSAttributedString?)? + /// Deferred begin-editing work used to avoid visual flicker on double-click. + private var pendingEditWorkItem: DispatchWorkItem? + + /// Creates a coordinator bound to a host window and rename delegate. + init(hostWindow: NSWindow, delegate: TabTitleEditorDelegate) { + self.hostWindow = hostWindow + self.delegate = delegate + } + + /// Handles double-click events from the host window and begins inline edit if possible. If this + /// returns true then the double click was handled by the coordinator. + func handleDoubleClick(_ event: NSEvent) -> Bool { + // We only want double-clicks + guard event.type == .leftMouseDown, event.clickCount == 2 else { return false } + + // If we don't have a host window to look up the click, we do nothing. + guard let hostWindow else { return false } + + // Find the tab window that is being clicked. + let locationInScreen = hostWindow.convertPoint(toScreen: event.locationInWindow) + guard let tabIndex = hostWindow.tabIndex(atScreenPoint: locationInScreen), + let targetWindow = hostWindow.tabbedWindows?[safe: tabIndex], + delegate?.tabTitleEditor(self, canRenameTabFor: targetWindow) == true + else { return false } + + // We need to start editing in a separate event loop tick, so set that up. + pendingEditWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self, weak targetWindow] in + guard let self, let targetWindow else { return } + if self.beginEditing(for: targetWindow) { + return + } + + // Inline editing failed, so trigger fallback rename whatever it is. + self.delegate?.tabTitleEditor(self, performFallbackRenameFor: targetWindow) + } + + pendingEditWorkItem = workItem + DispatchQueue.main.async(execute: workItem) + return true + } + + /// Begins editing the given target tab window title. Returns true if we're able to start the + /// inline edit. + @discardableResult + func beginEditing(for targetWindow: NSWindow) -> Bool { + // Resolve the visual tab button for the target tab window. We rely on visual order + // since native tab view hierarchy order does not necessarily match what is on screen. + guard let hostWindow, + let tabbedWindows = hostWindow.tabbedWindows, + let tabIndex = tabbedWindows.firstIndex(of: targetWindow), + let tabButton = hostWindow.tabButtonsInVisualOrder()[safe: tabIndex], + delegate?.tabTitleEditor(self, canRenameTabFor: targetWindow) == true + else { return false } + + // If we have a pending edit, we need to cancel it because we got + // called to start edit explicitly. + pendingEditWorkItem?.cancel() + pendingEditWorkItem = nil + finishEditing(commit: true) + + // Build the editor using title text and style derived from the tab's existing label. + let titleLabels = tabButton + .descendants(withClassName: "NSTextField") + .compactMap { $0 as? NSTextField } + let editedTitle = delegate?.tabTitleEditor(self, titleFor: targetWindow) ?? targetWindow.title + let sourceLabel = sourceTabTitleLabel(from: titleLabels, matching: editedTitle) + let editorFrame = tabTitleEditorFrame(for: tabButton, sourceLabel: sourceLabel) + guard editorFrame.width >= 20, editorFrame.height >= 14 else { return false } + + let editor = NSTextField(frame: editorFrame) + editor.delegate = self + editor.stringValue = editedTitle + editor.alignment = sourceLabel?.alignment ?? .center + editor.isBordered = false + editor.isBezeled = false + editor.drawsBackground = false + editor.focusRingType = .none + editor.lineBreakMode = .byClipping + if let editorCell = editor.cell as? NSTextFieldCell { + editorCell.wraps = false + editorCell.usesSingleLineMode = true + editorCell.isScrollable = true + } + if let sourceLabel { + applyTextStyle(to: editor, from: sourceLabel, title: editedTitle) + } + + // Hide it until the tab button has finished layout so we can avoid flicker. + editor.isHidden = true + + inlineTitleEditor = editor + inlineTitleTargetWindow = targetWindow + + // Temporarily hide native title label views while editing so only the text field is visible. + CATransaction.begin() + CATransaction.setDisableActions(true) + hiddenLabels = titleLabels.map { ($0, $0.isHidden) } + for label in titleLabels { + label.isHidden = true + } + if let tabButton = tabButton as? NSButton { + buttonState = (tabButton, tabButton.title, tabButton.attributedTitle) + tabButton.title = "" + tabButton.attributedTitle = NSAttributedString(string: "") + } else { + buttonState = nil + } + tabButton.layoutSubtreeIfNeeded() + tabButton.displayIfNeeded() + tabButton.addSubview(editor) + CATransaction.commit() + + // Focus after insertion so AppKit has created the field editor for this text field. + DispatchQueue.main.async { [weak hostWindow, weak editor] in + guard let hostWindow, let editor else { return } + editor.isHidden = false + hostWindow.makeFirstResponder(editor) + if let fieldEditor = editor.currentEditor() as? NSTextView, + let editorFont = editor.font { + fieldEditor.font = editorFont + var typingAttributes = fieldEditor.typingAttributes + typingAttributes[.font] = editorFont + fieldEditor.typingAttributes = typingAttributes + } + editor.currentEditor()?.selectAll(nil) + } + + return true + } + + /// Finishes any in-flight inline edit and optionally commits the edited title. + func finishEditing(commit: Bool) { + // If we're pending starting a new edit, cancel it. + pendingEditWorkItem?.cancel() + pendingEditWorkItem = nil + + // To finish editing we need a current editor. + guard let editor = inlineTitleEditor else { return } + let editedTitle = editor.stringValue + let targetWindow = inlineTitleTargetWindow + + // Clear coordinator references first so re-entrant paths don't see stale state. + editor.delegate = nil + inlineTitleEditor = nil + inlineTitleTargetWindow = nil + + // Make sure the window grabs focus again + if let hostWindow { + if let currentEditor = editor.currentEditor(), hostWindow.firstResponder === currentEditor { + hostWindow.makeFirstResponder(nil) + } else if hostWindow.firstResponder === editor { + hostWindow.makeFirstResponder(nil) + } + } + + editor.removeFromSuperview() + + // Restore original tab title presentation. + for (label, wasHidden) in hiddenLabels { + label.isHidden = wasHidden + } + hiddenLabels.removeAll() + + if let buttonState { + buttonState.button.title = buttonState.title + buttonState.button.attributedTitle = buttonState.attributedTitle ?? NSAttributedString(string: buttonState.title) + } + self.buttonState = nil + + // Delegate owns title persistence semantics (including empty-title handling). + guard commit, let targetWindow else { return } + delegate?.tabTitleEditor(self, didCommitTitle: editedTitle, for: targetWindow) + } + + /// Chooses an editor frame that aligns with the tab title within the tab button. + private func tabTitleEditorFrame(for tabButton: NSView, sourceLabel: NSTextField?) -> NSRect { + let bounds = tabButton.bounds + let horizontalInset: CGFloat = 6 + var frame = bounds.insetBy(dx: horizontalInset, dy: 0) + + if let sourceLabel { + let labelFrame = tabButton.convert(sourceLabel.bounds, from: sourceLabel) + frame.origin.y = labelFrame.minY + frame.size.height = labelFrame.height + } + + return frame.integral + } + + /// Selects the best title label candidate from private tab button subviews. + private func sourceTabTitleLabel(from labels: [NSTextField], matching title: String) -> NSTextField? { + let expected = title.trimmingCharacters(in: .whitespacesAndNewlines) + if !expected.isEmpty { + // Prefer a visible exact title match when we can find one. + if let exactVisible = labels.first(where: { + !$0.isHidden && + $0.alphaValue > 0.01 && + $0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected + }) { + return exactVisible + } + + // Fall back to any exact match, including hidden labels. + if let exactAny = labels.first(where: { + $0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected + }) { + return exactAny + } + } + + // Otherwise heuristically choose the largest visible, centered label first. + let visibleNonEmpty = labels.filter { + !$0.isHidden && + $0.alphaValue > 0.01 && + !$0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + if let centeredVisible = visibleNonEmpty + .filter({ $0.alignment == .center }) + .max(by: { $0.bounds.width < $1.bounds.width }) { + return centeredVisible + } + + if let visible = visibleNonEmpty.max(by: { $0.bounds.width < $1.bounds.width }) { + return visible + } + + return labels.max(by: { $0.bounds.width < $1.bounds.width }) + } + + /// Copies text styling from the source tab label onto the inline editor. + private func applyTextStyle(to editor: NSTextField, from label: NSTextField, title: String) { + var attributes: [NSAttributedString.Key: Any] = [:] + if label.attributedStringValue.length > 0 { + attributes = label.attributedStringValue.attributes(at: 0, effectiveRange: nil) + } + + if attributes[.font] == nil, let font = label.font { + attributes[.font] = font + } + + if attributes[.foregroundColor] == nil { + attributes[.foregroundColor] = label.textColor + } + + if let font = attributes[.font] as? NSFont { + editor.font = font + } + + if let textColor = attributes[.foregroundColor] as? NSColor { + editor.textColor = textColor + } + + if !attributes.isEmpty { + editor.attributedStringValue = NSAttributedString(string: title, attributes: attributes) + } else { + editor.stringValue = title + } + } + + // MARK: NSTextFieldDelegate + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + guard control === inlineTitleEditor else { return false } + + // Enter commits and exits inline edit. + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + finishEditing(commit: true) + return true + } + + // Escape cancels and restores the previous tab title. + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + finishEditing(commit: false) + return true + } + + return false + } + + func controlTextDidEndEditing(_ obj: Notification) { + guard let inlineTitleEditor, + let finishedEditor = obj.object as? NSTextField, + finishedEditor === inlineTitleEditor + else { return } + + // Blur/end-edit commits, matching standard NSTextField behavior. + finishEditing(commit: true) + } +} diff --git a/macos/Tests/ColorizedGhosttyIconTests.swift b/macos/Tests/ColorizedGhosttyIconTests.swift new file mode 100644 index 000000000..bf2963f33 --- /dev/null +++ b/macos/Tests/ColorizedGhosttyIconTests.swift @@ -0,0 +1,144 @@ +import AppKit +import Foundation +import Testing +@testable import Ghostty + +struct ColorizedGhosttyIconTests { + private func makeIcon( + screenColors: [NSColor] = [ + NSColor(hex: "#112233")!, + NSColor(hex: "#AABBCC")!, + ], + ghostColor: NSColor = NSColor(hex: "#445566")!, + frame: Ghostty.MacOSIconFrame = .aluminum + ) -> ColorizedGhosttyIcon { + .init(screenColors: screenColors, ghostColor: ghostColor, frame: frame) + } + + // MARK: - Codable + + @Test func codableRoundTripPreservesIcon() throws { + let icon = makeIcon(frame: .chrome) + let data = try JSONEncoder().encode(icon) + let decoded = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + + #expect(decoded == icon) + #expect(decoded.screenColors.compactMap(\.hexString) == ["#112233", "#AABBCC"]) + #expect(decoded.ghostColor.hexString == "#445566") + #expect(decoded.frame == .chrome) + } + + @Test func encodingWritesVersionAndHexColors() throws { + let icon = makeIcon(frame: .plastic) + let data = try JSONEncoder().encode(icon) + + let payload = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(payload["version"] as? Int == 1) + #expect(payload["screenColors"] as? [String] == ["#112233", "#AABBCC"]) + #expect(payload["ghostColor"] as? String == "#445566") + #expect(payload["frame"] as? String == "plastic") + } + + @Test func decodesLegacyV0PayloadWithoutVersion() throws { + let data = Data(""" + { + "screenColors": ["#112233", "#AABBCC"], + "ghostColor": "#445566", + "frame": "beige" + } + """.utf8) + + let decoded = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + #expect(decoded.screenColors.compactMap(\.hexString) == ["#112233", "#AABBCC"]) + #expect(decoded.ghostColor.hexString == "#445566") + #expect(decoded.frame == .beige) + } + + @Test func decodingUnsupportedVersionThrowsDataCorrupted() { + let data = Data(""" + { + "version": 99, + "screenColors": ["#112233", "#AABBCC"], + "ghostColor": "#445566", + "frame": "chrome" + } + """.utf8) + + do { + _ = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + Issue.record("Expected decode to fail for unsupported version") + } catch let DecodingError.dataCorrupted(context) { + #expect(context.debugDescription.contains("Unsupported ColorizedGhosttyIcon version")) + } catch { + Issue.record("Expected DecodingError.dataCorrupted, got: \(error)") + } + } + + @Test func decodingInvalidGhostColorThrows() { + let data = Data(""" + { + "version": 1, + "screenColors": ["#112233", "#AABBCC"], + "ghostColor": "not-a-color", + "frame": "chrome" + } + """.utf8) + + do { + _ = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + Issue.record("Expected decode to fail for invalid ghost color") + } catch let DecodingError.dataCorrupted(context) { + #expect(context.debugDescription.contains("Failed to decode ghost color")) + } catch { + Issue.record("Expected DecodingError.dataCorrupted, got: \(error)") + } + } + + @Test func decodingInvalidScreenColorsDropsInvalidEntries() throws { + let data = Data(""" + { + "version": 1, + "screenColors": ["#112233", "invalid", "#AABBCC"], + "ghostColor": "#445566", + "frame": "chrome" + } + """.utf8) + + let decoded = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + #expect(decoded.screenColors.compactMap(\.hexString) == ["#112233", "#AABBCC"]) + } + + // MARK: - Equatable + + @Test func equatableUsesHexColorAndFrameValues() { + let lhs = makeIcon( + screenColors: [ + NSColor(red: 0x11 / 255.0, green: 0x22 / 255.0, blue: 0x33 / 255.0, alpha: 1.0), + NSColor(red: 0xAA / 255.0, green: 0xBB / 255.0, blue: 0xCC / 255.0, alpha: 1.0), + ], + ghostColor: NSColor(red: 0x44 / 255.0, green: 0x55 / 255.0, blue: 0x66 / 255.0, alpha: 1.0), + frame: .chrome + ) + let rhs = makeIcon(frame: .chrome) + + #expect(lhs == rhs) + } + + @Test func equatableReturnsFalseForDifferentFrame() { + let lhs = makeIcon(frame: .aluminum) + let rhs = makeIcon(frame: .chrome) + #expect(lhs != rhs) + } + + @Test func equatableReturnsFalseForDifferentScreenColors() { + let lhs = makeIcon(screenColors: [NSColor(hex: "#112233")!, NSColor(hex: "#AABBCC")!]) + let rhs = makeIcon(screenColors: [NSColor(hex: "#112233")!, NSColor(hex: "#CCBBAA")!]) + #expect(lhs != rhs) + } + + @Test func equatableReturnsFalseForDifferentGhostColor() { + let lhs = makeIcon(ghostColor: NSColor(hex: "#445566")!) + let rhs = makeIcon(ghostColor: NSColor(hex: "#665544")!) + #expect(lhs != rhs) + } +} diff --git a/nix/pkgs/blessed.nix b/nix/pkgs/blessed.nix index 8b6728f43..a015e70b6 100644 --- a/nix/pkgs/blessed.nix +++ b/nix/pkgs/blessed.nix @@ -1,22 +1,24 @@ { lib, buildPythonPackage, - fetchPypi, + fetchFromGitHub, pythonOlder, flit-core, six, wcwidth, }: -buildPythonPackage rec { +buildPythonPackage { pname = "blessed"; - version = "1.23.0"; + version = "unstable-2026-02-23"; pyproject = true; - disabled = pythonOlder "3.7"; + disabled = pythonOlder "3.8"; - src = fetchPypi { - inherit pname version; - hash = "sha256-VlkaMpZvcE9hMfFACvQVHZ6PX0FEEzpcoDQBl2Pe53s="; + src = fetchFromGitHub { + owner = "jquast"; + repo = "blessed"; + rev = "master"; + hash = "sha256-ROd/O9pfqnF5DHXqoz+tkl1jQJSZad3Ta1h+oC3+gvY="; }; build-system = [flit-core]; @@ -27,6 +29,7 @@ buildPythonPackage rec { ]; doCheck = false; + dontCheckRuntimeDeps = true; meta = with lib; { homepage = "https://github.com/jquast/blessed"; diff --git a/nix/pkgs/ucs-detect.nix b/nix/pkgs/ucs-detect.nix index 07ec6c2fc..73721b62a 100644 --- a/nix/pkgs/ucs-detect.nix +++ b/nix/pkgs/ucs-detect.nix @@ -1,36 +1,42 @@ { lib, buildPythonPackage, - fetchPypi, + fetchFromGitHub, pythonOlder, - setuptools, + hatchling, # Dependencies blessed, wcwidth, pyyaml, + prettytable, + requests, }: -buildPythonPackage rec { +buildPythonPackage { pname = "ucs-detect"; - version = "1.0.8"; + version = "unstable-2026-02-23"; pyproject = true; disabled = pythonOlder "3.8"; - src = fetchPypi { - inherit version; - pname = "ucs_detect"; - hash = "sha256-ihB+tZCd6ykdeXYxc6V1Q6xALQ+xdCW5yqSL7oppqJc="; + src = fetchFromGitHub { + owner = "jquast"; + repo = "ucs-detect"; + rev = "master"; + hash = "sha256-x7BD14n1/mP9bzjM6DPqc5R1Fk/HLLycl4o41KV+xAE="; }; dependencies = [ blessed wcwidth pyyaml + prettytable + requests ]; - nativeBuildInputs = [setuptools]; + nativeBuildInputs = [hatchling]; doCheck = false; + dontCheckRuntimeDeps = true; meta = with lib; { description = "Measures number of Terminal column cells of wide-character codes"; diff --git a/nix/pkgs/wcwidth.nix b/nix/pkgs/wcwidth.nix new file mode 100644 index 000000000..4bbd1373b --- /dev/null +++ b/nix/pkgs/wcwidth.nix @@ -0,0 +1,27 @@ +{ + lib, + buildPythonPackage, + fetchPypi, + hatchling, +}: +buildPythonPackage rec { + pname = "wcwidth"; + version = "0.6.0"; + pyproject = true; + + src = fetchPypi { + inherit pname version; + hash = "sha256-zcTkJi1u+aGlfgGDhMvrEgjYq7xkF2An4sJFXIExMVk="; + }; + + build-system = [hatchling]; + + doCheck = false; + + meta = with lib; { + description = "Measures the displayed width of unicode strings in a terminal"; + homepage = "https://github.com/jquast/wcwidth"; + license = licenses.mit; + maintainers = []; + }; +} diff --git a/pkg/android-ndk/build.zig b/pkg/android-ndk/build.zig new file mode 100644 index 000000000..5b005665b --- /dev/null +++ b/pkg/android-ndk/build.zig @@ -0,0 +1,207 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +pub fn build(_: *std.Build) !void {} + +// Configure the step to point to the Android NDK for libc and include +// paths. This requires the Android NDK installed in the system and +// setting the appropriate environment variables or installing the NDK +// in the default location. +// +// The environment variables can be set as follows: +// - `ANDROID_NDK_HOME`: Directly points to the NDK path, including the version. +// - `ANDROID_HOME` or `ANDROID_SDK_ROOT`: Points to the Android SDK path; +// latest available NDK will be automatically selected. +// +// NB: This is a workaround until zig natively supports bionic +// cross-compilation (ziglang/zig#23906). +pub fn addPaths(b: *std.Build, step: *std.Build.Step.Compile) !void { + const Cache = struct { + const Key = struct { + arch: std.Target.Cpu.Arch, + abi: std.Target.Abi, + api_level: u32, + }; + + var map: std.AutoHashMapUnmanaged(Key, ?struct { + libc: std.Build.LazyPath, + cpp_include: std.Build.LazyPath, + lib: std.Build.LazyPath, + }) = .empty; + }; + + const target = step.rootModuleTarget(); + const gop = try Cache.map.getOrPut(b.allocator, .{ + .arch = target.cpu.arch, + .abi = target.abi, + .api_level = target.os.version_range.linux.android, + }); + + if (!gop.found_existing) { + const ndk_path = findNDKPath(b) orelse return error.AndroidNDKNotFound; + + const ndk_triple = ndkTriple(target) orelse { + gop.value_ptr.* = null; + return error.AndroidNDKUnsupportedTarget; + }; + + const host = hostTag() orelse { + gop.value_ptr.* = null; + return error.AndroidNDKUnsupportedHost; + }; + + const sysroot = b.pathJoin(&.{ + ndk_path, + "toolchains", + "llvm", + "prebuilt", + host, + "sysroot", + }); + const include_dir = b.pathJoin(&.{ + sysroot, + "usr", + "include", + }); + const sys_include_dir = b.pathJoin(&.{ + sysroot, + "usr", + "include", + ndk_triple, + }); + const c_runtime_dir = b.pathJoin(&.{ + sysroot, + "usr", + "lib", + ndk_triple, + b.fmt("{d}", .{target.os.version_range.linux.android}), + }); + const lib = b.pathJoin(&.{ + sysroot, + "usr", + "lib", + ndk_triple, + }); + const cpp_include = b.pathJoin(&.{ + sysroot, + "usr", + "include", + "c++", + "v1", + }); + + const libc_txt = b.fmt( + \\include_dir={s} + \\sys_include_dir={s} + \\crt_dir={s} + \\msvc_lib_dir= + \\kernel32_lib_dir= + \\gcc_dir= + , .{ include_dir, sys_include_dir, c_runtime_dir }); + + const wf = b.addWriteFiles(); + const libc_path = wf.add("libc.txt", libc_txt); + + gop.value_ptr.* = .{ + .libc = libc_path, + .cpp_include = .{ .cwd_relative = cpp_include }, + .lib = .{ .cwd_relative = lib }, + }; + } + + const value = gop.value_ptr.* orelse return error.AndroidNDKNotFound; + + step.setLibCFile(value.libc); + step.root_module.addSystemIncludePath(value.cpp_include); + step.root_module.addLibraryPath(value.lib); +} + +fn findNDKPath(b: *std.Build) ?[]const u8 { + // Check if user has set the environment variable for the NDK path. + if (std.process.getEnvVarOwned(b.allocator, "ANDROID_NDK_HOME") catch null) |value| { + if (value.len == 0) return null; + var dir = std.fs.openDirAbsolute(value, .{}) catch return null; + defer dir.close(); + return value; + } + + // Check the common environment variables for the Android SDK path and look for the NDK inside it. + inline for (.{ "ANDROID_HOME", "ANDROID_SDK_ROOT" }) |env| { + if (std.process.getEnvVarOwned(b.allocator, env) catch null) |sdk| { + if (sdk.len > 0) { + if (findLatestNDK(b, sdk)) |ndk| return ndk; + } + } + } + + // As a fallback, we assume the most common/default SDK path based on the OS. + const home = std.process.getEnvVarOwned( + b.allocator, + if (builtin.os.tag == .windows) "LOCALAPPDATA" else "HOME", + ) catch return null; + + const default_sdk_path = b.pathJoin( + &.{ + home, + switch (builtin.os.tag) { + .linux => "Android/sdk", + .macos => "Library/Android/Sdk", + .windows => "Android/Sdk", + else => return null, + }, + }, + ); + + return findLatestNDK(b, default_sdk_path); +} + +fn findLatestNDK(b: *std.Build, sdk_path: []const u8) ?[]const u8 { + const ndk_dir = b.pathJoin(&.{ sdk_path, "ndk" }); + var dir = std.fs.openDirAbsolute(ndk_dir, .{ .iterate = true }) catch return null; + defer dir.close(); + + var latest_: ?struct { + name: []const u8, + version: std.SemanticVersion, + } = null; + var iterator = dir.iterate(); + + while (iterator.next() catch null) |file| { + if (file.kind != .directory) continue; + const version = std.SemanticVersion.parse(file.name) catch continue; + if (latest_) |latest| { + if (version.order(latest.version) != .gt) continue; + } + latest_ = .{ + .name = file.name, + .version = version, + }; + } + + const latest = latest_ orelse return null; + + return b.pathJoin(&.{ sdk_path, "ndk", latest.name }); +} + +fn hostTag() ?[]const u8 { + return switch (builtin.os.tag) { + .linux => "linux-x86_64", + // All darwin hosts use the same prebuilt binaries + // (https://developer.android.com/ndk/guides/other_build_systems). + .macos => "darwin-x86_64", + .windows => "windows-x86_64", + else => null, + }; +} + +// We must map the target architecture to the corresponding NDK triple following the NDK +// documentation: https://android.googlesource.com/platform/ndk/+/master/docs/BuildSystemMaintainers.md#architectures +fn ndkTriple(target: std.Target) ?[]const u8 { + return switch (target.cpu.arch) { + .arm => "arm-linux-androideabi", + .aarch64 => "aarch64-linux-android", + .x86 => "i686-linux-android", + .x86_64 => "x86_64-linux-android", + else => null, + }; +} diff --git a/pkg/android-ndk/build.zig.zon b/pkg/android-ndk/build.zig.zon new file mode 100644 index 000000000..eb0de6820 --- /dev/null +++ b/pkg/android-ndk/build.zig.zon @@ -0,0 +1,10 @@ +.{ + .name = .android_ndk, + .version = "0.0.2", + .fingerprint = 0xee68d62c5a97b68b, + .dependencies = .{}, + .paths = .{ + "build.zig", + "build.zig.zon", + }, +} diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index 3715baf4a..b6e188b13 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -31,6 +31,11 @@ pub fn build(b: *std.Build) !void { try apple_sdk.addPaths(b, lib); } + if (target.result.abi.isAndroid()) { + const android_ndk = @import("android_ndk"); + try android_ndk.addPaths(b, lib); + } + var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); try flags.appendSlice(b.allocator, &.{ diff --git a/pkg/highway/build.zig.zon b/pkg/highway/build.zig.zon index 0777fcb7a..4870d1db5 100644 --- a/pkg/highway/build.zig.zon +++ b/pkg/highway/build.zig.zon @@ -12,5 +12,6 @@ }, .apple_sdk = .{ .path = "../apple-sdk" }, + .android_ndk = .{ .path = "../android-ndk" }, }, } diff --git a/pkg/simdutf/build.zig b/pkg/simdutf/build.zig index 3123cab21..8dcd141c1 100644 --- a/pkg/simdutf/build.zig +++ b/pkg/simdutf/build.zig @@ -20,6 +20,11 @@ pub fn build(b: *std.Build) !void { try apple_sdk.addPaths(b, lib); } + if (target.result.abi.isAndroid()) { + const android_ndk = @import("android_ndk"); + try android_ndk.addPaths(b, lib); + } + var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); // Zig 0.13 bug: https://github.com/ziglang/zig/issues/20414 diff --git a/pkg/simdutf/build.zig.zon b/pkg/simdutf/build.zig.zon index cd81c841e..afbef5418 100644 --- a/pkg/simdutf/build.zig.zon +++ b/pkg/simdutf/build.zig.zon @@ -5,5 +5,6 @@ .paths = .{""}, .dependencies = .{ .apple_sdk = .{ .path = "../apple-sdk" }, + .android_ndk = .{ .path = "../android-ndk" }, }, } diff --git a/pkg/utfcpp/build.zig b/pkg/utfcpp/build.zig index e06813b83..08efb4ac8 100644 --- a/pkg/utfcpp/build.zig +++ b/pkg/utfcpp/build.zig @@ -19,6 +19,11 @@ pub fn build(b: *std.Build) !void { try apple_sdk.addPaths(b, lib); } + if (target.result.abi.isAndroid()) { + const android_ndk = @import("android_ndk"); + try android_ndk.addPaths(b, lib); + } + var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); diff --git a/pkg/utfcpp/build.zig.zon b/pkg/utfcpp/build.zig.zon index eff395a60..1077e9655 100644 --- a/pkg/utfcpp/build.zig.zon +++ b/pkg/utfcpp/build.zig.zon @@ -12,5 +12,6 @@ }, .apple_sdk = .{ .path = "../apple-sdk" }, + .android_ndk = .{ .path = "../android-ndk" }, }, } diff --git a/po/it_IT.UTF-8.po b/po/it_IT.UTF-8.po index 87fc0c756..ea2031382 100644 --- a/po/it_IT.UTF-8.po +++ b/po/it_IT.UTF-8.po @@ -1,4 +1,4 @@ -# Italian translations for com.mitchellh.ghostty package +# Italian translations for com.mitchellh.ghostty package. # Traduzioni italiane per il pacchetto com.mitchellh.ghostty. # Copyright (C) 2025 "Mitchell Hashimoto, Ghostty contributors" # This file is distributed under the same license as the com.mitchellh.ghostty package. @@ -20,7 +20,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Apri in Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -93,23 +93,23 @@ msgstr "Ghostty: Ispettore del terminale" #: src/apprt/gtk/ui/1.2/search-overlay.blp:29 msgid "Find…" -msgstr "" +msgstr "Cerca…" #: src/apprt/gtk/ui/1.2/search-overlay.blp:64 msgid "Previous Match" -msgstr "" +msgstr "Corrispondenza precedente" #: src/apprt/gtk/ui/1.2/search-overlay.blp:74 msgid "Next Match" -msgstr "" +msgstr "Corrispondenza successiva" #: src/apprt/gtk/ui/1.2/surface.blp:6 msgid "Oh, no." -msgstr "" +msgstr "Oh no!" #: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Unable to acquire an OpenGL context for rendering." -msgstr "" +msgstr "Impossibile ottenere un contesto OpenGL per il rendering." #: src/apprt/gtk/ui/1.2/surface.blp:97 msgid "" @@ -117,10 +117,13 @@ msgid "" "through the content, but no input events will be sent to the running " "application." msgstr "" +"Questo terminale è in modalità di sola lettura. Puoi ancora vedere, " +"selezionare e scorrere il contenuto, ma non verrà inviato alcun evento " +"di input all'applicazione in esecuzione." #: src/apprt/gtk/ui/1.2/surface.blp:107 msgid "Read-only" -msgstr "" +msgstr "Sola lettura" #: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:200 msgid "Copy" @@ -132,7 +135,7 @@ msgstr "Incolla" #: src/apprt/gtk/ui/1.2/surface.blp:270 msgid "Notify on Next Command Finish" -msgstr "" +msgstr "Notifica al termine del prossimo comando" #: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:273 msgid "Clear" @@ -177,7 +180,7 @@ msgstr "Scheda" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Cambia titolo scheda…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -311,15 +314,15 @@ msgstr "" #: src/apprt/gtk/class/surface.zig:1108 msgid "Command Finished" -msgstr "" +msgstr "Comando terminato" #: src/apprt/gtk/class/surface.zig:1109 msgid "Command Succeeded" -msgstr "" +msgstr "Comando riuscito" #: src/apprt/gtk/class/surface.zig:1110 msgid "Command Failed" -msgstr "" +msgstr "Comando fallito" #: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command succeeded" @@ -335,7 +338,7 @@ msgstr "Cambia il titolo del terminale" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Cambia il titolo della scheda" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" diff --git a/po/lt_LT.UTF-8.po b/po/lt_LT.UTF-8.po index ed0ec86e6..ba4995ddc 100644 --- a/po/lt_LT.UTF-8.po +++ b/po/lt_LT.UTF-8.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-02-17 23:16+0100\n" -"PO-Revision-Date: 2026-02-10 08:14+0100\n" +"PO-Revision-Date: 2026-02-20 12:13+0100\n" "Last-Translator: Tadas Lotuzas \n" "Language-Team: Language LT\n" "Language: LT\n" @@ -18,7 +18,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Atidaryti su Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -177,7 +177,7 @@ msgstr "Kortelė" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Keisti kortelės pavadinimą…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -334,7 +334,7 @@ msgstr "Keisti terminalo pavadinimą" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Keisti kortelės pavadinimą" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" diff --git a/po/mk_MK.UTF-8.po b/po/mk_MK.UTF-8.po index 539283271..0307fff95 100644 --- a/po/mk_MK.UTF-8.po +++ b/po/mk_MK.UTF-8.po @@ -19,7 +19,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Отвори во Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -179,7 +179,7 @@ msgstr "Јазиче" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Промени наслов на јазиче…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -336,7 +336,7 @@ msgstr "Промени наслов на терминал" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Промени наслов на јазиче" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" diff --git a/po/nl_NL.UTF-8.po b/po/nl_NL.UTF-8.po index 9caee41a4..1d3e1014c 100644 --- a/po/nl_NL.UTF-8.po +++ b/po/nl_NL.UTF-8.po @@ -9,7 +9,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-02-17 23:16+0100\n" -"PO-Revision-Date: 2026-02-09 20:39+0100\n" +"PO-Revision-Date: 2026-02-18 20:59+0100\n" "Last-Translator: Nico Geesink \n" "Language-Team: Dutch \n" "Language: nl\n" @@ -20,7 +20,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Open in Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -180,7 +180,7 @@ msgstr "Tabblad" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Wijzig tabbladtitel…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -337,7 +337,7 @@ msgstr "Titel van de terminal wijzigen" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Wijzig tabbladtitel" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" diff --git a/po/zh_CN.UTF-8.po b/po/zh_CN.UTF-8.po index 92b79ee21..8e7e241fc 100644 --- a/po/zh_CN.UTF-8.po +++ b/po/zh_CN.UTF-8.po @@ -19,7 +19,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "在 Ghostty 中打开" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -175,7 +175,7 @@ msgstr "标签页" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "更改标签页标题…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -326,7 +326,7 @@ msgstr "更改终端标题" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "更改标签页标题" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" diff --git a/po/zh_TW.UTF-8.po b/po/zh_TW.UTF-8.po index 25dacd566..cacdc8acb 100644 --- a/po/zh_TW.UTF-8.po +++ b/po/zh_TW.UTF-8.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-02-17 23:16+0100\n" -"PO-Revision-Date: 2026-02-10 15:32+0800\n" +"PO-Revision-Date: 2026-02-18 13:58+0800\n" "Last-Translator: Yi-Jyun Pan \n" "Language-Team: Chinese (traditional)\n" "Language: zh_TW\n" @@ -18,7 +18,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "在 Ghostty 中開啟" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index 15e126642..24caa4990 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -330,6 +330,10 @@ pub const Tab = extern struct { glib.free(@ptrCast(@constCast(v))); priv.title = null; } + if (priv.title_override) |v| { + glib.free(@ptrCast(@constCast(v))); + priv.title_override = null; + } gobject.Object.virtual_methods.finalize.call( Class.parent, diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index dc33abd21..a79945991 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -339,6 +339,7 @@ pub const Window = extern struct { .init("close-tab", actionCloseTab, s_variant_type), .init("new-tab", actionNewTab, null), .init("new-window", actionNewWindow, null), + .init("prompt-surface-title", actionPromptSurfaceTitle, null), .init("prompt-tab-title", actionPromptTabTitle, null), .init("prompt-context-tab-title", actionPromptContextTabTitle, null), .init("ring-bell", actionRingBell, null), @@ -1803,6 +1804,14 @@ pub const Window = extern struct { tab.promptTabTitle(); } + fn actionPromptSurfaceTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.prompt_surface_title); + } + fn actionPromptTabTitle( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index a139f8cc5..b66a93093 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -243,7 +243,7 @@ menu main_menu { item { label: _("Change Title…"); - action: "win.prompt-title"; + action: "win.prompt-surface-title"; } item { diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 2f3d4a124..6d44c62b6 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -62,6 +62,13 @@ pub fn initShared( .{ .include_extensions = &.{".h"} }, ); + if (lib.rootModuleTarget().abi.isAndroid()) { + // Support 16kb page sizes, required for Android 15+. + lib.link_z_max_page_size = 16384; // 16kb + + try @import("android_ndk").addPaths(b, lib); + } + if (lib.rootModuleTarget().os.tag.isDarwin()) { // Self-hosted x86_64 doesn't work for darwin. It may not work // for other platforms too but definitely darwin. diff --git a/src/build/uucode_config.zig b/src/build/uucode_config.zig index d39d4d1e1..2bb0d4508 100644 --- a/src/build/uucode_config.zig +++ b/src/build/uucode_config.zig @@ -100,6 +100,7 @@ pub const tables = [_]config.Table{ }, .fields = &.{ width.field("width"), + wcwidth.field("wcwidth_zero_in_grapheme"), grapheme_break_no_control.field("grapheme_break_no_control"), is_symbol.field("is_symbol"), d.field("is_emoji_vs_base"), diff --git a/src/config/Config.zig b/src/config/Config.zig index 4e1ed1f4b..0a6ca9c9f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -39,6 +39,7 @@ pub const Path = @import("path.zig").Path; pub const RepeatablePath = @import("path.zig").RepeatablePath; const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig"); const KeyRemapSet = @import("../input/key_mods.zig").RemapSet; +const string = @import("string.zig"); // We do this instead of importing all of terminal/main.zig to // limit the dependency graph. This is important because some things @@ -1857,6 +1858,12 @@ class: ?[:0]const u8 = null, /// If an invalid key is pressed, the sequence ends but the table remains /// active. /// +/// * Chain actions work within tables, the `chain` keyword applies to +/// the most recently defined binding in the table. e.g. if you set +/// `table/ctrl+a=new_window` you can chain by using `chain=text:hello`. +/// Important: chain itself doesn't get prefixed with the table name, +/// since it applies to the most recent binding in any table. +/// /// * Prefixes like `global:` work within tables: /// `foo/global:ctrl+a=new_window`. /// @@ -2939,6 +2946,20 @@ keybind: Keybinds = .{}, /// /// * `vec4 iPreviousCursorColor` - Color of the previous terminal cursor. /// +/// * `vec4 iCurrentCursorStyle` - Style of the terminal cursor +/// +/// Macros simplified use are defined for the various cursor styles: +/// +/// - `CURSORSTYLE_BLOCK` or `0` +/// - `CURSORSTYLE_BLOCK_HOLLOW` or `1` +/// - `CURSORSTYLE_BAR` or `2` +/// - `CURSORSTYLE_UNDERLINE` or `3` +/// - `CURSORSTYLE_LOCK` or `4` +/// +/// * `vec4 iPreviousCursorStyle` - Style of the previous terminal cursor +/// +/// * `vec4 iCursorVisible` - Visibility of the terminal cursor. +/// /// * `float iTimeCursorChange` - Timestamp of terminal cursor change. /// /// When the terminal cursor changes position or color, this is set to @@ -5965,22 +5986,15 @@ pub const SelectionWordChars = struct { pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { const value = input orelse return error.ValueRequired; - // Parse UTF-8 string into codepoints + // Parse string with Zig escape sequence support into codepoints var list: std.ArrayList(u21) = .empty; defer list.deinit(alloc); // Always include null as first boundary try list.append(alloc, 0); - // Parse the UTF-8 string - const utf8_view = std.unicode.Utf8View.init(value) catch { - // Invalid UTF-8, just use null boundary - self.codepoints = try list.toOwnedSlice(alloc); - return; - }; - - var utf8_it = utf8_view.iterator(); - while (utf8_it.nextCodepoint()) |codepoint| { + var it = string.codepointIterator(value); + while (it.next() catch return error.InvalidValue) |codepoint| { try list.append(alloc, codepoint); } @@ -6033,6 +6047,56 @@ pub const SelectionWordChars = struct { try testing.expectEqual(@as(u21, ';'), chars.codepoints[3]); try testing.expectEqual(@as(u21, ','), chars.codepoints[4]); } + + test "parseCLI escape sequences" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // \t escape should be parsed as tab + var chars: Self = .{}; + try chars.parseCLI(alloc, " \\t;,"); + + try testing.expectEqual(@as(usize, 5), chars.codepoints.len); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, ' '), chars.codepoints[1]); + try testing.expectEqual(@as(u21, '\t'), chars.codepoints[2]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[3]); + try testing.expectEqual(@as(u21, ','), chars.codepoints[4]); + } + + test "parseCLI backslash escape" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // \\ should be parsed as a single backslash + var chars: Self = .{}; + try chars.parseCLI(alloc, "\\\\;"); + + try testing.expectEqual(@as(usize, 3), chars.codepoints.len); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, '\\'), chars.codepoints[1]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[2]); + } + + test "parseCLI unicode escape" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // \u{2502} should be parsed as │ + var chars: Self = .{}; + try chars.parseCLI(alloc, "\\u{2502};"); + + try testing.expectEqual(@as(usize, 3), chars.codepoints.len); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, '│'), chars.codepoints[1]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[2]); + } }; /// FontVariation is a repeatable configuration value that sets a single @@ -6169,6 +6233,15 @@ pub const Keybinds = struct { /// which allows all table names to be available without reservation. tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty, + /// The most recent binding target for `chain=` additions. + /// + /// This is intentionally tracked at the Keybinds level so that chains can + /// apply across table boundaries according to parse order. + chain_target: union(enum) { + root, + table: []const u8, + } = .root, + pub fn init(self: *Keybinds, alloc: Allocator) !void { // We don't clear the memory because it's in the arena and unlikely // to be free-able anyways (since arenas can only clear the last @@ -6176,6 +6249,7 @@ pub const Keybinds = struct { // will be freed when the config is freed. self.set = .{}; self.tables = .empty; + self.chain_target = .root; // keybinds for opening and reloading config try self.set.put( @@ -6958,6 +7032,7 @@ pub const Keybinds = struct { log.info("config has 'keybind = clear', all keybinds cleared", .{}); self.set = .{}; self.tables = .empty; + self.chain_target = .root; return; } @@ -6995,16 +7070,39 @@ pub const Keybinds = struct { if (binding.len == 0) { log.debug("config has 'keybind = {s}/', table cleared", .{table_name}); gop.value_ptr.* = .{}; + self.chain_target = .root; return; } + // Chains are only allowed at the root level. Their target is + // tracked globally by parse order in `self.chain_target`. + if (std.mem.startsWith(u8, binding, "chain=")) { + return error.InvalidFormat; + } + // Parse and add the binding to the table try gop.value_ptr.parseAndPut(alloc, binding); + self.chain_target = .{ .table = gop.key_ptr.* }; + return; + } + + if (std.mem.startsWith(u8, value, "chain=")) { + switch (self.chain_target) { + .root => try self.set.parseAndPut(alloc, value), + .table => |table_name| { + const table = self.tables.getPtr(table_name) orelse { + self.chain_target = .root; + return error.InvalidFormat; + }; + try table.parseAndPut(alloc, value); + }, + } return; } // Parse into default set try self.set.parseAndPut(alloc, value); + self.chain_target = .root; } /// Deep copy of the struct. Required by Config. @@ -7446,6 +7544,63 @@ pub const Keybinds = struct { try testing.expect(keybinds.tables.contains("mytable")); } + test "parseCLI chain without prior parsed binding is invalid" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try testing.expectError( + error.InvalidFormat, + keybinds.parseCLI(alloc, "chain=new_tab"), + ); + } + + test "parseCLI table chain syntax is invalid" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try keybinds.parseCLI(alloc, "foo/a=text:hello"); + try testing.expectError( + error.InvalidFormat, + keybinds.parseCLI(alloc, "foo/chain=deactivate_key_table"), + ); + } + + test "parseCLI chain applies to most recent table binding" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try keybinds.parseCLI(alloc, "ctrl+n=activate_key_table:foo"); + try keybinds.parseCLI(alloc, "foo/a=text:hello"); + try keybinds.parseCLI(alloc, "chain=deactivate_key_table"); + + const root_entry = keybinds.set.get(.{ + .mods = .{ .ctrl = true }, + .key = .{ .unicode = 'n' }, + }).?.value_ptr.*; + try testing.expect(root_entry == .leaf); + try testing.expect(root_entry.leaf.action == .activate_key_table); + + const foo_entry = keybinds.tables.get("foo").?.get(.{ + .key = .{ .unicode = 'a' }, + }).?.value_ptr.*; + try testing.expect(foo_entry == .leaf_chained); + try testing.expectEqual(@as(usize, 2), foo_entry.leaf_chained.actions.items.len); + try testing.expect(foo_entry.leaf_chained.actions.items[0] == .text); + try testing.expect(foo_entry.leaf_chained.actions.items[1] == .deactivate_key_table); + } + test "clone with tables" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); diff --git a/src/config/string.zig b/src/config/string.zig index 71826f005..450799373 100644 --- a/src/config/string.zig +++ b/src/config/string.zig @@ -36,6 +36,40 @@ pub fn parse(out: []u8, bytes: []const u8) ![]u8 { return out[0..dst_i]; } +/// Creates an iterator that requires no allocation to extract codepoints +/// from the string literal, parsing escape sequences as it goes. +pub fn codepointIterator(bytes: []const u8) CodepointIterator { + return .{ .bytes = bytes, .i = 0 }; +} + +pub const CodepointIterator = struct { + bytes: []const u8, + i: usize, + + pub fn next(self: *CodepointIterator) error{InvalidString}!?u21 { + if (self.i >= self.bytes.len) return null; + switch (self.bytes[self.i]) { + // An escape sequence + '\\' => return switch (std.zig.string_literal.parseEscapeSequence( + self.bytes, + &self.i, + )) { + .failure => error.InvalidString, + .success => |cp| cp, + }, + + // Not an escape, parse as UTF-8 + else => |start| { + const cp_len = std.unicode.utf8ByteSequenceLength(start) catch + return error.InvalidString; + defer self.i += cp_len; + return std.unicode.utf8Decode(self.bytes[self.i..][0..cp_len]) catch + return error.InvalidString; + }, + } + } +}; + test "parse: empty" { const testing = std.testing; @@ -65,3 +99,48 @@ test "parse: escapes" { try testing.expectEqualStrings("hello\u{1F601}world", result); } } + +test "codepointIterator: empty" { + var it = codepointIterator(""); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: ascii no escapes" { + var it = codepointIterator("abc"); + try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'c'), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: multibyte utf8" { + // │ is U+2502 (3 bytes in UTF-8) + var it = codepointIterator("a│b"); + try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, '│'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: escape sequences" { + var it = codepointIterator("a\\tb\\n\\\\"); + try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, '\t'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, '\n'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, '\\'), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: unicode escape" { + var it = codepointIterator("\\u{2502}x"); + try std.testing.expectEqual(@as(u21, '│'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'x'), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: emoji unicode escape" { + var it = codepointIterator("\\u{1F601}"); + try std.testing.expectEqual(@as(u21, 0x1F601), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index ab3c6aaab..5a8a6ccbf 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -1895,7 +1895,7 @@ test "shape Bengali ligatures with out of order vowels" { try testing.expectEqual(@as(u16, 0), cells[0].x); try testing.expectEqual(@as(u16, 0), cells[1].x); // See the giant "We need to reset the `cell_offset`" comment, but here - // we should technically have the rest of these be `x` of 1, but that + // we should technically have the rest of these be `x` of 2, but that // would require going back in the stream to adjust past cells, and // we don't take on that complexity. try testing.expectEqual(@as(u16, 0), cells[2].x); diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 946611e79..b1126dd4e 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1447,12 +1447,12 @@ test "shape Bengali ligatures with out of order vowels" { // Whereas CoreText puts everything all into the first cell (see the // corresponding test), HarfBuzz splits into two clusters. - try testing.expectEqual(@as(u16, 1), cells[2].x); - try testing.expectEqual(@as(u16, 1), cells[3].x); - try testing.expectEqual(@as(u16, 1), cells[4].x); - try testing.expectEqual(@as(u16, 1), cells[5].x); - try testing.expectEqual(@as(u16, 1), cells[6].x); - try testing.expectEqual(@as(u16, 1), cells[7].x); + try testing.expectEqual(@as(u16, 2), cells[2].x); + try testing.expectEqual(@as(u16, 2), cells[3].x); + try testing.expectEqual(@as(u16, 2), cells[4].x); + try testing.expectEqual(@as(u16, 2), cells[5].x); + try testing.expectEqual(@as(u16, 2), cells[6].x); + try testing.expectEqual(@as(u16, 2), cells[7].x); // The vowel sign E renders before the SSA: try testing.expect(cells[2].x_offset < cells[3].x_offset); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 57414d764..286c8f2ed 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -570,12 +570,9 @@ pub const Action = union(enum) { toggle_tab_overview, /// 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, - /// Change the title of the current tab/window via a pop-up prompt. The + /// Change the title of the current tab via a pop-up prompt. The /// title set via this prompt overrides any title set by the terminal /// and persists across focus changes within the tab. prompt_tab_title, diff --git a/src/input/command.zig b/src/input/command.zig index d6d2b0247..f50e6840b 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -440,13 +440,13 @@ fn actionCommands(action: Action.Key) []const Command { .prompt_surface_title => comptime &.{.{ .action = .prompt_surface_title, - .title = "Change Terminal Title...", + .title = "Change Terminal Title…", .description = "Prompt for a new title for the current terminal.", }}, .prompt_tab_title => comptime &.{.{ .action = .prompt_tab_title, - .title = "Change Tab Title...", + .title = "Change Tab Title…", .description = "Prompt for a new title for the current tab.", }}, diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index cddda9871..33992bc55 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -15,7 +15,7 @@ pub const Style = enum { lock, /// Create a cursor style from the terminal style request. - pub fn fromTerminal(term: terminal.CursorStyle) ?Style { + pub fn fromTerminal(term: terminal.CursorStyle) Style { return switch (term) { .bar => .bar, .block => .block, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 83417429e..ff632f64a 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -756,6 +756,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .previous_cursor = @splat(0), .current_cursor_color = @splat(0), .previous_cursor_color = @splat(0), + .current_cursor_style = 0, + .previous_cursor_style = 0, + .cursor_visible = 0, .cursor_change_time = 0, .time_focus = 0, .focus = 1, // assume focused initially @@ -1226,6 +1229,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // kitty state on every frame because any cell change can move // an image. if (self.images.kittyRequiresUpdate(state.terminal)) { + // We need to grab the draw mutex since this updates + // our image state that drawFrame uses. + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); self.images.kittyUpdate( self.alloc, state.terminal, @@ -2007,11 +2014,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Only update when terminal state is dirty. if (self.terminal_state.dirty == .false) return; + const uniforms: *shadertoy.Uniforms = &self.custom_shader_uniforms; const colors: *const terminal.RenderState.Colors = &self.terminal_state.colors; // 256-color palette for (colors.palette, 0..) |color, i| { - self.custom_shader_uniforms.palette[i] = .{ + uniforms.palette[i] = .{ @as(f32, @floatFromInt(color.r)) / 255.0, @as(f32, @floatFromInt(color.g)) / 255.0, @as(f32, @floatFromInt(color.b)) / 255.0, @@ -2020,7 +2028,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Background color - self.custom_shader_uniforms.background_color = .{ + uniforms.background_color = .{ @as(f32, @floatFromInt(colors.background.r)) / 255.0, @as(f32, @floatFromInt(colors.background.g)) / 255.0, @as(f32, @floatFromInt(colors.background.b)) / 255.0, @@ -2028,7 +2036,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; // Foreground color - self.custom_shader_uniforms.foreground_color = .{ + uniforms.foreground_color = .{ @as(f32, @floatFromInt(colors.foreground.r)) / 255.0, @as(f32, @floatFromInt(colors.foreground.g)) / 255.0, @as(f32, @floatFromInt(colors.foreground.b)) / 255.0, @@ -2037,7 +2045,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Cursor color if (colors.cursor) |cursor_color| { - self.custom_shader_uniforms.cursor_color = .{ + uniforms.cursor_color = .{ @as(f32, @floatFromInt(cursor_color.r)) / 255.0, @as(f32, @floatFromInt(cursor_color.g)) / 255.0, @as(f32, @floatFromInt(cursor_color.b)) / 255.0, @@ -2051,7 +2059,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Cursor text color if (self.config.cursor_text) |cursor_text| { - self.custom_shader_uniforms.cursor_text = .{ + uniforms.cursor_text = .{ @as(f32, @floatFromInt(cursor_text.color.r)) / 255.0, @as(f32, @floatFromInt(cursor_text.color.g)) / 255.0, @as(f32, @floatFromInt(cursor_text.color.b)) / 255.0, @@ -2061,7 +2069,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Selection background color if (self.config.selection_background) |selection_bg| { - self.custom_shader_uniforms.selection_background_color = .{ + uniforms.selection_background_color = .{ @as(f32, @floatFromInt(selection_bg.color.r)) / 255.0, @as(f32, @floatFromInt(selection_bg.color.g)) / 255.0, @as(f32, @floatFromInt(selection_bg.color.b)) / 255.0, @@ -2071,13 +2079,21 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Selection foreground color if (self.config.selection_foreground) |selection_fg| { - self.custom_shader_uniforms.selection_foreground_color = .{ + uniforms.selection_foreground_color = .{ @as(f32, @floatFromInt(selection_fg.color.r)) / 255.0, @as(f32, @floatFromInt(selection_fg.color.g)) / 255.0, @as(f32, @floatFromInt(selection_fg.color.b)) / 255.0, 1.0, }; } + + // Cursor visibility + uniforms.cursor_visible = @intFromBool(self.terminal_state.cursor.visible); + + // Cursor style + const cursor_style: renderer.CursorStyle = .fromTerminal(self.terminal_state.cursor.visual_style); + uniforms.previous_cursor_style = uniforms.current_cursor_style; + uniforms.current_cursor_style = @as(i32, @intFromEnum(cursor_style)); } /// Update per-frame custom shader uniforms. @@ -2087,7 +2103,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // We only need to do this if we have custom shaders. if (!self.has_custom_shaders) return; - const uniforms = &self.custom_shader_uniforms; + const uniforms: *shadertoy.Uniforms = &self.custom_shader_uniforms; const now = try std.time.Instant.now(); defer self.last_frame_time = now; @@ -2121,7 +2137,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { 0, }; - // Update custom cursor uniforms, if we have a cursor. if (self.cells.getCursorGlyph()) |cursor| { const cursor_width: f32 = @floatFromInt(cursor.glyph_size[0]); const cursor_height: f32 = @floatFromInt(cursor.glyph_size[1]); diff --git a/src/renderer/image.zig b/src/renderer/image.zig index 85f3a01ed..c43d27981 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -844,7 +844,7 @@ pub const Image = union(enum) { /// 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 { + 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 @@ -867,7 +867,7 @@ pub const Image = union(enum) { /// 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) wuffs.Error!void { + fn prepForUpload(self: *Image, alloc: Allocator) wuffs.Error!void { assert(self.isPending()); try self.convert(alloc); } diff --git a/src/renderer/shaders/shadertoy_prefix.glsl b/src/renderer/shaders/shadertoy_prefix.glsl index 661bd233d..4b6d091b8 100644 --- a/src/renderer/shaders/shadertoy_prefix.glsl +++ b/src/renderer/shaders/shadertoy_prefix.glsl @@ -15,6 +15,9 @@ layout(binding = 1, std140) uniform Globals { uniform vec4 iPreviousCursor; uniform vec4 iCurrentCursorColor; uniform vec4 iPreviousCursorColor; + uniform int iCurrentCursorStyle; + uniform int iPreviousCursorStyle; + uniform int iCursorVisible; uniform float iTimeCursorChange; uniform float iTimeFocus; uniform int iFocus; @@ -27,6 +30,12 @@ layout(binding = 1, std140) uniform Globals { uniform vec3 iSelectionBackgroundColor; }; +#define CURSORSTYLE_BLOCK 0 +#define CURSORSTYLE_BLOCK_HOLLOW 1 +#define CURSORSTYLE_BAR 2 +#define CURSORSTYLE_UNDERLINE 3 +#define CURSORSTYLE_LOCK 4 + layout(binding = 0) uniform sampler2D iChannel0; // These are unused currently by Ghostty: diff --git a/src/renderer/shadertoy.zig b/src/renderer/shadertoy.zig index 7d0ad4b0a..556c28293 100644 --- a/src/renderer/shadertoy.zig +++ b/src/renderer/shadertoy.zig @@ -24,6 +24,9 @@ pub const Uniforms = extern struct { previous_cursor: [4]f32 align(16), current_cursor_color: [4]f32 align(16), previous_cursor_color: [4]f32 align(16), + current_cursor_style: i32 align(4), + previous_cursor_style: i32 align(4), + cursor_visible: i32 align(4), cursor_change_time: f32 align(4), time_focus: f32 align(4), focus: i32 align(4), diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 248a2c512..ae495f0f3 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -329,12 +329,16 @@ pub fn print(self: *Terminal, c: u21) !void { @branchHint(.unlikely); // We need the previous cell to determine if we're at a grapheme // break or not. If we are NOT, then we are still combining the - // same grapheme. Otherwise, we can stay in this cell. + // same grapheme, and will be appending to prev.cell. Otherwise, we are + // in a new cell. const Prev = struct { cell: *Cell, left: size.CellCountInt }; - const prev: Prev = prev: { + var prev: Prev = prev: { const left: size.CellCountInt = left: { - // If we have wraparound, then we always use the prev col - if (self.modes.get(.wraparound)) break :left 1; + // If we have wraparound, then we use the prev col unless + // there's a pending wrap, in which case we use the current. + if (self.modes.get(.wraparound)) { + break :left @intFromBool(!self.screens.active.cursor.pending_wrap); + } // If we do not have wraparound, the logic is trickier. If // we're not on the last column, then we just use the previous @@ -380,6 +384,8 @@ pub fn print(self: *Terminal, c: u21) !void { // If we can NOT break, this means that "c" is part of a grapheme // with the previous char. if (!grapheme_break) { + var desired_wide: enum { no_change, wide, narrow } = .no_change; + // If this is an emoji variation selector then we need to modify // the cell width accordingly. VS16 makes the character wide and // VS15 makes it narrow. @@ -390,71 +396,132 @@ pub fn print(self: *Terminal, c: u21) !void { if (!prev_props.emoji_vs_base) return; switch (c) { - 0xFE0F => wide: { - if (prev.cell.wide == .wide) break :wide; + 0xFE0F => desired_wide = .wide, + 0xFE0E => desired_wide = .narrow, + else => unreachable, + } + } else if (!unicode.table.get(c).width_zero_in_grapheme) { + // If we have a code point that contributes to the width of a + // grapheme, it necessarily means that we're at least at width + // 2, since the first code point must be at least width 1 to + // start. (Note that Prepend code points could effectively mean + // the first code point should be width 0, but we don't handle + // that yet.) + desired_wide = .wide; + } - // Move our cursor back to the previous. We'll move - // the cursor within this block to the proper location. - self.screens.active.cursorLeft(prev.left); + switch (desired_wide) { + .wide => wide: { + if (prev.cell.wide == .wide) break :wide; - // If we don't have space for the wide char, we need - // to insert spacers and wrap. Then we just print the wide - // char as normal. - if (self.screens.active.cursor.x == right_limit - 1) { - if (!self.modes.get(.wraparound)) return; + // Move our cursor back to the previous. We'll move + // the cursor within this block to the proper location. + self.screens.active.cursorLeft(prev.left); + + // If we don't have space for the wide char, we need to + // insert spacers and wrap. We need special handling if the + // previous cell has grapheme data. + if (self.screens.active.cursor.x == right_limit - 1) { + if (!self.modes.get(.wraparound)) return; + + const prev_cp = prev.cell.content.codepoint; + + if (prev.cell.hasGrapheme()) { + // This is like printCell but without clearing the + // grapheme data from the cell, so we can move it + // later. + prev.cell.wide = if (right_limit == self.cols) .spacer_head else .narrow; + prev.cell.content.codepoint = 0; + + try self.printWrap(); + self.printCell(prev_cp, .wide); + + const new_pin = self.screens.active.cursor.page_pin.*; + const new_rac = new_pin.rowAndCell(); + + transfer_graphemes: { + var old_pin = self.screens.active.cursor.page_pin.up(1) orelse break :transfer_graphemes; + old_pin.x = right_limit - 1; + const old_rac = old_pin.rowAndCell(); + + if (new_pin.node == old_pin.node) { + new_pin.node.data.moveGrapheme(prev.cell, new_rac.cell); + prev.cell.content_tag = .codepoint; + new_rac.cell.content_tag = .codepoint_grapheme; + new_rac.row.grapheme = true; + } else { + const cps = old_pin.node.data.lookupGrapheme(old_rac.cell).?; + for (cps) |cp| { + try self.screens.active.appendGrapheme(new_rac.cell, cp); + } + old_pin.node.data.clearGrapheme(old_rac.cell); + } + + old_pin.node.data.updateRowGraphemeFlag(old_rac.row); + } + + // Point prev.cell to our new previous cell that + // we'll be appending graphemes to + prev.cell = new_rac.cell; + } else { self.printCell( 0, if (right_limit == self.cols) .spacer_head else .narrow, ); try self.printWrap(); + self.printCell(prev_cp, .wide); + + // Point prev.cell to our new previous cell that + // we'll be appending graphemes to + prev.cell = self.screens.active.cursor.page_cell; } + } else { + prev.cell.wide = .wide; + } - self.printCell(prev.cell.content.codepoint, .wide); + // Write our spacer, since prev.cell is now wide + self.screens.active.cursorRight(1); + self.printCell(0, .spacer_tail); - // Write our spacer + // Move the cursor again so we're beyond our spacer + if (self.screens.active.cursor.x == right_limit - 1) { + self.screens.active.cursor.pending_wrap = true; + } else { self.screens.active.cursorRight(1); - self.printCell(0, .spacer_tail); + } + }, - // Move the cursor again so we're beyond our spacer - if (self.screens.active.cursor.x == right_limit - 1) { - self.screens.active.cursor.pending_wrap = true; - } else { - self.screens.active.cursorRight(1); - } - }, + .narrow => narrow: { + // Prev cell is no longer wide + if (prev.cell.wide != .wide) break :narrow; + prev.cell.wide = .narrow; - 0xFE0E => narrow: { - // Prev cell is no longer wide - if (prev.cell.wide != .wide) break :narrow; - prev.cell.wide = .narrow; + // Remove the wide spacer tail + const cell = self.screens.active.cursorCellLeft(prev.left - 1); + cell.wide = .narrow; - // Remove the wide spacer tail - const cell = self.screens.active.cursorCellLeft(prev.left - 1); - cell.wide = .narrow; + // Back track the cursor so that we don't end up with + // an extra space after the character. Since xterm is + // not VS aware, it cannot be used as a reference for + // this behavior; but it does follow the principle of + // least surprise, and also matches the behavior that + // can be observed in Kitty, which is one of the only + // other VS aware terminals. + if (self.screens.active.cursor.x == right_limit - 1) { + // If we're already at the right edge, we stay + // here and set the pending wrap to false since + // when we pend a wrap, we only move our cursor once + // even for wide chars (tests verify). + self.screens.active.cursor.pending_wrap = false; + } else { + // Otherwise, move back. + self.screens.active.cursorLeft(1); + } - // Back track the cursor so that we don't end up with - // an extra space after the character. Since xterm is - // not VS aware, it cannot be used as a reference for - // this behavior; but it does follow the principle of - // least surprise, and also matches the behavior that - // can be observed in Kitty, which is one of the only - // other VS aware terminals. - if (self.screens.active.cursor.x == right_limit - 1) { - // If we're already at the right edge, we stay - // here and set the pending wrap to false since - // when we pend a wrap, we only move our cursor once - // even for wide chars (tests verify). - self.screens.active.cursor.pending_wrap = false; - } else { - // Otherwise, move back. - self.screens.active.cursorLeft(1); - } + break :narrow; + }, - break :narrow; - }, - - else => unreachable, - } + else => {}, } log.debug("c={X} grapheme attach to left={} primary_cp={X}", .{ @@ -3834,19 +3901,23 @@ test "Terminal: print invalid VS15 in emoji ZWJ sequence" { } test "Terminal: VS15 to make narrow character with pending wrap" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 2 }); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 4 }); defer t.deinit(testing.allocator); // Enable grapheme clustering t.modes.set(.grapheme_cluster, true); + try testing.expect(t.modes.get(.wraparound)); + + try t.print(0x1F34B); // Lemon, width=2 try t.print(0x2614); // Umbrella with rain drops, width=2 try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); t.clearDirty(); - // We only move one because we're in a pending wrap state. + // We only move to the end of the line because we're in a pending wrap + // state. try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x); try testing.expect(t.screens.active.cursor.pending_wrap); try t.print(0xFE0E); // VS15 to make narrow @@ -3855,17 +3926,17 @@ test "Terminal: VS15 to make narrow character with pending wrap" { // VS15 should clear the pending wrap state try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x); try testing.expect(!t.screens.active.cursor.pending_wrap); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("☔︎", str); + try testing.expectEqualStrings("🍋☔︎", str); } { - const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3873,6 +3944,102 @@ test "Terminal: VS15 to make narrow character with pending wrap" { const cps = list_cell.node.data.lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } + + // VS15 should not affect the previous grapheme + { + const lemon_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?.cell; + try testing.expectEqual(@as(u21, 0x1F34B), lemon_cell.content.codepoint); + try testing.expectEqual(Cell.Wide.wide, lemon_cell.wide); + const spacer_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?.cell; + try testing.expectEqual(@as(u21, 0), spacer_cell.content.codepoint); + try testing.expectEqual(Cell.Wide.spacer_tail, spacer_cell.wide); + } +} + +test "Terminal: VS16 to make wide character on next line" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorRight(2); + try t.print('#'); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 2, .y = 0 } })); + t.clearDirty(); + + try t.print(0xFE0F); // VS16 to make wide + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 2, .y = 0 } })); + t.clearDirty(); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // Previous cell turns into spacer_head + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + // '#' cell is wide + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + // spacer_tail + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: VS16 to make wide character with pending wrap" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorRight(1); + try t.print('#'); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + try t.print(0xFE0F); // VS16 to make wide + + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expect(t.screens.active.cursor.pending_wrap); + + { + // '#' cell is wide + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + // spacer_tail + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } } test "Terminal: VS16 to make wide character with mode 2027" { @@ -4013,6 +4180,173 @@ test "Terminal: print invalid VS16 with second char" { } } +test "Terminal: print grapheme ò (o with nonspacing mark) should be narrow" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print('o'); + try t.print(0x0300); // combining grave accent + + // We should have 1 cell taken up. + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'o'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: print Devanagari grapheme should be wide" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // क्‍ष + try t.print(0x0915); + try t.print(0x094D); + try t.print(0x200D); + try t.print(0x0937); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: print Devanagari grapheme should be wide on next line" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorRight(2); + + // क्‍ष + try t.print(0x0915); + try t.print(0x094D); + try t.print(0x200D); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + // This one increases the width to wide + try t.print(0x0937); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // Previous cell turns into spacer_head + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + // Devanagari grapheme is wide + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: print Devanagari grapheme should be wide on next page" { + const rows = pagepkg.std_capacity.rows; + const cols = pagepkg.std_capacity.cols; + var t = try init(testing.allocator, .{ .rows = rows, .cols = cols }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorDown(rows - 1); + + for (rows..t.screens.active.pages.pages.first.?.data.capacity.rows) |_| { + try t.index(); + } + + t.cursorRight(cols - 1); + + try testing.expectEqual(cols - 1, t.screens.active.cursor.x); + try testing.expectEqual(rows - 1, t.screens.active.cursor.y); + + // क्‍ष + try t.print(0x0915); + try t.print(0x094D); + try t.print(0x200D); + try testing.expectEqual(cols - 1, t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + // This one increases the width to wide + try t.print(0x0937); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(rows - 1, t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // Previous cell turns into spacer_head + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = cols - 1, .y = rows - 2 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + // Devanagari grapheme is wide + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = rows - 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 1, .y = rows - 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + test "Terminal: print invalid VS16 with second char (combining)" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 61507dc75..3e7ca9ac3 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1556,7 +1556,7 @@ pub const Page = struct { /// WARNING: This will NOT change the content_tag on the cells because /// there are scenarios where we want to move graphemes without changing /// the content tag. Callers beware but assertIntegrity should catch this. - inline fn moveGrapheme(self: *Page, src: *Cell, dst: *Cell) void { + pub inline fn moveGrapheme(self: *Page, src: *Cell, dst: *Cell) void { if (build_options.slow_runtime_safety) { assert(src.hasGrapheme()); assert(!dst.hasGrapheme()); diff --git a/src/unicode/props.zig b/src/unicode/props.zig index a6615e56e..2653f0cc6 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -13,6 +13,10 @@ pub const Properties = packed struct { /// becomes a 2-em dash). width: u2 = 0, + /// Whether the code point does not contribute to the width of a grapheme + /// cluster (not used for single code point cells). + width_zero_in_grapheme: bool = false, + /// Grapheme break property. grapheme_break: uucode.x.types.GraphemeBreakNoControl = .other, @@ -22,6 +26,7 @@ pub const Properties = packed struct { // Needed for lut.Generator pub fn eql(a: Properties, b: Properties) bool { return a.width == b.width and + a.width_zero_in_grapheme == b.width_zero_in_grapheme and a.grapheme_break == b.grapheme_break and a.emoji_vs_base == b.emoji_vs_base; } @@ -34,11 +39,13 @@ pub const Properties = packed struct { try writer.print( \\.{{ \\ .width= {}, + \\ .width_zero_in_grapheme= {}, \\ .grapheme_break= .{s}, \\ .emoji_vs_base= {}, \\}} , .{ self.width, + self.width_zero_in_grapheme, @tagName(self.grapheme_break), self.emoji_vs_base, }); diff --git a/src/unicode/props_uucode.zig b/src/unicode/props_uucode.zig index d876bf4ac..527a757ed 100644 --- a/src/unicode/props_uucode.zig +++ b/src/unicode/props_uucode.zig @@ -8,12 +8,14 @@ const Properties = @import("props.zig").Properties; pub fn get(cp: u21) Properties { if (cp > uucode.config.max_code_point) return .{ .width = 1, + .width_zero_in_grapheme = true, .grapheme_break = .other, .emoji_vs_base = false, }; return .{ .width = uucode.get(.width, cp), + .width_zero_in_grapheme = uucode.get(.wcwidth_zero_in_grapheme, cp), .grapheme_break = uucode.get(.grapheme_break_no_control, cp), .emoji_vs_base = uucode.get(.is_emoji_vs_base, cp), }; diff --git a/typos.toml b/typos.toml index ad167f06e..3c7cd75f2 100644 --- a/typos.toml +++ b/typos.toml @@ -42,6 +42,8 @@ extend-ignore-re = [ "draw[0-9A-F]+(_[0-9A-F]+)?\\(", # Ignore test data in src/input/paste.zig "\"hel\\\\x", + # Ignore long hex-like IDs such as 815E26BA2EF1E00F005C67B1 + "[0-9A-F]{12,}", ] [default.extend-words]