diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index ec55f2dff..a905531c2 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,16 +36,16 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@v30 + uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index ced497997..3f89bd702 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -47,7 +47,7 @@ jobs: sentry-cli dif upload --project ghostty --wait dsym.zip build-macos: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -57,10 +57,10 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -94,7 +94,7 @@ jobs: - name: Build Ghostty.app run: | cd macos - sudo xcode-select -s /Applications/Xcode_16.0.app + sudo xcode-select -s /Applications/Xcode_16.4.app xcodebuild -target Ghostty -configuration Release # We inject the "build number" as simply the number of commits since HEAD. @@ -199,7 +199,7 @@ jobs: destination-dir: ./ build-macos-debug: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -209,10 +209,10 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -246,7 +246,7 @@ jobs: - name: Build Ghostty.app run: | cd macos - sudo xcode-select -s /Applications/Xcode_16.0.app + sudo xcode-select -s /Applications/Xcode_16.4.app xcodebuild -target Ghostty -configuration Release # We inject the "build number" as simply the number of commits since HEAD. diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index ab103d6df..3deafd066 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,17 +83,17 @@ jobs: - uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -120,7 +120,7 @@ jobs: build-macos: needs: [setup] - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 env: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} @@ -130,16 +130,16 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_16.4.app - name: Setup Sparkle env: @@ -288,7 +288,7 @@ jobs: appcast: needs: [setup, build-macos] - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia env: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index d23787743..6c6399afd 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -107,15 +107,15 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -132,7 +132,7 @@ jobs: nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password - name: Update Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.3.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -154,7 +154,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -164,16 +164,16 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_16.4.app # Setup Sparkle - name: Setup Sparkle @@ -299,7 +299,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.3.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -369,7 +369,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -379,16 +379,16 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_16.4.app # Setup Sparkle - name: Setup Sparkle @@ -507,7 +507,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.3.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -544,7 +544,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -554,16 +554,16 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_16.4.app # Setup Sparkle - name: Setup Sparkle @@ -682,7 +682,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.3.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e6e3a77a0..814acec8f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,7 @@ jobs: - build-nix - build-snap - build-macos + - build-macos-tahoe - build-macos-matrix - build-windows - build-windows-cross @@ -67,17 +68,17 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -98,17 +99,17 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -134,17 +135,17 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -163,17 +164,17 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -196,23 +197,38 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Test NixOS package build - run: nix build .#ghostty + - name: Test release NixOS package build + run: nix build .#ghostty-releasefast + + - name: Check version + run: result/bin/ghostty +version | grep -q 'builtin.OptimizeMode.ReleaseFast' + + - name: Check to see if the binary has been stripped + run: nm result/bin/.ghostty-wrapped 2>&1 | grep -q 'no symbols' + + - name: Test debug NixOS package build + run: nix build .#ghostty-debug + + - name: Check version + run: result/bin/ghostty +version | grep -q 'builtin.OptimizeMode.Debug' + + - name: Check to see if the binary has not been stripped + run: nm result/bin/.ghostty-wrapped 2>&1 | grep -q 'main_ghostty.main' build-dist: runs-on: namespace-profile-ghostty-md @@ -225,17 +241,17 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -254,23 +270,23 @@ jobs: ghostty-source.tar.gz build-macos: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia needs: test steps: - name: Checkout code uses: actions/checkout@v4 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_16.4.app - name: get the Zig deps id: deps @@ -281,7 +297,58 @@ jobs: - name: Build GhosttyKit run: nix develop -c zig build --system ${{ steps.deps.outputs.deps }} - # The native app is built with native XCode tooling. This also does + # The native app is built with native Xcode tooling. This also does + # codesigning. IMPORTANT: this must NOT run in a Nix environment. + # Nix breaks xcodebuild so this has to be run outside. + - name: Build Ghostty.app + run: cd macos && xcodebuild -target Ghostty + + # Build the iOS target without code signing just to verify it works. + - name: Build Ghostty iOS + run: | + cd macos + xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" + + build-macos-tahoe: + runs-on: namespace-profile-ghostty-macos-tahoe + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + - uses: cachix/cachix-action@v16 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_26.0.app + + # TODO(tahoe): + # https://developer.apple.com/documentation/xcode-release-notes/xcode-26-release-notes#Interface-Builder + # We allow this step to fail because if our image already has + # the workaround in place this will fail. + - name: Xcode 26 Beta 17A5241e Metal Workaround + continue-on-error: true + run: | + xcodebuild -downloadComponent metalToolchain -exportPath /tmp/MyMetalExport/ + sed -i '' -e 's/17A5241c/17A5241e/g' /tmp/MyMetalExport/MetalToolchain-17A5241c.exportedBundle/ExportMetadata.plist + xcodebuild -importComponent metalToolchain -importPath /tmp/MyMetalExport/MetalToolchain-17A5241c.exportedBundle + + - name: get the Zig deps + id: deps + run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT + + # GhosttyKit is the framework that is built from Zig for our native + # Mac app to access. + - name: Build GhosttyKit + run: nix develop -c zig build --system ${{ steps.deps.outputs.deps }} + + # The native app is built with native Xcode tooling. This also does # codesigning. IMPORTANT: this must NOT run in a Nix environment. # Nix breaks xcodebuild so this has to be run outside. - name: Build Ghostty.app @@ -294,23 +361,23 @@ jobs: xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" build-macos-matrix: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia needs: test steps: - name: Checkout code uses: actions/checkout@v4 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_16.4.app - name: get the Zig deps id: deps @@ -367,7 +434,7 @@ jobs: mkdir dist tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -477,17 +544,17 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -508,17 +575,17 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -553,17 +620,17 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -592,17 +659,17 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -612,23 +679,23 @@ jobs: nix develop -c zig build -Dsentry=${{ matrix.sentry }} test-macos: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia needs: test steps: - name: Checkout code uses: actions/checkout@v4 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_16.4.app - name: get the Zig deps id: deps @@ -647,15 +714,15 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -674,15 +741,15 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -701,15 +768,15 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -728,15 +795,15 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -755,15 +822,15 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -782,15 +849,15 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -817,17 +884,17 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -875,16 +942,16 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@v30 + uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index fed6d2db7..2533285e6 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,17 +22,17 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@v30 + uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/LICENSE b/LICENSE index 14e132f55..0a07a66cd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Mitchell Hashimoto +Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/PACKAGING.md b/PACKAGING.md index 234a86770..d85f55de7 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -4,13 +4,12 @@ Ghostty relies on downstream package maintainers to distribute Ghostty to end-users. This document provides guidance to package maintainers on how to package Ghostty for distribution. -> [!NOTE] +> [!IMPORTANT] > -> While Ghostty went through an extensive private beta testing period, -> packaging Ghostty is immature and may require additional build script -> tweaks and documentation improvement. I'm extremely motivated to work with -> package maintainers to improve the packaging process. Please open issues -> to discuss any packaging issues you encounter. +> This document is only accurate for the Ghostty source alongside it. +> **Do not use this document for older or newer versions of Ghostty!** If +> you are reading this document in a different version of Ghostty, please +> find the `PACKAGING.md` file alongside that version. ## Source Tarballs @@ -37,6 +36,19 @@ Use the `ghostty-source.tar.gz` asset and _not the GitHub auto-generated source tarball_. These tarballs are generated for every commit to the `main` branch and are not associated with a specific version. +> [!WARNING] +> +> Source tarballs are _not the same_ as a Git checkout. Source tarballs +> contain some preprocessed files that allow building Ghostty with less +> dependencies. If you are building Ghostty from a Git checkout, the +> steps below are the same but they may require additional dependencies +> not listed here. See the `README.md` for more information on building +> from a Git checkout. +> +> For everyone except Ghostty developers, please use the source tarballs. +> We generate tip source tarballs for users following the development +> branch. + ## Zig Version [Zig](https://ziglang.org) is required to build Ghostty. Prior to Zig 1.0, @@ -81,13 +93,6 @@ for system packages which separate a build and install step, since the install step can then be done with a `mv` or `cp` command (from `/tmp/ghostty` to wherever the package manager expects it). -> [!NOTE] -> -> **Version 1.1.1 and 1.1.2 are missing `fetch-zig-cache.sh`.** This was -> an oversight on the release process. You can use the script from version -> 1.1.0 to fetch the Zig cache for these versions. Future versions will -> restore the script. - ### Build Options Ghostty uses the Zig build system. You can see all available build options by diff --git a/build.zig b/build.zig index 0751bab51..80af88488 100644 --- a/build.zig +++ b/build.zig @@ -110,9 +110,15 @@ pub fn build(b: *std.Build) !void { const test_exe = b.addTest(.{ .name = "ghostty-test", - .root_source_file = b.path("src/main.zig"), - .target = config.target, - .filter = test_filter, + .filters = if (test_filter) |v| &.{v} else &.{}, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = config.target, + .optimize = .Debug, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), }); { diff --git a/build.zig.zon b/build.zig.zon index 7c5ff8ffc..fa071dbfe 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -103,8 +103,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/1e4957e65005908993250f8f07be3f70e805195e.tar.gz", - .hash = "N-V-__8AAHOASAQuLADcCSHLHEJiKFVLZiCD9Aq2rh5GT01A", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/10f216f54a32cee6f1f2e3aa01812650a209c16b.tar.gz", + .hash = "N-V-__8AAMJWXQSVe0xzU_4UVRTDVERqnNpQU4wfS0FRGRRj", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 513ee0dcd..ee2f14508 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAHOASAQuLADcCSHLHEJiKFVLZiCD9Aq2rh5GT01A": { + "N-V-__8AAMJWXQSVe0xzU_4UVRTDVERqnNpQU4wfS0FRGRRj": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/1e4957e65005908993250f8f07be3f70e805195e.tar.gz", - "hash": "sha256-xpDitXpZrdU/EcgLyG4G0cEiT4r42viy+DJALmy2sQE=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/10f216f54a32cee6f1f2e3aa01812650a209c16b.tar.gz", + "hash": "sha256-TcrTZUCVetvAFGX0fBqg3zlG90flljScNr/OYR/MJ5Y=" }, "N-V-__8AAJrvXQCqAT8Mg9o_tk6m0yf5Fz-gCNEOKLyTSerD": { "name": "libpng", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 46cf07cc9..e28a2a0dd 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -170,11 +170,11 @@ in }; } { - name = "N-V-__8AAHOASAQuLADcCSHLHEJiKFVLZiCD9Aq2rh5GT01A"; + name = "N-V-__8AAMJWXQSVe0xzU_4UVRTDVERqnNpQU4wfS0FRGRRj"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/1e4957e65005908993250f8f07be3f70e805195e.tar.gz"; - hash = "sha256-xpDitXpZrdU/EcgLyG4G0cEiT4r42viy+DJALmy2sQE="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/10f216f54a32cee6f1f2e3aa01812650a209c16b.tar.gz"; + hash = "sha256-TcrTZUCVetvAFGX0fBqg3zlG90flljScNr/OYR/MJ5Y="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 5f06418a7..3335b9574 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -27,7 +27,7 @@ https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5. https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/glfw/glfw/archive/e7ea71be039836da3a98cea55ae5569cb5eb885c.tar.gz https://github.com/jcollie/ghostty-gobject/releases/download/0.14.0-2025-03-18-21-1/ghostty-gobject-0.14.0-2025-03-18-21-1.tar.zst -https://github.com/mbadolato/iTerm2-Color-Schemes/archive/1e4957e65005908993250f8f07be3f70e805195e.tar.gz +https://github.com/mbadolato/iTerm2-Color-Schemes/archive/10f216f54a32cee6f1f2e3aa01812650a209c16b.tar.gz https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz https://github.com/mitchellh/zig-objc/archive/3ab0d37c7d6b933d6ded1b3a35b6b60f05590a98.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz diff --git a/flake.lock b/flake.lock index df09a9666..4b8ce405c 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1733328505, - "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "lastModified": 1747046372, + "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", "owner": "edolstra", "repo": "flake-compat", - "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", "type": "github" }, "original": { @@ -34,44 +34,24 @@ "type": "github" } }, - "nixpkgs-stable": { + "nixpkgs": { "locked": { - "lastModified": 1741992157, - "narHash": "sha256-nlIfTsTrMSksEJc1f7YexXiPVuzD1gOfeN1ggwZyUoc=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "da4b122f63095ca1199bd4d526f9e26426697689", - "type": "github" + "lastModified": 1748189127, + "narHash": "sha256-zRDR+EbbeObu4V2X5QCd2Bk5eltfDlCr5yvhBwUT6pY=", + "rev": "7c43f080a7f28b2774f3b3f43234ca11661bf334", + "type": "tarball", + "url": "https://releases.nixos.org/nixos/25.05/nixos-25.05.802491.7c43f080a7f2/nixexprs.tar.xz" }, "original": { - "owner": "nixos", - "ref": "release-24.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-unstable": { - "locked": { - "lastModified": 1741865919, - "narHash": "sha256-4thdbnP6dlbdq+qZWTsm4ffAwoS8Tiq1YResB+RP6WE=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "573c650e8a14b2faa0041645ab18aed7e60f0c9a", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" + "type": "tarball", + "url": "https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz" } }, "root": { "inputs": { "flake-compat": "flake-compat", "flake-utils": "flake-utils", - "nixpkgs-stable": "nixpkgs-stable", - "nixpkgs-unstable": "nixpkgs-unstable", + "nixpkgs": "nixpkgs", "zig": "zig", "zon2nix": "zon2nix" } @@ -98,15 +78,15 @@ "flake-utils" ], "nixpkgs": [ - "nixpkgs-stable" + "nixpkgs" ] }, "locked": { - "lastModified": 1741825901, - "narHash": "sha256-aeopo+aXg5I2IksOPFN79usw7AeimH1+tjfuMzJHFdk=", + "lastModified": 1748261582, + "narHash": "sha256-3i0IL3s18hdDlbsf0/E+5kyPRkZwGPbSFngq5eToiAA=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "0b14285e283f5a747f372fb2931835dd937c4383", + "rev": "aafb1b093fb838f7a02613b719e85ec912914221", "type": "github" }, "original": { @@ -121,7 +101,7 @@ "flake-utils" ], "nixpkgs": [ - "nixpkgs-unstable" + "nixpkgs" ] }, "locked": { diff --git a/flake.nix b/flake.nix index d4c6aa6ca..6794afb11 100644 --- a/flake.nix +++ b/flake.nix @@ -2,12 +2,10 @@ description = "👻"; inputs = { - nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable"; - # We want to stay as up to date as possible but need to be careful that the # glibc versions used by our dependencies from Nix are compatible with the # system glibc that the user is building for. - nixpkgs-stable.url = "github:nixos/nixpkgs/release-24.11"; + nixpkgs.url = "https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz"; flake-utils.url = "github:numtide/flake-utils"; # Used for shell.nix @@ -19,7 +17,7 @@ zig = { url = "github:mitchellh/zig-overlay"; inputs = { - nixpkgs.follows = "nixpkgs-stable"; + nixpkgs.follows = "nixpkgs"; flake-utils.follows = "flake-utils"; flake-compat.follows = ""; }; @@ -28,7 +26,7 @@ zon2nix = { url = "github:jcollie/zon2nix?ref=56c159be489cc6c0e73c3930bd908ddc6fe89613"; inputs = { - nixpkgs.follows = "nixpkgs-unstable"; + nixpkgs.follows = "nixpkgs"; flake-utils.follows = "flake-utils"; }; }; @@ -36,24 +34,19 @@ outputs = { self, - nixpkgs-unstable, - nixpkgs-stable, + nixpkgs, zig, zon2nix, ... }: - builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} ( + builtins.foldl' nixpkgs.lib.recursiveUpdate {} ( builtins.map ( system: let - pkgs-stable = nixpkgs-stable.legacyPackages.${system}; - pkgs-unstable = nixpkgs-unstable.legacyPackages.${system}; + pkgs = nixpkgs.legacyPackages.${system}; in { - devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix { - zig = zig.packages.${system}."0.14.0"; - wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {}; - uv = pkgs-unstable.uv; - # remove once blueprint-compiler 0.16.0 is in the stable nixpkgs - blueprint-compiler = pkgs-unstable.blueprint-compiler; + devShell.${system} = pkgs.callPackage ./nix/devShell.nix { + zig = zig.packages.${system}."0.14.1"; + wraptest = pkgs.callPackage ./nix/wraptest.nix {}; zon2nix = zon2nix; }; @@ -64,30 +57,29 @@ revision = self.shortRev or self.dirtyShortRev or "dirty"; }; in rec { - deps = pkgs-unstable.callPackage ./build.zig.zon.nix {}; - ghostty-debug = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "Debug"); - ghostty-releasesafe = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe"); - ghostty-releasefast = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "ReleaseFast"); + deps = pkgs.callPackage ./build.zig.zon.nix {}; + ghostty-debug = pkgs.callPackage ./nix/package.nix (mkArgs "Debug"); + ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseSafe"); + ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseFast"); ghostty = ghostty-releasefast; default = ghostty; }; - formatter.${system} = pkgs-stable.alejandra; + formatter.${system} = pkgs.alejandra; apps.${system} = let runVM = ( module: let vm = import ./nix/vm/create.nix { - inherit system module; - nixpkgs = nixpkgs-unstable; + inherit system module nixpkgs; overlay = self.overlays.debug; }; - program = pkgs-unstable.writeShellScript "run-ghostty-vm" '' + program = pkgs.writeShellScript "run-ghostty-vm" '' SHARED_DIR=$(pwd) export SHARED_DIR - ${pkgs-unstable.lib.getExe vm.config.system.build.vm} "$@" + ${pkgs.lib.getExe vm.config.system.build.vm} "$@" ''; in { type = "app"; diff --git a/flatpak/com.mitchellh.ghostty.Devel.yml b/flatpak/com.mitchellh.ghostty-debug.yml similarity index 92% rename from flatpak/com.mitchellh.ghostty.Devel.yml rename to flatpak/com.mitchellh.ghostty-debug.yml index 244c3987f..8a2c0056e 100644 --- a/flatpak/com.mitchellh.ghostty.Devel.yml +++ b/flatpak/com.mitchellh.ghostty-debug.yml @@ -1,4 +1,4 @@ -app-id: com.mitchellh.ghostty.Devel +app-id: com.mitchellh.ghostty-debug runtime: org.gnome.Platform runtime-version: "48" sdk: org.gnome.Sdk @@ -10,10 +10,12 @@ command: ghostty rename-desktop-file: com.mitchellh.ghostty.desktop rename-appdata-file: com.mitchellh.ghostty.metainfo.xml rename-icon: com.mitchellh.ghostty -desktop-file-name-suffix: " (Devel)" +desktop-file-name-suffix: " (Debug)" finish-args: # 3D rendering - --device=dri + # use host PTS namespace + - --device=all # Windowing - --share=ipc - --socket=fallback-x11 diff --git a/flatpak/com.mitchellh.ghostty.yml b/flatpak/com.mitchellh.ghostty.yml index 17c92633f..1b119c11b 100644 --- a/flatpak/com.mitchellh.ghostty.yml +++ b/flatpak/com.mitchellh.ghostty.yml @@ -9,6 +9,8 @@ command: ghostty finish-args: # 3D rendering - --device=dri + # use host PTS namespace + - --device=all # Windowing - --share=ipc - --socket=fallback-x11 diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index bc3b6cd0c..fb032fe82 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/1e4957e65005908993250f8f07be3f70e805195e.tar.gz", - "dest": "vendor/p/N-V-__8AAHOASAQuLADcCSHLHEJiKFVLZiCD9Aq2rh5GT01A", - "sha256": "c690e2b57a59add53f11c80bc86e06d1c1224f8af8daf8b2f832402e6cb6b101" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/10f216f54a32cee6f1f2e3aa01812650a209c16b.tar.gz", + "dest": "vendor/p/N-V-__8AAMJWXQSVe0xzU_4UVRTDVERqnNpQU4wfS0FRGRRj", + "sha256": "4dcad36540957adbc01465f47c1aa0df3946f747e596349c36bfce611fcc2796" }, { "type": "archive", diff --git a/include/ghostty.h b/include/ghostty.h index 18c547910..9f17d0b97 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -103,10 +103,30 @@ typedef enum { GHOSTTY_ACTION_REPEAT, } ghostty_input_action_e; +// Based on: https://www.w3.org/TR/uievents-code/ typedef enum { - GHOSTTY_KEY_INVALID, + GHOSTTY_KEY_UNIDENTIFIED, - // a-z + // "Writing System Keys" § 3.1.1 + GHOSTTY_KEY_BACKQUOTE, + GHOSTTY_KEY_BACKSLASH, + GHOSTTY_KEY_BRACKET_LEFT, + GHOSTTY_KEY_BRACKET_RIGHT, + GHOSTTY_KEY_COMMA, + GHOSTTY_KEY_DIGIT_0, + GHOSTTY_KEY_DIGIT_1, + GHOSTTY_KEY_DIGIT_2, + GHOSTTY_KEY_DIGIT_3, + GHOSTTY_KEY_DIGIT_4, + GHOSTTY_KEY_DIGIT_5, + GHOSTTY_KEY_DIGIT_6, + GHOSTTY_KEY_DIGIT_7, + GHOSTTY_KEY_DIGIT_8, + GHOSTTY_KEY_DIGIT_9, + GHOSTTY_KEY_EQUAL, + GHOSTTY_KEY_INTL_BACKSLASH, + GHOSTTY_KEY_INTL_RO, + GHOSTTY_KEY_INTL_YEN, GHOSTTY_KEY_A, GHOSTTY_KEY_B, GHOSTTY_KEY_C, @@ -133,56 +153,91 @@ typedef enum { GHOSTTY_KEY_X, GHOSTTY_KEY_Y, GHOSTTY_KEY_Z, - - // numbers - GHOSTTY_KEY_ZERO, - GHOSTTY_KEY_ONE, - GHOSTTY_KEY_TWO, - GHOSTTY_KEY_THREE, - GHOSTTY_KEY_FOUR, - GHOSTTY_KEY_FIVE, - GHOSTTY_KEY_SIX, - GHOSTTY_KEY_SEVEN, - GHOSTTY_KEY_EIGHT, - GHOSTTY_KEY_NINE, - - // puncuation - GHOSTTY_KEY_SEMICOLON, - GHOSTTY_KEY_SPACE, - GHOSTTY_KEY_APOSTROPHE, - GHOSTTY_KEY_COMMA, - GHOSTTY_KEY_GRAVE_ACCENT, // ` - GHOSTTY_KEY_PERIOD, - GHOSTTY_KEY_SLASH, GHOSTTY_KEY_MINUS, - GHOSTTY_KEY_PLUS, - GHOSTTY_KEY_EQUAL, - GHOSTTY_KEY_LEFT_BRACKET, // [ - GHOSTTY_KEY_RIGHT_BRACKET, // ] - GHOSTTY_KEY_BACKSLASH, // \ + GHOSTTY_KEY_PERIOD, + GHOSTTY_KEY_QUOTE, + GHOSTTY_KEY_SEMICOLON, + GHOSTTY_KEY_SLASH, - // control - GHOSTTY_KEY_UP, - GHOSTTY_KEY_DOWN, - GHOSTTY_KEY_RIGHT, - GHOSTTY_KEY_LEFT, - GHOSTTY_KEY_HOME, - GHOSTTY_KEY_END, - GHOSTTY_KEY_INSERT, - GHOSTTY_KEY_DELETE, - GHOSTTY_KEY_CAPS_LOCK, - GHOSTTY_KEY_SCROLL_LOCK, - GHOSTTY_KEY_NUM_LOCK, - GHOSTTY_KEY_PAGE_UP, - GHOSTTY_KEY_PAGE_DOWN, - GHOSTTY_KEY_ESCAPE, - GHOSTTY_KEY_ENTER, - GHOSTTY_KEY_TAB, + // "Functional Keys" § 3.1.2 + GHOSTTY_KEY_ALT_LEFT, + GHOSTTY_KEY_ALT_RIGHT, GHOSTTY_KEY_BACKSPACE, - GHOSTTY_KEY_PRINT_SCREEN, - GHOSTTY_KEY_PAUSE, + GHOSTTY_KEY_CAPS_LOCK, + GHOSTTY_KEY_CONTEXT_MENU, + GHOSTTY_KEY_CONTROL_LEFT, + GHOSTTY_KEY_CONTROL_RIGHT, + GHOSTTY_KEY_ENTER, + GHOSTTY_KEY_META_LEFT, + GHOSTTY_KEY_META_RIGHT, + GHOSTTY_KEY_SHIFT_LEFT, + GHOSTTY_KEY_SHIFT_RIGHT, + GHOSTTY_KEY_SPACE, + GHOSTTY_KEY_TAB, + GHOSTTY_KEY_CONVERT, + GHOSTTY_KEY_KANA_MODE, + GHOSTTY_KEY_NON_CONVERT, - // function keys + // "Control Pad Section" § 3.2 + GHOSTTY_KEY_DELETE, + GHOSTTY_KEY_END, + GHOSTTY_KEY_HELP, + GHOSTTY_KEY_HOME, + GHOSTTY_KEY_INSERT, + GHOSTTY_KEY_PAGE_DOWN, + GHOSTTY_KEY_PAGE_UP, + + // "Arrow Pad Section" § 3.3 + GHOSTTY_KEY_ARROW_DOWN, + GHOSTTY_KEY_ARROW_LEFT, + GHOSTTY_KEY_ARROW_RIGHT, + GHOSTTY_KEY_ARROW_UP, + + // "Numpad Section" § 3.4 + GHOSTTY_KEY_NUM_LOCK, + GHOSTTY_KEY_NUMPAD_0, + GHOSTTY_KEY_NUMPAD_1, + GHOSTTY_KEY_NUMPAD_2, + GHOSTTY_KEY_NUMPAD_3, + GHOSTTY_KEY_NUMPAD_4, + GHOSTTY_KEY_NUMPAD_5, + GHOSTTY_KEY_NUMPAD_6, + GHOSTTY_KEY_NUMPAD_7, + GHOSTTY_KEY_NUMPAD_8, + GHOSTTY_KEY_NUMPAD_9, + GHOSTTY_KEY_NUMPAD_ADD, + GHOSTTY_KEY_NUMPAD_BACKSPACE, + GHOSTTY_KEY_NUMPAD_CLEAR, + GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, + GHOSTTY_KEY_NUMPAD_COMMA, + GHOSTTY_KEY_NUMPAD_DECIMAL, + GHOSTTY_KEY_NUMPAD_DIVIDE, + GHOSTTY_KEY_NUMPAD_ENTER, + GHOSTTY_KEY_NUMPAD_EQUAL, + GHOSTTY_KEY_NUMPAD_MEMORY_ADD, + GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, + GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, + GHOSTTY_KEY_NUMPAD_MEMORY_STORE, + GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, + GHOSTTY_KEY_NUMPAD_MULTIPLY, + GHOSTTY_KEY_NUMPAD_PAREN_LEFT, + GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, + GHOSTTY_KEY_NUMPAD_SUBTRACT, + GHOSTTY_KEY_NUMPAD_SEPARATOR, + GHOSTTY_KEY_NUMPAD_UP, + GHOSTTY_KEY_NUMPAD_DOWN, + GHOSTTY_KEY_NUMPAD_RIGHT, + GHOSTTY_KEY_NUMPAD_LEFT, + GHOSTTY_KEY_NUMPAD_BEGIN, + GHOSTTY_KEY_NUMPAD_HOME, + GHOSTTY_KEY_NUMPAD_END, + GHOSTTY_KEY_NUMPAD_INSERT, + GHOSTTY_KEY_NUMPAD_DELETE, + GHOSTTY_KEY_NUMPAD_PAGE_UP, + GHOSTTY_KEY_NUMPAD_PAGE_DOWN, + + // "Function Section" § 3.5 + GHOSTTY_KEY_ESCAPE, GHOSTTY_KEY_F1, GHOSTTY_KEY_F2, GHOSTTY_KEY_F3, @@ -208,47 +263,40 @@ typedef enum { GHOSTTY_KEY_F23, GHOSTTY_KEY_F24, GHOSTTY_KEY_F25, + GHOSTTY_KEY_FN, + GHOSTTY_KEY_FN_LOCK, + GHOSTTY_KEY_PRINT_SCREEN, + GHOSTTY_KEY_SCROLL_LOCK, + GHOSTTY_KEY_PAUSE, - // keypad - GHOSTTY_KEY_KP_0, - GHOSTTY_KEY_KP_1, - GHOSTTY_KEY_KP_2, - GHOSTTY_KEY_KP_3, - GHOSTTY_KEY_KP_4, - GHOSTTY_KEY_KP_5, - GHOSTTY_KEY_KP_6, - GHOSTTY_KEY_KP_7, - GHOSTTY_KEY_KP_8, - GHOSTTY_KEY_KP_9, - GHOSTTY_KEY_KP_DECIMAL, - GHOSTTY_KEY_KP_DIVIDE, - GHOSTTY_KEY_KP_MULTIPLY, - GHOSTTY_KEY_KP_SUBTRACT, - GHOSTTY_KEY_KP_ADD, - GHOSTTY_KEY_KP_ENTER, - GHOSTTY_KEY_KP_EQUAL, - GHOSTTY_KEY_KP_SEPARATOR, - GHOSTTY_KEY_KP_LEFT, - GHOSTTY_KEY_KP_RIGHT, - GHOSTTY_KEY_KP_UP, - GHOSTTY_KEY_KP_DOWN, - GHOSTTY_KEY_KP_PAGE_UP, - GHOSTTY_KEY_KP_PAGE_DOWN, - GHOSTTY_KEY_KP_HOME, - GHOSTTY_KEY_KP_END, - GHOSTTY_KEY_KP_INSERT, - GHOSTTY_KEY_KP_DELETE, - GHOSTTY_KEY_KP_BEGIN, + // "Media Keys" § 3.6 + GHOSTTY_KEY_BROWSER_BACK, + GHOSTTY_KEY_BROWSER_FAVORITES, + GHOSTTY_KEY_BROWSER_FORWARD, + GHOSTTY_KEY_BROWSER_HOME, + GHOSTTY_KEY_BROWSER_REFRESH, + GHOSTTY_KEY_BROWSER_SEARCH, + GHOSTTY_KEY_BROWSER_STOP, + GHOSTTY_KEY_EJECT, + GHOSTTY_KEY_LAUNCH_APP_1, + GHOSTTY_KEY_LAUNCH_APP_2, + GHOSTTY_KEY_LAUNCH_MAIL, + GHOSTTY_KEY_MEDIA_PLAY_PAUSE, + GHOSTTY_KEY_MEDIA_SELECT, + GHOSTTY_KEY_MEDIA_STOP, + GHOSTTY_KEY_MEDIA_TRACK_NEXT, + GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, + GHOSTTY_KEY_POWER, + GHOSTTY_KEY_SLEEP, + GHOSTTY_KEY_AUDIO_VOLUME_DOWN, + GHOSTTY_KEY_AUDIO_VOLUME_MUTE, + GHOSTTY_KEY_AUDIO_VOLUME_UP, + GHOSTTY_KEY_WAKE_UP, - // modifiers - GHOSTTY_KEY_LEFT_SHIFT, - GHOSTTY_KEY_LEFT_CONTROL, - GHOSTTY_KEY_LEFT_ALT, - GHOSTTY_KEY_LEFT_SUPER, - GHOSTTY_KEY_RIGHT_SHIFT, - GHOSTTY_KEY_RIGHT_CONTROL, - GHOSTTY_KEY_RIGHT_ALT, - GHOSTTY_KEY_RIGHT_SUPER, + // "Legacy, Non-standard, and Special Keys" § 3.7 + GHOSTTY_KEY_COPY, + GHOSTTY_KEY_CUT, + GHOSTTY_KEY_PASTE, } ghostty_input_key_e; typedef struct { @@ -262,7 +310,6 @@ typedef struct { } ghostty_input_key_s; typedef enum { - GHOSTTY_TRIGGER_TRANSLATED, GHOSTTY_TRIGGER_PHYSICAL, GHOSTTY_TRIGGER_UNICODE, } ghostty_input_trigger_tag_e; @@ -606,6 +653,7 @@ typedef enum { GHOSTTY_ACTION_INITIAL_SIZE, GHOSTTY_ACTION_CELL_SIZE, GHOSTTY_ACTION_INSPECTOR, + GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, GHOSTTY_ACTION_RENDER_INSPECTOR, GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, @@ -625,6 +673,9 @@ typedef enum { GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_UNDO, + GHOSTTY_ACTION_REDO, + GHOSTTY_ACTION_CHECK_FOR_UPDATES } ghostty_action_tag_e; typedef union { @@ -735,6 +786,7 @@ ghostty_app_t ghostty_surface_app(ghostty_surface_t); ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t); void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t); bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); +bool ghostty_surface_process_exited(ghostty_surface_t); void ghostty_surface_refresh(ghostty_surface_t); void ghostty_surface_draw(ghostty_surface_t); void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a34c4685f..a5663202b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -12,10 +12,13 @@ 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; + A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; - A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */; }; + A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */; }; + A51545002DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */; }; + A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */; }; A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51BFC1D2B2FB5CE00E92F16 /* About.xib */; }; A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */; }; A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC212B2FB6B400E92F16 /* AboutView.swift */; }; @@ -51,6 +54,12 @@ A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; }; A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; }; A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; + A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; }; + A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; }; + A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; }; + A5593FE52DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */; }; + A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */; }; + A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */; }; A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */; }; A56B880B2A840447007A0E29 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A56B880A2A840447007A0E29 /* Carbon.framework */; }; @@ -59,6 +68,12 @@ A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; }; A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; }; + A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; }; + A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; }; + A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; }; + A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366E2DF25D8300E04A10 /* Duration+Extension.swift */; }; + A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */; }; + A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636722DF4813000E04A10 /* UndoManager+Extension.swift */; }; A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; }; A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; @@ -66,9 +81,6 @@ A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A59630992AEE1C6400D64628 /* Terminal.xib */; }; A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309B2AEE1C9E00D64628 /* TerminalController.swift */; }; A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309D2AEE1D6C00D64628 /* TerminalView.swift */; }; - A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; }; - A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */; }; - A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */; }; A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; A5985CE62C33060F00C57AD3 /* man in Resources */ = {isa = PBXBuildFile; fileRef = A5985CE52C33060F00C57AD3 /* man */; }; @@ -78,9 +90,10 @@ A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3C92D4445E20033CF96 /* Dock.swift */; }; A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */; }; - A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; + A5A6F72A2CC41B8900B232A5 /* AppInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* AppInfo.swift */; }; A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */; }; A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; }; A5CA378E2D31D6C300931030 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378D2D31D6C100931030 /* Weak.swift */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; @@ -108,7 +121,6 @@ A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; - AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; }; @@ -125,8 +137,11 @@ 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; + A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; - A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; + A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = ""; }; + A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = ""; }; + A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsVenturaTerminalWindow.swift; sourceTree = ""; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = ""; }; A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = ""; }; A51BFC212B2FB6B400E92F16 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -156,6 +171,12 @@ A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = ""; }; A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = ""; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; + A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; + A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = ""; }; + A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = ""; }; + A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarVentura.xib; sourceTree = ""; }; + A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentTitlebarTerminalWindow.swift; sourceTree = ""; }; + A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTransparentTitlebar.xib; sourceTree = ""; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = ""; }; A56B880A2A840447007A0E29 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; @@ -164,6 +185,12 @@ A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = ""; }; A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = ""; }; A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = ""; }; + A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = ""; }; + A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = ""; }; + A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; + A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = ""; }; + A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringUndoManager.swift; sourceTree = ""; }; + A58636722DF4813000E04A10 /* UndoManager+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UndoManager+Extension.swift"; sourceTree = ""; }; A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -171,9 +198,6 @@ A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = ""; }; A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; - A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; - A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.TerminalSplit.swift; sourceTree = ""; }; - A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitNode.swift; sourceTree = ""; }; A5985CD62C320C4500C57AD3 /* String+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = ""; }; A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAppearance+Extension.swift"; sourceTree = ""; }; @@ -182,11 +206,12 @@ A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; A5A2A3C92D4445E20033CF96 /* Dock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dock.swift; sourceTree = ""; }; A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+Extension.swift"; sourceTree = ""; }; - A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = ""; }; + A5A6F7292CC41B8700B232A5 /* AppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfo.swift; sourceTree = ""; }; A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastWindowPosition.swift; sourceTree = ""; }; A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; + A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMenuItem+Extension.swift"; sourceTree = ""; }; A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; A5CA378D2D31D6C100931030 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; @@ -215,7 +240,6 @@ A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; - AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; C1F26EA62B738B9900404083 /* NSView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView+Extension.swift"; sourceTree = ""; }; C1F26EE72B76CBFC00404083 /* VibrantLayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = VibrantLayer.h; sourceTree = ""; }; @@ -275,6 +299,7 @@ A5CBD05A2CA0C5910017A1AE /* QuickTerminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, A57D79252C9C8782001D522E /* Secure Input */, + A58636622DEF955100E04A10 /* Splits */, A53A29742DB2E04900B6E02C /* Command Palette */, A534263E2A7DCC5800EBB7A2 /* Settings */, A51BFC1C2B2FB5AB00E92F16 /* About */, @@ -287,34 +312,23 @@ A534263D2A7DCBB000EBB7A2 /* Helpers */ = { isa = PBXGroup; children = ( + A58636692DF0A98100E04A10 /* Extensions */, A5874D9B2DAD781100E83852 /* Private */, + A5A6F7292CC41B8700B232A5 /* AppInfo.swift */, A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */, - A5A6F7292CC41B8700B232A5 /* Xcode.swift */, A5CEAFFE29C2410700646FDA /* Backport.swift */, A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5CBD0572C9F30860017A1AE /* Cursor.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, + A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */, A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */, - A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, - A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */, - C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, - A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, - A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, - A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */, - A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, - C1F26EA62B738B9900404083 /* NSView+Extension.swift */, - AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, - A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */, - A5985CD62C320C4500C57AD3 /* String+Extension.swift */, - A5CC36142C9CDA03004D6760 /* View+Extension.swift */, A5CA378D2D31D6C100931030 /* Weak.swift */, C1F26EE72B76CBFC00404083 /* VibrantLayer.h */, C1F26EE82B76CBFC00404083 /* VibrantLayer.m */, - A5CEAFDA29B8005900646FDA /* SplitView */, ); path = Helpers; sourceTree = ""; @@ -388,6 +402,23 @@ path = Sources; sourceTree = ""; }; + A5593FDD2DF8D56000B47B10 /* Window Styles */ = { + isa = PBXGroup; + children = ( + A59630992AEE1C6400D64628 /* Terminal.xib */, + A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */, + A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */, + A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */, + A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */, + A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */, + A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */, + A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */, + A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */, + A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */, + ); + path = "Window Styles"; + sourceTree = ""; + }; A55B7BB429B6F4410055DE60 /* Ghostty */ = { isa = PBXGroup; children = ( @@ -402,8 +433,6 @@ A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, - A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */, - A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */, A55685DF29A03A9F004303CE /* AppError.swift */, A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */, A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */, @@ -428,6 +457,41 @@ path = "Secure Input"; sourceTree = ""; }; + A58636622DEF955100E04A10 /* Splits */ = { + isa = PBXGroup; + children = ( + A586365E2DEE6C2100E04A10 /* SplitTree.swift */, + A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */, + A5CEAFDB29B8009000646FDA /* SplitView.swift */, + A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */, + ); + path = Splits; + sourceTree = ""; + }; + A58636692DF0A98100E04A10 /* Extensions */ = { + isa = PBXGroup; + children = ( + A586366A2DF0A98900E04A10 /* Array+Extension.swift */, + A50297342DFA0F3300B4E924 /* Double+Extension.swift */, + A586366E2DF25D8300E04A10 /* Duration+Extension.swift */, + A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */, + A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */, + C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, + A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, + A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, + A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */, + A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */, + A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, + AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, + C1F26EA62B738B9900404083 /* NSView+Extension.swift */, + A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */, + A5985CD62C320C4500C57AD3 /* String+Extension.swift */, + A58636722DF4813000E04A10 /* UndoManager+Extension.swift */, + A5CC36142C9CDA03004D6760 /* View+Extension.swift */, + ); + path = Extensions; + sourceTree = ""; + }; A5874D9B2DAD781100E83852 /* Private */ = { isa = PBXGroup; children = ( @@ -440,13 +504,10 @@ A59630982AEE1C4400D64628 /* Terminal */ = { isa = PBXGroup; children = ( - A59630992AEE1C6400D64628 /* Terminal.xib */, - A596309F2AEF6AEB00D64628 /* TerminalManager.swift */, + A5593FDD2DF8D56000B47B10 /* Window Styles */, A596309B2AEE1C9E00D64628 /* TerminalController.swift */, A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, - A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */, - AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */, ); @@ -515,15 +576,6 @@ path = "Global Keybinds"; sourceTree = ""; }; - A5CEAFDA29B8005900646FDA /* SplitView */ = { - isa = PBXGroup; - children = ( - A5CEAFDB29B8009000646FDA /* SplitView.swift */, - A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */, - ); - path = SplitView; - sourceTree = ""; - }; A5D495A3299BECBA00DD1313 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -630,9 +682,11 @@ buildActionMask = 2147483647; files = ( FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */, + A5593FE52DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib in Resources */, 29C15B1D2CDC3B2900520DD4 /* bat in Resources */, A586167C2B7703CC009BDB1D /* fish in Resources */, 55154BE02B33911F001622DC /* ghostty in Resources */, + A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */, A546F1142D7B68D7003B11A0 /* locale in Resources */, A5985CE62C33060F00C57AD3 /* man in Resources */, 9351BE8E3D22937F003B3499 /* nvim in Resources */, @@ -641,10 +695,12 @@ FC5218FA2D10FFCE004C93E0 /* zsh in Resources */, A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */, A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */, + A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */, A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */, A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */, 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */, A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */, + A51545002DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib in Resources */, A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -665,17 +721,18 @@ buildActionMask = 2147483647; files = ( A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */, - A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */, A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, + A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */, A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, + A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */, A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */, A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, @@ -684,40 +741,47 @@ A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, + A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */, A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */, + A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */, + A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, + A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */, + A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, - A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */, AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */, A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */, - A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */, + A5A6F72A2CC41B8900B232A5 /* AppInfo.swift in Sources */, A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, A5CA378E2D31D6C300931030 /* Weak.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, + A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */, A5874D992DAD751B00E83852 /* CGS.swift in Sources */, + A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */, + A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */, - A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */, A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, - A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */, + A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */, A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, + A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */, A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */, @@ -734,8 +798,8 @@ A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */, A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */, A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */, + A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */, A52FFF592CAA4FF3000C6A5B /* Fullscreen.swift in Sources */, - AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */, C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */, A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */, A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index a3a3185d9..f460017f5 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -36,6 +36,8 @@ class AppDelegate: NSObject, @IBOutlet private var menuCloseWindow: NSMenuItem? @IBOutlet private var menuCloseAllWindows: NSMenuItem? + @IBOutlet private var menuUndo: NSMenuItem? + @IBOutlet private var menuRedo: NSMenuItem? @IBOutlet private var menuCopy: NSMenuItem? @IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuPasteSelection: NSMenuItem? @@ -85,8 +87,8 @@ class AppDelegate: NSObject, /// The ghostty global state. Only one per process. let ghostty: Ghostty.App = Ghostty.App() - /// Manages our terminal windows. - let terminalManager: TerminalManager + /// The global undo manager for app-level state such as window restoration. + lazy var undoManager = ExpiringUndoManager() /// Our quick terminal. This starts out uninitialized and only initializes if used. private var quickController: QuickTerminalController? = nil @@ -114,7 +116,6 @@ class AppDelegate: NSObject, } override init() { - terminalManager = TerminalManager(ghostty) updaterController = SPUStandardUpdaterController( // Important: we must not start the updater here because we need to read our configuration // first to determine whether we're automatically checking, downloading, etc. The updater @@ -154,10 +155,6 @@ class AppDelegate: NSObject, toggleSecureInput(self) } - // Hook up updater menu - menuCheckForUpdates?.target = updaterController - menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:)) - // Initial config loading ghosttyConfigDidChange(config: ghostty.config) @@ -169,7 +166,7 @@ class AppDelegate: NSObject, // This registers the Ghostty => Services menu to exist. NSApp.servicesMenu = menuServices - + // Setup a local event monitor for app-level keyboard shortcuts. See // localEventHandler for more info why. _ = NSEvent.addLocalMonitorForEvents( @@ -201,6 +198,16 @@ class AppDelegate: NSObject, name: .ghosttyBellDidRing, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyNewWindow(_:)), + name: Ghostty.Notification.ghosttyNewWindow, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyNewTab(_:)), + name: Ghostty.Notification.ghosttyNewTab, + object: nil) // Configure user notifications let actions = [ @@ -235,6 +242,9 @@ class AppDelegate: NSObject, ghostty_app_set_color_scheme(app, scheme) } + + // Setup our menu + setupMenuImages() } func applicationDidBecomeActive(_ notification: Notification) { @@ -252,8 +262,10 @@ class AppDelegate: NSObject, // is possible to have other windows in a few scenarios: // - if we're opening a URL since `application(_:openFile:)` is called before this. // - if we're restoring from persisted state - if terminalManager.windows.count == 0 && derivedConfig.initialWindow { - terminalManager.newWindow() + if TerminalController.all.isEmpty && derivedConfig.initialWindow { + undoManager.disableUndoRegistration() + _ = TerminalController.newWindow(ghostty) + undoManager.enableUndoRegistration() } } } @@ -320,6 +332,13 @@ class AppDelegate: NSObject, } } + func applicationWillTerminate(_ notification: Notification) { + // We have no notifications we want to persist after death, + // so remove them all now. In the future we may want to be + // more selective and only remove surface-targeted notifications. + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + } + /// This is called when the application is already open and someone double-clicks the icon /// or clicks the dock icon. func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { @@ -331,10 +350,15 @@ class AppDelegate: NSObject, // This is possible with flag set to false if there a race where the // window is still initializing and is not visible but the user clicked // the dock icon. - guard terminalManager.windows.count == 0 else { return true } + guard TerminalController.all.isEmpty else { return true } + + // If the application isn't active yet then we don't want to process + // this because we're not ready. This happens sometimes in Xcode runs + // but I haven't seen it happen in releases. I'm unsure why. + guard applicationHasBecomeActive else { return true } // No visible windows, open a new one. - terminalManager.newWindow() + _ = TerminalController.newWindow(ghostty) return false } @@ -350,16 +374,17 @@ class AppDelegate: NSObject, var config = Ghostty.SurfaceConfiguration() if (isDirectory.boolValue) { - // When opening a directory, create a new tab in the main window with that as the working directory. + // When opening a directory, create a new tab in the main + // window with that as the working directory. // If no windows exist, a new one will be created. config.workingDirectory = filename - terminalManager.newTab(withBaseConfig: config) + _ = TerminalController.newTab(ghostty, withBaseConfig: config) } else { // When opening a file, open a new window with that file as the command, // and its parent directory as the working directory. config.command = filename config.workingDirectory = (filename as NSString).deletingLastPathComponent - terminalManager.newWindow(withBaseConfig: config) + _ = TerminalController.newWindow(ghostty, withBaseConfig: config) } return true @@ -370,10 +395,46 @@ class AppDelegate: NSObject, return dockMenu } + /// Setup all the images for our menu items. + private func setupMenuImages() { + // Note: This COULD Be done all in the xib file, but I find it easier to + // modify this stuff as code. + self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus") + self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow") + self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled") + self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled") + self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled") + self.menuSplitDown?.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled") + self.menuClose?.setImageIfDesired(systemSymbolName: "xmark") + self.menuIncreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.larger") + self.menuResetFontSize?.setImageIfDesired(systemSymbolName: "textformat.size") + self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller") + self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection") + self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") + self.menuChangeTitle?.setImageIfDesired(systemSymbolName: "pencil.line") + self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") + self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") + self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") + self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") + self.menuPreviousSplit?.setImageIfDesired(systemSymbolName: "chevron.backward.2") + self.menuNextSplit?.setImageIfDesired(systemSymbolName: "chevron.forward.2") + self.menuEqualizeSplits?.setImageIfDesired(systemSymbolName: "inset.filled.topleft.topright.bottomleft.bottomright.rectangle") + self.menuSelectSplitLeft?.setImageIfDesired(systemSymbolName: "arrow.left") + self.menuSelectSplitRight?.setImageIfDesired(systemSymbolName: "arrow.right") + self.menuSelectSplitAbove?.setImageIfDesired(systemSymbolName: "arrow.up") + self.menuSelectSplitBelow?.setImageIfDesired(systemSymbolName: "arrow.down") + self.menuMoveSplitDividerUp?.setImageIfDesired(systemSymbolName: "arrow.up.to.line") + self.menuMoveSplitDividerDown?.setImageIfDesired(systemSymbolName: "arrow.down.to.line") + self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line") + self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line") + self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.3.layers.3d.top.filled") + } + /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. private func syncMenuShortcuts(_ config: Ghostty.Config) { guard ghostty.readiness == .ready else { return } + syncMenuShortcut(config, action: "check_for_updates", menuItem: self.menuCheckForUpdates) syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig) syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig) syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit) @@ -389,6 +450,8 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp) + syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo) + syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo) syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) @@ -445,10 +508,6 @@ class AppDelegate: NSObject, menu.keyEquivalentModifierMask = .init(swiftUIFlags: shortcut.modifiers) } - private func focusedSurface() -> ghostty_surface_t? { - return terminalManager.focusedSurface?.surface - } - // MARK: Notifications and Events /// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get @@ -469,17 +528,22 @@ class AppDelegate: NSObject, guard NSApp.mainWindow == nil else { return event } // If this event as-is would result in a key binding then we send it. - if let app = ghostty.app, - ghostty_app_key_is_binding( - app, - event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { + if let app = ghostty.app { + var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) + let match = (event.characters ?? "").withCString { ptr in + ghosttyEvent.text = ptr + if !ghostty_app_key_is_binding(app, ghosttyEvent) { + return false + } + + return ghostty_app_key(app, ghosttyEvent) + } + // If the key was handled by Ghostty we stop the event chain. If // the key wasn't handled then we let it fall through and continue // processing. This is important because some bindings may have no // affect at this scope. - if (ghostty_app_key( - app, - event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) { + if match { return nil } } @@ -528,11 +592,13 @@ class AppDelegate: NSObject, } @objc private func ghosttyBellDidRing(_ notification: Notification) { - // Bounce the dock icon if we're not focused. - NSApp.requestUserAttention(.informationalRequest) + if (ghostty.config.bellFeatures.contains(.attention)) { + // Bounce the dock icon if we're not focused. + NSApp.requestUserAttention(.informationalRequest) - // Handle setting the dock badge based on permissions - ghosttyUpdateBadgeForBell() + // Handle setting the dock badge based on permissions + ghosttyUpdateBadgeForBell() + } } private func ghosttyUpdateBadgeForBell() { @@ -574,6 +640,26 @@ class AppDelegate: NSObject, } } + @objc private func ghosttyNewWindow(_ notification: Notification) { + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? Ghostty.SurfaceConfiguration + _ = TerminalController.newWindow(ghostty, withBaseConfig: config) + } + + @objc private func ghosttyNewTab(_ notification: Notification) { + guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } + guard let window = surfaceView.window else { return } + + // We only want to listen to new tabs if the focused parent is + // a regular terminal controller. + guard window.windowController is TerminalController else { return } + + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? Ghostty.SurfaceConfiguration + + _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config) + } + private func setDockBadge(_ label: String? = "•") { NSApp.dockTile.badgeLabel = label NSApp.dockTile.display() @@ -609,7 +695,7 @@ class AppDelegate: NSObject, // Config could change keybindings, so update everything that depends on that syncMenuShortcuts(config) - terminalManager.relabelAllTabs() + TerminalController.all.forEach { $0.relabelTabs() } // Config could change window appearance. We wrap this in an async queue because when // this is called as part of application launch it can deadlock with an internal @@ -738,9 +824,11 @@ class AppDelegate: NSObject, //MARK: - GhosttyAppDelegate func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? { - for c in terminalManager.windows { - if let v = c.controller.surfaceTree?.findUUID(uuid: uuid) { - return v + for c in TerminalController.all { + for view in c.surfaceTree { + if view.uuid == uuid { + return view + } } } @@ -786,8 +874,12 @@ class AppDelegate: NSObject, ghostty.reloadConfig() } + @IBAction func checkForUpdates(_ sender: Any?) { + updaterController.checkForUpdates(sender) + } + @IBAction func newWindow(_ sender: Any?) { - terminalManager.newWindow() + _ = TerminalController.newWindow(ghostty) // We also activate our app so that it becomes front. This may be // necessary for the dock menu. @@ -795,7 +887,7 @@ class AppDelegate: NSObject, } @IBAction func newTab(_ sender: Any?) { - terminalManager.newTab() + _ = TerminalController.newTab(ghostty) // We also activate our app so that it becomes front. This may be // necessary for the dock menu. @@ -803,7 +895,7 @@ class AppDelegate: NSObject, } @IBAction func closeAllWindows(_ sender: Any?) { - terminalManager.closeAllWindows() + TerminalController.closeAllWindows() AboutController.shared.hide() } @@ -865,6 +957,14 @@ class AppDelegate: NSObject, NSApplication.shared.arrangeInFront(sender) } + @IBAction func undo(_ sender: Any?) { + undoManager.undo() + } + + @IBAction func redo(_ sender: Any?) { + undoManager.redo() + } + private struct DerivedConfig { let initialWindow: Bool let shouldQuitAfterLastWindowClosed: Bool @@ -954,6 +1054,22 @@ extension AppDelegate: NSMenuItemValidation { // terminal window (not quick terminal). return NSApp.keyWindow is TerminalWindow + case #selector(undo(_:)): + if undoManager.canUndo { + item.title = "Undo \(undoManager.undoActionName)" + } else { + item.title = "Undo" + } + return undoManager.canUndo + + case #selector(redo(_:)): + if undoManager.canRedo { + item.title = "Redo \(undoManager.redoActionName)" + } else { + item.title = "Redo" + } + return undoManager.canRedo + default: return true } diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 724f21355..c9bff8b4a 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -40,6 +40,7 @@ + @@ -57,6 +58,7 @@ + @@ -76,6 +78,9 @@ + + + @@ -201,6 +206,19 @@ + + + + + + + + + + + + + @@ -233,18 +251,18 @@ - - - - - - + + + + + + diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 57a76dd43..47f2baf23 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -29,7 +29,8 @@ struct TerminalCommandPaletteView: View { let key = String(cString: c.action_key) switch (key) { case "toggle_tab_overview", - "toggle_window_decorations": + "toggle_window_decorations", + "show_gtk_inspector": return false default: return true diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift index 935c2fb03..ae77535be 100644 --- a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -141,12 +141,7 @@ fileprivate func cgEventFlagsChangedHandler( guard let event: NSEvent = .init(cgEvent: cgEvent) else { return result } // Build our event input and call ghostty - var key_ev = ghostty_input_key_s() - key_ev.action = GHOSTTY_ACTION_PRESS - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) - key_ev.text = nil - key_ev.composing = false + let key_ev = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) if (ghostty_app_key(ghostty, key_ev)) { GlobalEventTap.logger.info("global key event handled event=\(event)") return nil diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 1abe30da1..28dea9579 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -21,6 +21,14 @@ class QuickTerminalController: BaseTerminalController { // The active space when the quick terminal was last shown. private var previousActiveSpace: CGSSpace? = nil + /// The window frame saved when the quick terminal's surface tree becomes empty. + /// + /// This preserves the user's window size and position when all terminal surfaces + /// are closed (e.g., via the `exit` command). When a new surface is created, + /// the window will be restored to this frame, preventing SwiftUI from resetting + /// the window to its default minimum size. + private var lastClosedFrame: NSRect? = nil + /// Non-nil if we have hidden dock state. private var hiddenDock: HiddenDock? = nil @@ -30,7 +38,7 @@ class QuickTerminalController: BaseTerminalController { init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree tree: Ghostty.SplitNode? = nil + surfaceTree tree: SplitTree? = nil ) { self.position = position self.derivedConfig = DerivedConfig(ghostty.config) @@ -53,6 +61,12 @@ class QuickTerminalController: BaseTerminalController { selector: #selector(ghosttyConfigDidChange(_:)), name: .ghosttyConfigDidChange, object: nil) + center.addObserver( + self, + selector: #selector(closeWindow(_:)), + name: .ghosttyCloseWindow, + object: nil + ) center.addObserver( self, selector: #selector(onNewTab), @@ -185,13 +199,51 @@ class QuickTerminalController: BaseTerminalController { // MARK: Base Controller Overrides - override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { super.surfaceTreeDidChange(from: from, to: to) - // If our surface tree is nil then we animate the window out. - if (to == nil) { + // If our surface tree is nil then we animate the window out. We + // defer reinitializing the tree to save some memory here. + if to.isEmpty { animateOut() + return } + + // If we're not empty (e.g. this isn't the first set) and we're + // not visible, then we animate in. This allows us to show the quick + // terminal when things such as undo/redo are done. + if !from.isEmpty && !visible { + animateIn() + return + } + } + + override func closeSurfaceNode( + _ node: SplitTree.Node, + withConfirmation: Bool = true + ) { + // If this isn't the root then we're dealing with a split closure. + if surfaceTree.root != node { + super.closeSurfaceNode(node, withConfirmation: withConfirmation) + return + } + + // If this isn't a final leaf then we're dealing with a split closure + guard case .leaf(let surface) = node else { + super.closeSurfaceNode(node, withConfirmation: withConfirmation) + return + } + + // If its the root, we check if the process exited. If it did, + // then we do empty the tree. + if surface.processExited { + surfaceTree = .init() + return + } + + // If its the root then we just animate out. We never actually allow + // the surface to fully close. + animateOut() } // MARK: Methods @@ -230,17 +282,18 @@ class QuickTerminalController: BaseTerminalController { // Set previous active space self.previousActiveSpace = CGSSpace.active() + // If our surface tree is empty then we initialize a new terminal. The surface + // tree can be empty if for example we run "exit" in the terminal and force + // animate out. + if surfaceTree.isEmpty, + let ghostty_app = ghostty.app { + let view = Ghostty.SurfaceView(ghostty_app, baseConfig: nil) + surfaceTree = SplitTree(view: view) + focusedSurface = view + } + // Animate the window in animateWindowIn(window: window, from: position) - - // If our surface tree is nil then we initialize a new terminal. The surface - // tree can be nil if for example we run "eixt" in the terminal and force - // animate out. - if (surfaceTree == nil) { - let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil) - surfaceTree = .leaf(leaf) - focusedSurface = leaf.surface - } } func animateOut() { @@ -262,6 +315,12 @@ class QuickTerminalController: BaseTerminalController { private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = derivedConfig.quickTerminalScreen.screen else { return } + // Restore our previous frame if we have one + if let lastClosedFrame { + window.setFrame(lastClosedFrame, display: false) + self.lastClosedFrame = nil + } + // Move our window off screen to the top position.setInitial(in: window, on: screen) @@ -372,6 +431,12 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { + // Save the current window frame before animating out. This preserves + // the user's preferred window size and position for when the quick + // terminal is reactivated with a new surface. Without this, SwiftUI + // would reset the window to its minimum content size. + lastClosedFrame = window.frame + // If we hid the dock then we unhide it. hiddenDock = nil diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index bb95cb55a..f60f94211 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -5,7 +5,7 @@ class ServiceProvider: NSObject { static private let errorNoString = NSString(string: "Could not load any text from the clipboard.") /// The target for an open operation - enum OpenTarget { + private enum OpenTarget { case tab case window } @@ -15,7 +15,7 @@ class ServiceProvider: NSObject { userData: String?, error: AutoreleasingUnsafeMutablePointer ) { - openTerminalFromPasteboard(pasteboard: pasteboard, target: .tab, error: error) + openTerminal(from: pasteboard, target: .tab, error: error) } @objc func openWindow( @@ -23,45 +23,39 @@ class ServiceProvider: NSObject { userData: String?, error: AutoreleasingUnsafeMutablePointer ) { - openTerminalFromPasteboard(pasteboard: pasteboard, target: .window, error: error) + openTerminal(from: pasteboard, target: .window, error: error) } - @inline(__always) - private func openTerminalFromPasteboard( - pasteboard: NSPasteboard, + private func openTerminal( + from pasteboard: NSPasteboard, target: OpenTarget, error: AutoreleasingUnsafeMutablePointer ) { - guard let objs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [NSURL] else { + guard let delegate = NSApp.delegate as? AppDelegate else { return } + + guard let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else { error.pointee = Self.errorNoString return } - let filePaths = objs.map { $0.path }.compactMap { $0 } - openTerminal(filePaths, target: target) - } + // Build a set of unique directory URLs to open. File paths are truncated + // to their directories because that's the only thing we can open. + let directoryURLs = Set( + pathURLs.map { url -> URL in + url.hasDirectoryPath ? url : url.deletingLastPathComponent() + } + ) - private func openTerminal(_ paths: [String], target: OpenTarget) { - guard let delegateRaw = NSApp.delegate else { return } - guard let delegate = delegateRaw as? AppDelegate else { return } - let terminalManager = delegate.terminalManager - - for path in paths { - // We only open in directories. - var isDirectory = ObjCBool(true) - guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { continue } - guard isDirectory.boolValue else { continue } - - // Build our config + for url in directoryURLs { var config = Ghostty.SurfaceConfiguration() - config.workingDirectory = path + config.workingDirectory = url.path(percentEncoded: false) switch (target) { case .window: - terminalManager.newWindow(withBaseConfig: config) + _ = TerminalController.newWindow(delegate.ghostty, withBaseConfig: config) case .tab: - terminalManager.newTab(withBaseConfig: config) + _ = TerminalController.newTab(delegate.ghostty, withBaseConfig: config) } } diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift new file mode 100644 index 000000000..1c4be7dd6 --- /dev/null +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -0,0 +1,1263 @@ +import AppKit + +/// SplitTree represents a tree of views that can be divided. +struct SplitTree: Codable { + /// The root of the tree. This can be nil to indicate the tree is empty. + let root: Node? + + /// The node that is currently zoomed. A zoomed split is expected to take up the full + /// size of the view area where the splits are shown. + let zoomed: Node? + + /// A single node in the tree is either a leaf node (a view) or a split (has a + /// left/right or top/bottom). + indirect enum Node: Codable { + case leaf(view: ViewType) + case split(Split) + + struct Split: Equatable, Codable { + let direction: Direction + let ratio: Double + let left: Node + let right: Node + } + } + + enum Direction: Codable { + case horizontal // Splits are laid out left and right + case vertical // Splits are laid out top and bottom + } + + /// The path to a specific node in the tree. + struct Path { + let path: [Component] + + var isEmpty: Bool { path.isEmpty } + + enum Component { + case left + case right + } + } + + /// Spatial representation of the split tree. This can be used to better understand + /// its physical representation to perform tasks such as navigation. + struct Spatial { + let slots: [Slot] + + /// A single slot within the spatial mapping of a tree. Note that the bounds are + /// _relative_. They can't be mapped to physical pixels because the SplitTree + /// isn't aware of actual rendering. But relative to each other the bounds are + /// correct. + struct Slot { + let node: Node + let bounds: CGRect + } + + /// Direction for spatial navigation within the split tree. + enum Direction { + case left + case right + case up + case down + } + } + + enum SplitError: Error { + case viewNotFound + } + + enum NewDirection { + case left + case right + case down + case up + } + + /// The direction that focus can move from a node. + enum FocusDirection { + // Follow a consistent tree-like structure. + case previous + case next + + // Spatially-aware navigation targets. These take into account the + // layout to find the spatially correct node to move to. Spatial navigation + // is always from the top-left corner for now. + case spatial(Spatial.Direction) + } +} + +// MARK: SplitTree + +extension SplitTree { + var isEmpty: Bool { + root == nil + } + + /// Returns true if this tree is split. + var isSplit: Bool { + if case .split = root { true } else { false } + } + + init() { + self.init(root: nil, zoomed: nil) + } + + init(view: ViewType) { + self.init(root: .leaf(view: view), zoomed: nil) + } + + /// Checks if the tree contains the specified node. + /// + /// Note that SplitTree implements Sequence on views so there's already a `contains` + /// for views too. + /// + /// - Parameter node: The node to search for in the tree + /// - Returns: True if the node exists in the tree, false otherwise + func contains(_ node: Node) -> Bool { + guard let root else { return false } + return root.path(to: node) != nil + } + + /// Insert a new view at the given view point by creating a split in the given direction. + /// This will always reset the zoomed state of the tree. + func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { + guard let root else { throw SplitError.viewNotFound } + return .init( + root: try root.insert(view: view, at: at, direction: direction), + zoomed: nil) + } + + /// Remove a node from the tree. If the node being removed is part of a split, + /// the sibling node takes the place of the parent split. + func remove(_ target: Node) -> Self { + guard let root else { return self } + + // If we're removing the root itself, return an empty tree + if root == target { + return .init(root: nil, zoomed: nil) + } + + // Otherwise, try to remove from the tree + let newRoot = root.remove(target) + + // Update zoomed if it was the removed node + let newZoomed = (zoomed == target) ? nil : zoomed + + return .init(root: newRoot, zoomed: newZoomed) + } + + /// Replace a node in the tree with a new node. + func replace(node: Node, with newNode: Node) throws -> Self { + guard let root else { throw SplitError.viewNotFound } + + // Get the path to the node we want to replace + guard let path = root.path(to: node) else { + throw SplitError.viewNotFound + } + + // Replace the node + let newRoot = try root.replaceNode(at: path, with: newNode) + + // Update zoomed if it was the replaced node + let newZoomed = (zoomed == node) ? newNode : zoomed + + return .init(root: newRoot, zoomed: newZoomed) + } + + /// Find the next view to focus based on the current focused node and direction + func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? { + guard let root else { return nil } + + switch direction { + case .previous: + // For previous, we traverse in order and find the previous leaf from our leftmost + let allLeaves = root.leaves() + let currentView = currentNode.leftmostLeaf() + guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else { + // Shouldn't be possible leftmostLeaf can't return something that doesn't exist! + return nil + } + let index = allLeaves.indexWrapping(before: currentIndex) + return allLeaves[index] + + case .next: + // For previous, we traverse in order and find the next leaf from our rightmost + let allLeaves = root.leaves() + let currentView = currentNode.rightmostLeaf() + guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else { + return nil + } + let index = allLeaves.indexWrapping(after: currentIndex) + return allLeaves[index] + + case .spatial(let spatialDirection): + // Get spatial representation and find best candidate + let spatial = root.spatial() + let nodes = spatial.slots(in: spatialDirection, from: currentNode) + + // If we have no nodes in the direction specified then we don't do + // anything. + if nodes.isEmpty { + return nil + } + + // Extract the view from the best candidate node. The best candidate + // node is the closest leaf node. If we have no leaves (impossible?) + // just use the first node. + let bestNode = nodes.first(where: { + if case .leaf = $0.node { return true } else { return false } + }) ?? nodes[0] + switch bestNode.node { + case .leaf(let view): + return view + + case .split: + // If the best candidate is a split node, use its the leaf/rightmost + // depending on our spatial direction. + return switch (spatialDirection) { + case .up, .left: bestNode.node.leftmostLeaf() + case .down, .right: bestNode.node.rightmostLeaf() + } + } + } + } + + /// Equalize all splits in the tree so that each split's ratio is based on the + /// relative weight (number of leaves) of its children. + func equalize() -> Self { + guard let root else { return self } + let newRoot = root.equalize() + return .init(root: newRoot, zoomed: zoomed) + } + + /// Resize a node in the tree by the given pixel amount in the specified direction. + /// + /// This method adjusts the split ratios of the tree to accommodate the requested resize + /// operation. For up/down resizing, it finds the nearest parent vertical split and adjusts + /// its ratio. For left/right resizing, it finds the nearest parent horizontal split. + /// The bounds parameter is used to construct the spatial tree representation which is + /// needed to calculate the current pixel dimensions. + /// + /// This will always reset the zoomed state. + /// + /// - Parameters: + /// - node: The node to resize + /// - by: The number of pixels to resize by + /// - direction: The direction to resize in (up, down, left, right) + /// - bounds: The bounds used to construct the spatial tree representation + /// - Returns: A new SplitTree with the adjusted split ratios + /// - Throws: SplitError.viewNotFound if the node is not found in the tree or no suitable parent split exists + func resize(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self { + guard let root else { throw SplitError.viewNotFound } + + // Find the path to the target node + guard let path = root.path(to: node) else { + throw SplitError.viewNotFound + } + + // Determine which type of split we need to find based on resize direction + let targetSplitDirection: Direction = switch direction { + case .up, .down: .vertical + case .left, .right: .horizontal + } + + // Find the nearest parent split of the correct type by walking up the path + var splitPath: Path? + var splitNode: Node? + + for i in stride(from: path.path.count - 1, through: 0, by: -1) { + let parentPath = Path(path: Array(path.path.prefix(i))) + if let parent = root.node(at: parentPath), case .split(let split) = parent { + if split.direction == targetSplitDirection { + splitPath = parentPath + splitNode = parent + break + } + } + } + + guard let splitPath = splitPath, + let splitNode = splitNode, + case .split(let split) = splitNode else { + throw SplitError.viewNotFound + } + + // Get current spatial representation to calculate pixel dimensions + let spatial = root.spatial(within: bounds.size) + guard let splitSlot = spatial.slots.first(where: { $0.node == splitNode }) else { + throw SplitError.viewNotFound + } + + // Calculate the new ratio based on pixel change + let pixelOffset = Double(pixels) + let newRatio: Double + + switch (split.direction, direction) { + case (.horizontal, .left): + // Moving left boundary: decrease left side + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.width))) + case (.horizontal, .right): + // Moving right boundary: increase left side + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.width))) + case (.vertical, .up): + // Moving top boundary: decrease top side + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.height))) + case (.vertical, .down): + // Moving bottom boundary: increase top side + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.height))) + default: + // Direction doesn't match split type - shouldn't happen due to earlier logic + throw SplitError.viewNotFound + } + + // Create new split with adjusted ratio + let newSplit = Node.Split( + direction: split.direction, + ratio: newRatio, + left: split.left, + right: split.right + ) + + // Replace the split node with the new one + let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit)) + return .init(root: newRoot, zoomed: nil) + } + + /// Returns the total bounds of the split hierarchy using NSView bounds. + /// Ignores x/y coordinates and assumes views are laid out in a perfect grid. + /// Also ignores any possible padding between views. + /// - Returns: The total width and height needed to contain all views + func viewBounds() -> CGSize { + guard let root else { return .zero } + return root.viewBounds() + } +} + +// MARK: SplitTree.Node + +extension SplitTree.Node { + typealias Node = SplitTree.Node + typealias NewDirection = SplitTree.NewDirection + typealias SplitError = SplitTree.SplitError + typealias Path = SplitTree.Path + + /// Returns the node in the tree that contains the given view. + func node(view: ViewType) -> Node? { + switch (self) { + case .leaf(view): + return self + + case .split(let split): + if let result = split.left.node(view: view) { + return result + } else if let result = split.right.node(view: view) { + return result + } + + return nil + + default: + return nil + } + } + + /// Returns the path to a given node in the tree. If the returned value is nil then the + /// node doesn't exist. + func path(to node: Self) -> Path? { + var components: [Path.Component] = [] + func search(_ current: Self) -> Bool { + if current == node { + return true + } + + switch current { + case .leaf: + return false + + case .split(let split): + // Try left branch + components.append(.left) + if search(split.left) { + return true + } + components.removeLast() + + // Try right branch + components.append(.right) + if search(split.right) { + return true + } + components.removeLast() + + return false + } + } + + return search(self) ? Path(path: components) : nil + } + + /// Returns the node at the given path from this node as root. + func node(at path: Path) -> Node? { + if path.isEmpty { + return self + } + + guard case .split(let split) = self else { + return nil + } + + let component = path.path[0] + let remainingPath = Path(path: Array(path.path.dropFirst())) + + switch component { + case .left: + return split.left.node(at: remainingPath) + case .right: + return split.right.node(at: remainingPath) + } + } + + /// Inserts a new view into the split tree by creating a split at the location of an existing view. + /// + /// This method creates a new split node containing both the existing view and the new view, + /// The position of the new view relative to the existing view is determined by the direction parameter. + /// + /// - Parameters: + /// - view: The new view to insert into the tree + /// - at: The existing view at whose location the split should be created + /// - direction: The direction relative to the existing view where the new view should be placed + /// + /// - Note: If the existing view (`at`) is not found in the tree, this method does nothing. We should + /// maybe throw instead but at the moment we just do nothing. + func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { + // Get the path to our insertion point. If it doesn't exist we do + // nothing. + guard let path = path(to: .leaf(view: at)) else { + throw SplitError.viewNotFound + } + + // Determine split direction and which side the new view goes on + let splitDirection: SplitTree.Direction + let newViewOnLeft: Bool + switch direction { + case .left: + splitDirection = .horizontal + newViewOnLeft = true + case .right: + splitDirection = .horizontal + newViewOnLeft = false + case .up: + splitDirection = .vertical + newViewOnLeft = true + case .down: + splitDirection = .vertical + newViewOnLeft = false + } + + // Create the new split node + let newNode: Node = .leaf(view: view) + let existingNode: Node = .leaf(view: at) + let newSplit: Node = .split(.init( + direction: splitDirection, + ratio: 0.5, + left: newViewOnLeft ? newNode : existingNode, + right: newViewOnLeft ? existingNode : newNode + )) + + // Replace the node at the path with the new split + return try replaceNode(at: path, with: newSplit) + } + + /// Helper function to replace a node at the given path from the root + func replaceNode(at path: Path, with newNode: Self) throws -> Self { + // If path is empty, replace the root + if path.isEmpty { + return newNode + } + + // Otherwise, we need to replace the proper left/right all along + // the way since Node is a value type (enum). To do that, we need + // recursion. We can't use a simple iterative approach because we + // can't update in-place. + func replaceInner(current: Node, pathOffset: Int) throws -> Node { + // Base case: if we've consumed the entire path, replace this node + if pathOffset >= path.path.count { + return newNode + } + + // We need to go deeper, so current must be a split for the path + // to be valid. Otherwise, the path is invalid. + guard case .split(let split) = current else { + throw SplitError.viewNotFound + } + + let component = path.path[pathOffset] + switch component { + case .left: + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: try replaceInner(current: split.left, pathOffset: pathOffset + 1), + right: split.right + )) + case .right: + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: split.left, + right: try replaceInner(current: split.right, pathOffset: pathOffset + 1) + )) + } + } + + return try replaceInner(current: self, pathOffset: 0) + } + + /// Remove a node from the tree. Returns the modified tree, or nil if removing + /// the node results in an empty tree. + func remove(_ target: Node) -> Node? { + // If we're removing ourselves, return nil + if self == target { + return nil + } + + switch self { + case .leaf: + // A leaf that isn't the target stays as is + return self + + case .split(let split): + // Neither child is directly the target, so we need to recursively + // try to remove from both children + let newLeft = split.left.remove(target) + let newRight = split.right.remove(target) + + // If both are nil then we remove everything. This shouldn't ever + // happen because duplicate nodes shouldn't exist, but we want to + // be robust against it. + if newLeft == nil && newRight == nil { + return nil + } else if newLeft == nil { + return newRight + } else if newRight == nil { + return newLeft + } + + // Both children still exist after removal + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: newLeft!, + right: newRight! + )) + } + } + + /// Resize a split node to the specified ratio. + /// For leaf nodes, this returns the node unchanged. + /// For split nodes, this creates a new split with the updated ratio. + func resize(to ratio: Double) -> Self { + switch self { + case .leaf: + // Leaf nodes don't have a ratio to resize + return self + + case .split(let split): + // Create a new split with the updated ratio + return .split(.init( + direction: split.direction, + ratio: ratio, + left: split.left, + right: split.right + )) + } + } + + /// Get the leftmost leaf in this subtree + func leftmostLeaf() -> ViewType { + switch self { + case .leaf(let view): + return view + case .split(let split): + return split.left.leftmostLeaf() + } + } + + /// Get the rightmost leaf in this subtree + func rightmostLeaf() -> ViewType { + switch self { + case .leaf(let view): + return view + case .split(let split): + return split.right.rightmostLeaf() + } + } + + /// Equalize this node and all its children, returning a new node with splits + /// adjusted so that each split's ratio is based on the relative weight + /// (number of leaves) of its children. + func equalize() -> Node { + let (equalizedNode, _) = equalizeWithWeight() + return equalizedNode + } + + /// Internal helper that equalizes and returns both the node and its weight. + private func equalizeWithWeight() -> (node: Node, weight: Int) { + switch self { + case .leaf: + // A leaf has weight 1 and doesn't change + return (self, 1) + + case .split(let split): + // Recursively equalize children + let (leftNode, leftWeight) = split.left.equalizeWithWeight() + let (rightNode, rightWeight) = split.right.equalizeWithWeight() + + // Calculate new ratio based on relative weights + let totalWeight = leftWeight + rightWeight + let newRatio = Double(leftWeight) / Double(totalWeight) + + // Create new split with equalized ratio + let newSplit = Split( + direction: split.direction, + ratio: newRatio, + left: leftNode, + right: rightNode + ) + + return (.split(newSplit), totalWeight) + } + } + + /// Calculate the bounds of all views in this subtree based on split ratios + func calculateViewBounds(in bounds: CGRect) -> [(view: ViewType, bounds: CGRect)] { + switch self { + case .leaf(let view): + return [(view, bounds)] + + case .split(let split): + // Calculate bounds for left and right based on split direction and ratio + let leftBounds: CGRect + let rightBounds: CGRect + + switch split.direction { + case .horizontal: + // Split horizontally: left | right + let splitX = bounds.minX + bounds.width * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width * split.ratio, + height: bounds.height + ) + rightBounds = CGRect( + x: splitX, + y: bounds.minY, + width: bounds.width * (1 - split.ratio), + height: bounds.height + ) + + case .vertical: + // Split vertically: top / bottom + // Note: In our normalized coordinate system, Y increases upward + let splitY = bounds.minY + bounds.height * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: splitY, + width: bounds.width, + height: bounds.height * (1 - split.ratio) + ) + rightBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: bounds.height * split.ratio + ) + } + + // Recursively calculate bounds for children + return split.left.calculateViewBounds(in: leftBounds) + + split.right.calculateViewBounds(in: rightBounds) + } + } + + /// Returns the total bounds of this subtree using NSView bounds. + /// Ignores x/y coordinates and assumes views are laid out in a perfect grid. + /// - Returns: The total width and height needed to contain all views in this subtree + func viewBounds() -> CGSize { + switch self { + case .leaf(let view): + return view.bounds.size + + case .split(let split): + let leftBounds = split.left.viewBounds() + let rightBounds = split.right.viewBounds() + + switch split.direction { + case .horizontal: + // Horizontal split: width is sum, height is max + return CGSize( + width: leftBounds.width + rightBounds.width, + height: Swift.max(leftBounds.height, rightBounds.height) + ) + + case .vertical: + // Vertical split: height is sum, width is max + return CGSize( + width: Swift.max(leftBounds.width, rightBounds.width), + height: leftBounds.height + rightBounds.height + ) + } + } + } +} + +// MARK: SplitTree.Node Spatial + +extension SplitTree.Node { + /// Returns the spatial representation of this node and its subtree. + /// + /// This method creates a `Spatial` representation that maps the logical split tree structure + /// to 2D coordinate space. The coordinate system uses (0,0) as the top-left corner with + /// positive X extending right and positive Y extending down. + /// + /// The spatial representation provides: + /// - Relative bounds for each node based on split ratios + /// - Grid-like dimensions where each split adds 1 to the column/row count + /// - Accurate positioning that reflects the actual layout structure + /// + /// The bounds are pixel perfect based on assuming that each row and column are 1 pixel + /// tall or wide, respectively. This needs to be scaled up to the proper bounds for a real + /// layout. + /// + /// Example: + /// ``` + /// // For a layout like: + /// // +--------+----+ + /// // | A | B | + /// // +--------+----+ + /// // | C | D | + /// // +--------+----+ + /// // + /// // The spatial representation would have: + /// // - Total dimensions: (width: 2, height: 2) + /// // - Node bounds based on actual split ratios + /// ``` + /// + /// - Parameter bounds: Optional size constraints for the spatial representation. If nil, uses artificial dimensions based + /// on grid layout + /// - Returns: A `Spatial` struct containing all slots with their calculated bounds + func spatial(within bounds: CGSize? = nil) -> SplitTree.Spatial { + // If we're not given bounds, we use artificial dimensions based on + // the total width/height in columns/rows. + let width: Double + let height: Double + if let bounds { + width = bounds.width + height = bounds.height + } else { + let (w, h) = self.dimensions() + width = Double(w) + height = Double(h) + } + + // Calculate slots with relative bounds + let slots = spatialSlots(in: CGRect(x: 0, y: 0, width: width, height: height)) + return SplitTree.Spatial(slots: slots) + } + + /// Calculates the grid dimensions (columns and rows) needed to represent this subtree. + /// + /// This method recursively analyzes the split tree structure to determine how many + /// columns and rows are needed to represent the layout in a 2D grid. Each leaf node + /// occupies one grid cell (1×1), and each split extends the grid in one direction: + /// + /// - **Horizontal splits**: Add columns (increase width) + /// - **Vertical splits**: Add rows (increase height) + /// + /// The calculation rules are: + /// - **Leaf nodes**: Always (1, 1) - one column, one row + /// - **Horizontal splits**: Width = sum of children widths, Height = max of children heights + /// - **Vertical splits**: Width = max of children widths, Height = sum of children heights + /// + /// Example: + /// ``` + /// // Single leaf: (1, 1) + /// // Horizontal split with 2 leaves: (2, 1) + /// // Vertical split with 2 leaves: (1, 2) + /// // Complex layout with both: (2, 2) or larger + /// ``` + /// + /// - Returns: A tuple containing (width: columns, height: rows) as unsigned integers + private func dimensions() -> (width: UInt, height: UInt) { + switch self { + case .leaf: + return (1, 1) + + case .split(let split): + let leftDimensions = split.left.dimensions() + let rightDimensions = split.right.dimensions() + + switch split.direction { + case .horizontal: + // Horizontal split: width is sum, height is max + return ( + width: leftDimensions.width + rightDimensions.width, + height: Swift.max(leftDimensions.height, rightDimensions.height) + ) + + case .vertical: + // Vertical split: height is sum, width is max + return ( + width: Swift.max(leftDimensions.width, rightDimensions.width), + height: leftDimensions.height + rightDimensions.height + ) + } + } + } + + /// Calculates the spatial slots (nodes with bounds) for this subtree within the given bounds. + /// + /// This method recursively traverses the split tree and calculates the precise bounds + /// for each node based on the split ratios and directions. The bounds are calculated + /// relative to the provided bounds rectangle. + /// + /// The calculation process: + /// 1. **Leaf nodes**: Create a single slot with the provided bounds + /// 2. **Split nodes**: + /// - Divide the bounds according to the split ratio and direction + /// - Create a slot for the split node itself + /// - Recursively calculate slots for both children + /// - Return all slots combined + /// + /// Split ratio interpretation: + /// - **Horizontal splits**: Ratio determines left/right width distribution + /// - Left child gets `ratio * width` + /// - Right child gets `(1 - ratio) * width` + /// - **Vertical splits**: Ratio determines top/bottom height distribution + /// - Top (left) child gets `ratio * height` + /// - Bottom (right) child gets `(1 - ratio) * height` + /// + /// Coordinate system: (0,0) is top-left, positive X goes right, positive Y goes down. + /// + /// - Parameter bounds: The bounding rectangle to subdivide for this subtree + /// - Returns: An array of `Spatial.Slot` objects, each containing a node and its bounds + private func spatialSlots(in bounds: CGRect) -> [SplitTree.Spatial.Slot] { + switch self { + case .leaf: + // A leaf takes up our full bounds. + return [.init(node: self, bounds: bounds)] + + case .split(let split): + let leftBounds: CGRect + let rightBounds: CGRect + + switch split.direction { + case .horizontal: + // Split horizontally: left | right using the ratio + let splitX = bounds.minX + bounds.width * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width * split.ratio, + height: bounds.height + ) + rightBounds = CGRect( + x: splitX, + y: bounds.minY, + width: bounds.width * (1 - split.ratio), + height: bounds.height + ) + + case .vertical: + // Split vertically: top / bottom using the ratio + // Top-left is (0,0), so top (left) gets the upper portion + let splitY = bounds.minY + bounds.height * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: bounds.height * split.ratio + ) + rightBounds = CGRect( + x: bounds.minX, + y: splitY, + width: bounds.width, + height: bounds.height * (1 - split.ratio) + ) + } + + // Recursively calculate slots for children and include a slot for this split + var slots: [SplitTree.Spatial.Slot] = [.init(node: self, bounds: bounds)] + slots += split.left.spatialSlots(in: leftBounds) + slots += split.right.spatialSlots(in: rightBounds) + + return slots + } + } +} + +// MARK: SplitTree.Spatial + +extension SplitTree.Spatial { + /// Returns all slots in the specified direction relative to the reference node. + /// + /// This method finds all slots positioned in the given direction from the reference node: + /// - **Left**: Slots with bounds to the left of the reference node + /// - **Right**: Slots with bounds to the right of the reference node + /// - **Up**: Slots with bounds above the reference node (Y=0 is top) + /// - **Down**: Slots with bounds below the reference node + /// + /// Results are sorted by 2D euclidean distance from the reference node, with closest slots first. + /// Distance is calculated from the top-left corners of the bounds, prioritizing nodes that are + /// closer in both dimensions. + /// + /// **Important**: The returned array contains both split nodes and leaf nodes. When using this + /// for navigation or focus management, you typically want to filter for leaf nodes first, as they + /// represent the actual views that can receive focus. Split nodes are included in the results + /// because they have bounds and occupy space in the layout, but they are structural elements + /// that cannot themselves be focused. If no leaf nodes are found in the results, you may need + /// to traverse into a split node to find its appropriate leaf child. + /// + /// - Parameters: + /// - direction: The direction to search for slots + /// - referenceNode: The node to use as the reference point + /// - Returns: An array of slots in the specified direction, sorted by 2D distance (closest first) + func slots(in direction: Direction, from referenceNode: SplitTree.Node) -> [Slot] { + guard let refSlot = slots.first(where: { $0.node == referenceNode }) else { return [] } + + // Helper function to calculate 2D euclidean distance between top-left corners of two rectangles + func distance(from rect1: CGRect, to rect2: CGRect) -> Double { + // Calculate distance between top-left corners + let dx = rect2.minX - rect1.minX + let dy = rect2.minY - rect1.minY + return sqrt(dx * dx + dy * dy) + } + + let result = switch direction { + case .left: + // Slots to the left: their right edge is at or left of reference's left edge + slots.filter { + $0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX + }.sorted { + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) + } + + case .right: + // Slots to the right: their left edge is at or right of reference's right edge + slots.filter { + $0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX + }.sorted { + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) + } + + case .up: + // Slots above: their bottom edge is at or above reference's top edge + slots.filter { + $0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY + }.sorted { + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) + } + + case .down: + // Slots below: their top edge is at or below reference's bottom edge + slots.filter { + $0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY + }.sorted { + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) + } + } + + return result + } + + /// Returns whether the given node borders the specified side of the spatial bounds. + /// + /// This method checks if a node's bounds touch the edge of the overall spatial area: + /// - **Up**: Node's top edge touches the top of the spatial area (Y=0) + /// - **Down**: Node's bottom edge touches the bottom of the spatial area (Y=maxY) + /// - **Left**: Node's left edge touches the left of the spatial area (X=0) + /// - **Right**: Node's right edge touches the right of the spatial area (X=maxX) + /// + /// - Parameters: + /// - side: The side of the spatial bounds to check + /// - node: The node to check if it borders the specified side + /// - Returns: True if the node borders the specified side, false otherwise + func doesBorder(side: Direction, from node: SplitTree.Node) -> Bool { + // Find the slot for this node + guard let slot = slots.first(where: { $0.node == node }) else { return false } + + // Calculate the overall bounds of all slots + let overallBounds = slots.reduce(CGRect.null) { result, slot in + result.union(slot.bounds) + } + + return switch side { + case .up: + slot.bounds.minY == overallBounds.minY + case .down: + slot.bounds.maxY == overallBounds.maxY + case .left: + slot.bounds.minX == overallBounds.minX + case .right: + slot.bounds.maxX == overallBounds.maxX + } + } +} + +// MARK: SplitTree.Node Protocols + +extension SplitTree.Node: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.leaf(leftView), .leaf(rightView)): + // Compare NSView instances by object identity + return leftView === rightView + + case let (.split(split1), .split(split2)): + return split1 == split2 + + default: + return false + } + } +} + +// MARK: SplitTree Codable + +extension SplitTree.Node { + enum CodingKeys: String, CodingKey { + case view + case split + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if container.contains(.view) { + let view = try container.decode(ViewType.self, forKey: .view) + self = .leaf(view: view) + } else if container.contains(.split) { + let split = try container.decode(Split.self, forKey: .split) + self = .split(split) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "No valid node type found" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .leaf(let view): + try container.encode(view, forKey: .view) + + case .split(let split): + try container.encode(split, forKey: .split) + } + } +} + +// MARK: SplitTree Sequences + +extension SplitTree.Node { + /// Returns all leaf views in this subtree + func leaves() -> [ViewType] { + switch self { + case .leaf(let view): + return [view] + + case .split(let split): + return split.left.leaves() + split.right.leaves() + } + } +} + +extension SplitTree: Sequence { + func makeIterator() -> [ViewType].Iterator { + return root?.leaves().makeIterator() ?? [].makeIterator() + } +} + +extension SplitTree.Node: Sequence { + func makeIterator() -> [ViewType].Iterator { + return leaves().makeIterator() + } +} + +// MARK: SplitTree Collection + +extension SplitTree: Collection { + typealias Index = Int + typealias Element = ViewType + + var startIndex: Int { + return 0 + } + + var endIndex: Int { + return root?.leaves().count ?? 0 + } + + subscript(position: Int) -> ViewType { + precondition(position >= 0 && position < endIndex, "Index out of bounds") + let leaves = root?.leaves() ?? [] + return leaves[position] + } + + func index(after i: Int) -> Int { + precondition(i < endIndex, "Cannot increment index beyond endIndex") + return i + 1 + } +} + +// MARK: Structural Identity + +extension SplitTree.Node { + /// Returns a hashable representation that captures this node's structural identity. + var structuralIdentity: StructuralIdentity { + StructuralIdentity(self) + } + + /// A hashable representation of a node that captures its structural identity. + /// + /// This type provides a way to track changes to a node's structure in SwiftUI + /// by implementing `Hashable` based on: + /// - The node's hierarchical structure (splits and their directions) + /// - The identity of view instances in leaf nodes (using object identity) + /// - The split directions (but not ratios, as those may change slightly) + /// + /// This is useful for SwiftUI's `id()` modifier to detect when a node's structure + /// has changed, triggering appropriate view updates while preserving view identity + /// for unchanged portions of the tree. + struct StructuralIdentity: Hashable { + private let node: SplitTree.Node + + init(_ node: SplitTree.Node) { + self.node = node + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.node.isStructurallyEqual(to: rhs.node) + } + + func hash(into hasher: inout Hasher) { + node.hashStructure(into: &hasher) + } + } + + /// Checks if this node is structurally equal to another node. + /// Two nodes are structurally equal if they have the same tree structure + /// and the same views (by identity) in the same positions. + fileprivate func isStructurallyEqual(to other: Node) -> Bool { + switch (self, other) { + case let (.leaf(view1), .leaf(view2)): + // Views must be the same instance + return view1 === view2 + + case let (.split(split1), .split(split2)): + // Splits must have same direction and structurally equal children + // Note: We intentionally don't compare ratios as they may change slightly + return split1.direction == split2.direction && + split1.left.isStructurallyEqual(to: split2.left) && + split1.right.isStructurallyEqual(to: split2.right) + + default: + // Different node types + return false + } + } + + /// Hash keys for structural identity + private enum HashKey: UInt8 { + case leaf = 0 + case split = 1 + } + + /// Hashes the structural identity of this node. + /// Includes the tree structure and view identities in the hash. + fileprivate func hashStructure(into hasher: inout Hasher) { + switch self { + case .leaf(let view): + hasher.combine(HashKey.leaf) + hasher.combine(ObjectIdentifier(view)) + + case .split(let split): + hasher.combine(HashKey.split) + hasher.combine(split.direction) + // Note: We intentionally don't hash the ratio + split.left.hashStructure(into: &hasher) + split.right.hashStructure(into: &hasher) + } + } +} + +extension SplitTree { + /// Returns a hashable representation that captures this tree's structural identity. + var structuralIdentity: StructuralIdentity { + StructuralIdentity(self) + } + + /// A hashable representation of a SplitTree that captures its structural identity. + /// + /// This type provides a way to track changes to a SplitTree's structure in SwiftUI + /// by implementing `Hashable` based on: + /// - The tree's hierarchical structure (splits and their directions) + /// - The identity of view instances in leaf nodes (using object identity) + /// - The zoomed node state (if any) + /// + /// This is useful for SwiftUI's `id()` modifier to detect when a tree's structure + /// has changed, triggering appropriate view updates while preserving view identity + /// for unchanged portions of the tree. + /// + /// Example usage: + /// ```swift + /// var body: some View { + /// SplitTreeView(tree: splitTree) + /// .id(splitTree.structuralIdentity) + /// } + /// ``` + struct StructuralIdentity: Hashable { + private let root: Node? + private let zoomed: Node? + + init(_ tree: SplitTree) { + self.root = tree.root + self.zoomed = tree.zoomed + } + + static func == (lhs: Self, rhs: Self) -> Bool { + areNodesStructurallyEqual(lhs.root, rhs.root) && + areNodesStructurallyEqual(lhs.zoomed, rhs.zoomed) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(0) // Tree marker + if let root = root { + root.hashStructure(into: &hasher) + } + hasher.combine(1) // Zoomed marker + if let zoomed = zoomed { + zoomed.hashStructure(into: &hasher) + } + } + + /// Helper to compare optional nodes for structural equality + private static func areNodesStructurallyEqual(_ lhs: Node?, _ rhs: Node?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + case let (node1?, node2?): + return node1.isStructurallyEqual(to: node2) + default: + return false + } + } + } +} diff --git a/macos/Sources/Helpers/SplitView/SplitView.Divider.swift b/macos/Sources/Features/Splits/SplitView.Divider.swift similarity index 100% rename from macos/Sources/Helpers/SplitView/SplitView.Divider.swift rename to macos/Sources/Features/Splits/SplitView.Divider.swift diff --git a/macos/Sources/Helpers/SplitView/SplitView.swift b/macos/Sources/Features/Splits/SplitView.swift similarity index 79% rename from macos/Sources/Helpers/SplitView/SplitView.swift rename to macos/Sources/Features/Splits/SplitView.swift index 8ac2bc33f..9747ac99f 100644 --- a/macos/Sources/Helpers/SplitView/SplitView.swift +++ b/macos/Sources/Features/Splits/SplitView.swift @@ -1,5 +1,4 @@ import SwiftUI -import Combine /// A split view shows a left and right (or top and bottom) view with a divider in the middle to do resizing. /// The terminlogy "left" and "right" is always used but for vertical splits "left" is "top" and "right" is "bottom". @@ -13,12 +12,10 @@ struct SplitView: View { /// Divider color let dividerColor: Color - /// If set, the split view supports programmatic resizing via events sent via the publisher. /// Minimum increment (in points) that this split can be resized by, in /// each direction. Both `height` and `width` should be whole numbers /// greater than or equal to 1.0 let resizeIncrements: NSSize - let resizePublisher: PassthroughSubject /// The left and right views to render. let left: L @@ -55,37 +52,15 @@ struct SplitView: View { .position(splitterPoint) .gesture(dragGesture(geo.size, splitterPoint: splitterPoint)) } - .onReceive(resizePublisher) { value in - resize(for: geo.size, amount: value) - } } } - /// Initialize a split view. This view isn't programmatically resizable; it can only be resized - /// by manually dragging the divider. - init(_ direction: SplitViewDirection, - _ split: Binding, - dividerColor: Color, - @ViewBuilder left: (() -> L), - @ViewBuilder right: (() -> R)) { - self.init( - direction, - split, - dividerColor: dividerColor, - resizeIncrements: .init(width: 1, height: 1), - resizePublisher: .init(), - left: left, - right: right - ) - } - - /// Initialize a split view that supports programmatic resizing. + /// Initialize a split view that can be resized by manually dragging the divider. init( _ direction: SplitViewDirection, _ split: Binding, dividerColor: Color, - resizeIncrements: NSSize, - resizePublisher: PassthroughSubject, + resizeIncrements: NSSize = .init(width: 1, height: 1), @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R) ) { @@ -93,25 +68,10 @@ struct SplitView: View { self._split = split self.dividerColor = dividerColor self.resizeIncrements = resizeIncrements - self.resizePublisher = resizePublisher self.left = left() self.right = right() } - private func resize(for size: CGSize, amount: Double) { - let dim: CGFloat - switch (direction) { - case .horizontal: - dim = size.width - case .vertical: - dim = size.height - } - - let pos = split * dim - let new = min(max(minSize, pos + amount), dim - minSize) - split = new / dim - } - private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture { return DragGesture() .onChanged { gesture in diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift new file mode 100644 index 000000000..2810fc2b4 --- /dev/null +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct TerminalSplitTreeView: View { + let tree: SplitTree + let onResize: (SplitTree.Node, Double) -> Void + + var body: some View { + if let node = tree.zoomed ?? tree.root { + TerminalSplitSubtreeView( + node: node, + isRoot: node == tree.root, + onResize: onResize) + // This is necessary because we can't rely on SwiftUI's implicit + // structural identity to detect changes to this view. Due to + // the tree structure of splits it could result in bad beaviors. + // See: https://github.com/ghostty-org/ghostty/issues/7546 + .id(node.structuralIdentity) + } + } +} + +struct TerminalSplitSubtreeView: View { + @EnvironmentObject var ghostty: Ghostty.App + + let node: SplitTree.Node + var isRoot: Bool = false + let onResize: (SplitTree.Node, Double) -> Void + + var body: some View { + switch (node) { + case .leaf(let leafView): + Ghostty.InspectableSurface( + surfaceView: leafView, + isSplit: !isRoot) + + case .split(let split): + let splitViewDirection: SplitViewDirection = switch (split.direction) { + case .horizontal: .horizontal + case .vertical: .vertical + } + + SplitView( + splitViewDirection, + .init(get: { + CGFloat(split.ratio) + }, set: { + onResize(node, $0) + }), + dividerColor: ghostty.config.splitDividerColor, + resizeIncrements: .init(width: 1, height: 1), + left: { + TerminalSplitSubtreeView(node: split.left, onResize: onResize) + }, + right: { + TerminalSplitSubtreeView(node: split.right, onResize: onResize) + } + ) + } + } +} diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 62384586a..bc91b920e 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -41,8 +41,8 @@ class BaseTerminalController: NSWindowController, didSet { syncFocusToSurfaceTree() } } - /// The surface tree for this window. - @Published var surfaceTree: Ghostty.SplitNode? = nil { + /// The tree of splits within this terminal window. + @Published var surfaceTree: SplitTree = .init() { didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } @@ -75,6 +75,27 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// The time that undo/redo operations that contain running ptys are valid for. + var undoExpiration: Duration { + ghostty.config.undoTimeout + } + + /// The undo manager for this controller is the undo manager of the window, + /// which we set via the delegate method. + override var undoManager: ExpiringUndoManager? { + // This should be set via the delegate method windowWillReturnUndoManager + if let result = window?.undoManager as? ExpiringUndoManager { + return result + } + + // If the window one isn't set, we fallback to our global one. + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + return appDelegate.undoManager + } + + return nil + } + struct SavedFrame { let window: NSRect let screen: NSRect @@ -86,7 +107,7 @@ class BaseTerminalController: NSWindowController, init(_ ghostty: Ghostty.App, baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree tree: Ghostty.SplitNode? = nil + surfaceTree tree: SplitTree? = nil ) { self.ghostty = ghostty self.derivedConfig = DerivedConfig(ghostty.config) @@ -95,7 +116,7 @@ class BaseTerminalController: NSWindowController, // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } - self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) + self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base)) // Setup our notifications for behaviors let center = NotificationCenter.default @@ -125,6 +146,38 @@ class BaseTerminalController: NSWindowController, name: .ghosttyMaximizeDidToggle, object: nil) + // Splits + center.addObserver( + self, + selector: #selector(ghosttyDidCloseSurface(_:)), + name: Ghostty.Notification.ghosttyCloseSurface, + object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidNewSplit(_:)), + name: Ghostty.Notification.ghosttyNewSplit, + object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidEqualizeSplits(_:)), + name: Ghostty.Notification.didEqualizeSplits, + object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidFocusSplit(_:)), + name: Ghostty.Notification.ghosttyFocusSplit, + object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidToggleSplitZoom(_:)), + name: Ghostty.Notification.didToggleSplitZoom, + object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidResizeSplit(_:)), + name: Ghostty.Notification.didResizeSplit, + object: nil) + // Listen for local events that we need to know of outside of // single surface handlers. self.eventMonitor = NSEvent.addLocalMonitorForEvents( @@ -134,7 +187,7 @@ class BaseTerminalController: NSWindowController, deinit { NotificationCenter.default.removeObserver(self) - + undoManager?.removeAllActions(withTarget: self) if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } @@ -143,11 +196,9 @@ class BaseTerminalController: NSWindowController, /// Called when the surfaceTree variable changed. /// /// Subclasses should call super first. - func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { - // If our surface tree becomes nil then ensure all surfaces - // in the old tree have closed. - if (to == nil) { - from?.close() + func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { + // If our surface tree becomes empty then we have no focused surface. + if (to.isEmpty) { focusedSurface = nil } } @@ -155,16 +206,14 @@ class BaseTerminalController: NSWindowController, /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about /// what surface is focused. This must be called whenever a surface OR window changes focus. func syncFocusToSurfaceTree() { - guard let tree = self.surfaceTree else { return } - - for leaf in tree { + for surfaceView in surfaceTree { // Our focus state requires that this window is key and our currently - // focused surface is the surface in this leaf. + // focused surface is the surface in this view. let focused: Bool = (window?.isKeyWindow ?? false) && !commandPaletteIsShowing && focusedSurface != nil && - leaf.surface == focusedSurface! - leaf.surface.focusDidChange(focused) + surfaceView == focusedSurface! + surfaceView.focusDidChange(focused) } } @@ -177,6 +226,124 @@ class BaseTerminalController: NSWindowController, savedFrame = .init(window: window.frame, screen: screen.visibleFrame) } + func confirmClose( + messageText: String, + informativeText: String, + completion: @escaping () -> Void + ) { + // If we already have an alert, we need to wait for that one. + guard alert == nil else { return } + + // If there is no window to attach the modal then we assume success + // since we'll never be able to show the modal. + guard let window else { + completion() + return + } + + // If we need confirmation by any, show one confirmation for all windows + // in the tab group. + let alert = NSAlert() + alert.messageText = messageText + alert.informativeText = informativeText + alert.addButton(withTitle: "Close") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: window) { response in + self.alert = nil + if response == .alertFirstButtonReturn { + completion() + } + } + + // Store our alert so we only ever show one. + self.alert = alert + } + + // MARK: Split Tree Management + + /// Find the next surface to focus when a node is being closed. + /// Goes to previous split unless we're the leftmost leaf, then goes to next. + private func findNextFocusTargetAfterClosing(node: SplitTree.Node) -> Ghostty.SurfaceView? { + guard let root = surfaceTree.root else { return nil } + + // If we're the leftmost, then we move to the next surface after closing. + // Otherwise, we move to the previous. + if root.leftmostLeaf() == node.leftmostLeaf() { + return surfaceTree.focusTarget(for: .next, from: node) + } else { + return surfaceTree.focusTarget(for: .previous, from: node) + } + } + + /// Remove a node from the surface tree and move focus appropriately. + /// + /// This also updates the undo manager to support restoring this node. + /// + /// This does no confirmation and assumes confirmation is already done. + private func removeSurfaceNode(_ node: SplitTree.Node) { + // Move focus if the closed surface was focused and we have a next target + let nextFocus: Ghostty.SurfaceView? = if node.contains( + where: { $0 == focusedSurface } + ) { + findNextFocusTargetAfterClosing(node: node) + } else { + nil + } + + replaceSurfaceTree( + surfaceTree.remove(node), + moveFocusTo: nextFocus, + moveFocusFrom: focusedSurface, + undoAction: "Close Terminal" + ) + } + + private func replaceSurfaceTree( + _ newTree: SplitTree, + moveFocusTo newView: Ghostty.SurfaceView? = nil, + moveFocusFrom oldView: Ghostty.SurfaceView? = nil, + undoAction: String? = nil + ) { + // Setup our new split tree + let oldTree = surfaceTree + surfaceTree = newTree + if let newView { + DispatchQueue.main.async { + Ghostty.moveFocus(to: newView, from: oldView) + } + } + + // Setup our undo + if let undoManager { + if let undoAction { + undoManager.setActionName(undoAction) + } + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration + ) { target in + target.surfaceTree = oldTree + if let oldView { + DispatchQueue.main.async { + Ghostty.moveFocus(to: oldView, from: target.focusedSurface) + } + } + + undoManager.registerUndo( + withTarget: target, + expiresAfter: target.undoExpiration + ) { target in + target.replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: target.focusedSurface, + undoAction: undoAction) + } + } + } + } + // MARK: Notifications @objc private func didChangeScreenParametersNotification(_ notification: Notification) { @@ -239,17 +406,212 @@ class BaseTerminalController: NSWindowController, @objc private func ghosttyCommandPaletteDidToggle(_ notification: Notification) { guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: surfaceView) ?? false else { return } + guard surfaceTree.contains(surfaceView) else { return } toggleCommandPalette(nil) } @objc private func ghosttyMaximizeDidToggle(_ notification: Notification) { guard let window else { return } guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: surfaceView) ?? false else { return } + guard surfaceTree.contains(surfaceView) else { return } window.zoom(nil) } + @objc private func ghosttyDidCloseSurface(_ notification: Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard let node = surfaceTree.root?.node(view: target) else { return } + closeSurfaceNode( + node, + withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false) + } + + /// Close a surface node (which may contain splits), requesting confirmation if necessary. + /// + /// This will also insert the proper undo stack information in. + func closeSurfaceNode( + _ node: SplitTree.Node, + withConfirmation: Bool = true + ) { + // This node must be part of our tree + guard surfaceTree.contains(node) else { return } + + // If the child process is not alive, then we exit immediately + guard withConfirmation else { + removeSurfaceNode(node) + return + } + + // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog + // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that + // confirmationDialog allows the user to Cmd-W close the alert, but when doing + // so SwiftUI does not update any of the bindings to note that window is no longer + // being shown, and provides no callback to detect this. + confirmClose( + messageText: "Close Terminal?", + informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." + ) { [weak self] in + if let self { + self.removeSurfaceNode(node) + } + } + } + + @objc private func ghosttyDidNewSplit(_ notification: Notification) { + // The target must be within our tree + guard let oldView = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree.root?.node(view: oldView) != nil else { return } + + // Notification must contain our base config + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? Ghostty.SurfaceConfiguration + + // Determine our desired direction + guard let directionAny = notification.userInfo?["direction"] else { return } + guard let direction = directionAny as? ghostty_action_split_direction_e else { return } + let splitDirection: SplitTree.NewDirection + switch (direction) { + case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right + case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left + case GHOSTTY_SPLIT_DIRECTION_DOWN: splitDirection = .down + case GHOSTTY_SPLIT_DIRECTION_UP: splitDirection = .up + default: return + } + + // Create a new surface view + guard let ghostty_app = ghostty.app else { return } + let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config) + + // Do the split + let newTree: SplitTree + do { + newTree = try surfaceTree.insert( + view: newView, + at: oldView, + direction: splitDirection) + } catch { + // If splitting fails for any reason (it should not), then we just log + // and return. The new view we created will be deinitialized and its + // no big deal. + Ghostty.logger.warning("failed to insert split: \(error)") + return + } + + replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: oldView, + undoAction: "New Split") + } + + @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + + // Check if target surface is in current controller's tree + guard surfaceTree.contains(target) else { return } + + // Equalize the splits + surfaceTree = surfaceTree.equalize() + } + + @objc private func ghosttyDidFocusSplit(_ notification: Notification) { + // The target must be within our tree + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree.root?.node(view: target) != nil else { return } + + // Get the direction from the notification + guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return } + guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return } + + // Convert Ghostty.SplitFocusDirection to our SplitTree.FocusDirection + let focusDirection: SplitTree.FocusDirection + switch direction { + case .previous: focusDirection = .previous + case .next: focusDirection = .next + case .up: focusDirection = .spatial(.up) + case .down: focusDirection = .spatial(.down) + case .left: focusDirection = .spatial(.left) + case .right: focusDirection = .spatial(.right) + } + + // Find the node for the target surface + guard let targetNode = surfaceTree.root?.node(view: target) else { return } + + // Find the next surface to focus + guard let nextSurface = surfaceTree.focusTarget(for: focusDirection, from: targetNode) else { + return + } + + // Remove the zoomed state for this surface tree. + if surfaceTree.zoomed != nil { + surfaceTree = .init(root: surfaceTree.root, zoomed: nil) + } + + // Move focus to the next surface + DispatchQueue.main.async { + Ghostty.moveFocus(to: nextSurface, from: target) + } + } + + @objc private func ghosttyDidToggleSplitZoom(_ notification: Notification) { + // The target must be within our tree + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard let targetNode = surfaceTree.root?.node(view: target) else { return } + + // Toggle the zoomed state + if surfaceTree.zoomed == targetNode { + // Already zoomed, unzoom it + surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil) + } else { + // We require that the split tree have splits + guard surfaceTree.isSplit else { return } + + // Not zoomed or different node zoomed, zoom this node + surfaceTree = SplitTree(root: surfaceTree.root, zoomed: targetNode) + } + + // Move focus to our window. Importantly this ensures that if we click the + // reset zoom button in a tab bar of an unfocused tab that we become focused. + window?.makeKeyAndOrderFront(nil) + + // Ensure focus stays on the target surface. We lose focus when we do + // this so we need to grab it again. + DispatchQueue.main.async { + Ghostty.moveFocus(to: target) + } + } + + @objc private func ghosttyDidResizeSplit(_ notification: Notification) { + // The target must be within our tree + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard let targetNode = surfaceTree.root?.node(view: target) else { return } + + // Extract direction and amount from notification + guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return } + guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return } + + guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return } + guard let amount = amountAny as? UInt16 else { return } + + // Convert Ghostty.SplitResizeDirection to SplitTree.Spatial.Direction + let spatialDirection: SplitTree.Spatial.Direction + switch direction { + case .up: spatialDirection = .up + case .down: spatialDirection = .down + case .left: spatialDirection = .left + case .right: spatialDirection = .right + } + + // Use viewBounds for the spatial calculation bounds + let bounds = CGRect(origin: .zero, size: surfaceTree.viewBounds()) + + // Perform the resize using the new SplitTree resize method + do { + surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds) + } catch { + Ghostty.logger.warning("failed to resize split: \(error)") + } + } + // MARK: Local Events private func localEventHandler(_ event: NSEvent) -> NSEvent? { @@ -263,20 +625,17 @@ class BaseTerminalController: NSWindowController, } private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? { - // Go through all our surfaces and notify it that the flags changed. - if let surfaceTree { - var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0.surface } + var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0 } - // If we're the main window receiving key input, then we want to avoid - // calling this on our focused surface because that'll trigger a double - // flagsChanged call. - if NSApp.mainWindow == window { - surfaces = surfaces.filter { $0 != focusedSurface } - } - - for surface in surfaces { - surface.flagsChanged(with: event) - } + // If we're the main window receiving key input, then we want to avoid + // calling this on our focused surface because that'll trigger a double + // flagsChanged call. + if NSApp.mainWindow == window { + surfaces = surfaces.filter { $0 != focusedSurface } + } + + for surface in surfaces { + surface.flagsChanged(with: event) } return event @@ -284,11 +643,6 @@ class BaseTerminalController: NSWindowController, // MARK: TerminalViewDelegate - // Note: this is different from surfaceDidTreeChange(from:,to:) because this is called - // when the currently set value changed in place and the from:to: variant is called - // when the variable was set. - func surfaceTreeDidChange() {} - func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { let lastFocusedSurface = focusedSurface focusedSurface = to @@ -301,7 +655,7 @@ class BaseTerminalController: NSWindowController, // want to care if the surface is in the tree so we don't listen to titles of // closed surfaces. if let titleSurface = focusedSurface ?? lastFocusedSurface, - surfaceTree?.contains(view: titleSurface) ?? false { + surfaceTree.contains(titleSurface) { // If we have a surface, we want to listen for title changes. titleSurface.$title .sink { [weak self] in self?.titleDidChange(to: $0) } @@ -336,7 +690,15 @@ class BaseTerminalController: NSWindowController, self.window?.contentResizeIncrements = to } - func zoomStateDidChange(to: Bool) {} + func splitDidResize(node: SplitTree.Node, to newRatio: Double) { + let resizedNode = node.resize(to: newRatio) + do { + surfaceTree = try surfaceTree.replace(node: node, with: resizedNode) + } catch { + Ghostty.logger.warning("failed to replace node during split resize: \(error)") + return + } + } func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) { guard let surface = surfaceView.surface else { return } @@ -396,6 +758,8 @@ class BaseTerminalController: NSWindowController, } } + func fullscreenDidChange() {} + // MARK: Clipboard Confirmation @objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) { @@ -462,6 +826,11 @@ class BaseTerminalController: NSWindowController, // MARK: NSWindowController override func windowDidLoad() { + super.windowDidLoad() + + // Setup our undo manager. + + // Everything beyond here is setting up the window guard let window else { return } // If there is a hardcoded title in the configuration, we set that @@ -491,35 +860,21 @@ class BaseTerminalController: NSWindowController, guard let window = self.window else { return true } // If we have no surfaces, close. - guard let node = self.surfaceTree else { return true } + if surfaceTree.isEmpty { return true } // If we already have an alert, continue with it guard alert == nil else { return false } // If our surfaces don't require confirmation, close. - if (!node.needsConfirmQuit()) { return true } + if !surfaceTree.contains(where: { $0.needsConfirmQuit }) { return true } // We require confirmation, so show an alert as long as we aren't already. - let alert = NSAlert() - alert.messageText = "Close Terminal?" - alert.informativeText = "The terminal still has a running process. If you close the " + - "terminal the process will be killed." - alert.addButton(withTitle: "Close the Terminal") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window, completionHandler: { response in - self.alert = nil - switch (response) { - case .alertFirstButtonReturn: - alert.window.orderOut(nil) - window.close() - - default: - break - } - }) - - self.alert = alert + confirmClose( + messageText: "Close Terminal?", + informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." + ) { + window.close() + } return false } @@ -531,6 +886,9 @@ class BaseTerminalController: NSWindowController, // the view and the window so we had to nil this out to break it but I think this // may now be resolved. We should verify that no memory leaks and we can remove this. window.contentView = nil + + // Make sure we clean up all our undos + window.undoManager?.removeAllActions(withTarget: self) } func windowDidBecomeKey(_ notification: Notification) { @@ -546,10 +904,9 @@ class BaseTerminalController: NSWindowController, } func windowDidChangeOcclusionState(_ notification: Notification) { - guard let surfaceTree = self.surfaceTree else { return } let visible = self.window?.occlusionState.contains(.visible) ?? false - for leaf in surfaceTree { - if let surface = leaf.surface.surface { + for view in surfaceTree { + if let surface = view.surface { ghostty_surface_set_occlusion(surface, visible) } } @@ -563,6 +920,11 @@ class BaseTerminalController: NSWindowController, windowFrameDidChange() } + func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? { + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } + return appDelegate.undoManager + } + // MARK: First Responder @IBAction func close(_ sender: Any) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index cf2dd3348..49b3fea34 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -6,7 +6,26 @@ import GhosttyKit /// A classic, tabbed terminal experience. class TerminalController: BaseTerminalController { - override var windowNibName: NSNib.Name? { "Terminal" } + override var windowNibName: NSNib.Name? { + let defaultValue = "Terminal" + + guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue } + let config = appDelegate.ghostty.config + let nib = switch config.macosTitlebarStyle { + case "native": "Terminal" + case "hidden": "TerminalHiddenTitlebar" + case "transparent": "TerminalTransparentTitlebar" + case "tabs": + if #available(macOS 26.0, *), hasLiquidGlass() { + "TerminalTabsTitlebarTahoe" + } else { + "TerminalTabsTitlebarVentura" + } + default: defaultValue + } + + return nib + } /// This is set to true when we care about frame changes. This is a small optimization since /// this controller registers a listener for ALL frame change notifications and this lets us bail @@ -32,7 +51,8 @@ class TerminalController: BaseTerminalController { init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: Ghostty.SplitNode? = nil + withSurfaceTree tree: SplitTree? = nil, + parent: NSWindow? = nil ) { // The window we manage is not restorable if we've specified a command // to execute. We do this because the restored window is meaningless at the @@ -85,12 +105,6 @@ class TerminalController: BaseTerminalController { selector: #selector(onFrameDidChange), name: NSView.frameDidChangeNotification, object: nil) - center.addObserver( - self, - selector: #selector(onEqualizeSplits), - name: Ghostty.Notification.didEqualizeSplits, - object: nil - ) center.addObserver( self, selector: #selector(onCloseWindow), @@ -111,29 +125,244 @@ class TerminalController: BaseTerminalController { // MARK: Base Controller Overrides - override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { super.surfaceTreeDidChange(from: from, to: to) + + // Whenever our surface tree changes in any way (new split, close split, etc.) + // we want to invalidate our state. + invalidateRestorableState() + + // Update our zoom state + if let window = window as? TerminalWindow { + window.surfaceIsZoomed = to.zoomed != nil + } // If our surface tree is now nil then we close our window. - if (to == nil) { + if (to.isEmpty) { self.window?.close() } } - func fullscreenDidChange() { + override func fullscreenDidChange() { + super.fullscreenDidChange() + // When our fullscreen state changes, we resync our appearance because some // properties change when fullscreen or not. guard let focusedSurface else { return } - if (!(fullscreenStyle?.isFullscreen ?? false) && - ghostty.config.macosTitlebarStyle == "hidden") - { - applyHiddenTitlebarStyle() - } syncAppearance(focusedSurface.derivedConfig) } + // MARK: Terminal Creation + + /// Returns all the available terminal controllers present in the app currently. + static var all: [TerminalController] { + return NSApplication.shared.windows.compactMap { + $0.windowController as? TerminalController + } + } + + // Keep track of the last point that our window was launched at so that new + // windows "cascade" over each other and don't just launch directly on top + // of each other. + private static var lastCascadePoint = NSPoint(x: 0, y: 0) + + // The preferred parent terminal controller. + private static var preferredParent: TerminalController? { + all.first { + $0.window?.isMainWindow ?? false + } ?? all.last + } + + /// The "new window" action. + static func newWindow( + _ ghostty: Ghostty.App, + withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil, + withParent explicitParent: NSWindow? = nil + ) -> TerminalController { + let c = TerminalController.init(ghostty, withBaseConfig: baseConfig) + + // Get our parent. Our parent is the one explicitly given to us, + // otherwise the focused terminal, otherwise an arbitrary one. + let parent: NSWindow? = explicitParent ?? preferredParent?.window + + if let parent { + if parent.styleMask.contains(.fullScreen) { + parent.toggleFullScreen(nil) + } else if ghostty.config.windowFullscreen { + switch (ghostty.config.windowFullscreenMode) { + case .native: + // Native has to be done immediately so that our stylemask contains + // fullscreen for the logic later in this method. + c.toggleFullscreen(mode: .native) + + case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch: + // If we're non-native then we have to do it on a later loop + // so that the content view is setup. + DispatchQueue.main.async { + c.toggleFullscreen(mode: ghostty.config.windowFullscreenMode) + } + } + } + } + + // We're dispatching this async because otherwise the lastCascadePoint doesn't + // take effect. Our best theory is there is some next-event-loop-tick logic + // that Cocoa is doing that we need to be after. + DispatchQueue.main.async { + // Only cascade if we aren't fullscreen. + if let window = c.window { + if (!window.styleMask.contains(.fullScreen)) { + Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) + } + } + + c.showWindow(self) + } + + // Setup our undo + if let undoManager = c.undoManager { + undoManager.setActionName("New Window") + undoManager.registerUndo( + withTarget: c, + expiresAfter: c.undoExpiration + ) { target in + // Close the window when undoing + undoManager.disableUndoRegistration { + target.closeWindow(nil) + } + + // Register redo action + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: target.undoExpiration + ) { ghostty in + _ = TerminalController.newWindow( + ghostty, + withBaseConfig: baseConfig, + withParent: explicitParent) + } + } + } + + return c + } + + static func newTab( + _ ghostty: Ghostty.App, + from parent: NSWindow? = nil, + withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil + ) -> TerminalController? { + // Making sure that we're dealing with a TerminalController. If not, + // then we just create a new window. + guard let parent, + let parentController = parent.windowController as? TerminalController else { + return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent) + } + + // If our parent is in non-native fullscreen, then new tabs do not work. + // See: https://github.com/mitchellh/ghostty/issues/392 + if let fullscreenStyle = parentController.fullscreenStyle, + fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs { + let alert = NSAlert() + alert.messageText = "Cannot Create New Tab" + alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again." + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.beginSheetModal(for: parent) + return nil + } + + // Create a new window and add it to the parent + let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig) + guard let window = controller.window else { return controller } + + // If the parent is miniaturized, then macOS exhibits really strange behaviors + // so we have to bring it back out. + if (parent.isMiniaturized) { parent.deminiaturize(self) } + + // If our parent tab group already has this window, macOS added it and + // we need to remove it so we can set the correct order in the next line. + // If we don't do this, macOS gets really confused and the tabbedWindows + // state becomes incorrect. + // + // At the time of writing this code, the only known case this happens + // is when the "+" button is clicked in the tab bar. + if let tg = parent.tabGroup, + tg.windows.firstIndex(of: window) != nil { + tg.removeWindow(window) + } + + // If we don't allow tabs then we create a new window instead. + if (window.tabbingMode != .disallowed) { + // Add the window to the tab group and show it. + switch ghostty.config.windowNewTabPosition { + case "end": + // If we already have a tab group and we want the new tab to open at the end, + // then we use the last window in the tab group as the parent. + if let last = parent.tabGroup?.windows.last { + last.addTabbedWindow(window, ordered: .above) + } else { + fallthrough + } + + case "current": fallthrough + default: + parent.addTabbedWindow(window, ordered: .above) + } + } + + // We're dispatching this async because otherwise the lastCascadePoint doesn't + // take effect. Our best theory is there is some next-event-loop-tick logic + // that Cocoa is doing that we need to be after. + DispatchQueue.main.async { + // Only cascade if we aren't fullscreen and are alone in the tab group. + if !window.styleMask.contains(.fullScreen) && + window.tabGroup?.windows.count ?? 1 == 1 { + Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) + } + + controller.showWindow(self) + window.makeKeyAndOrderFront(self) + } + + // It takes an event loop cycle until the macOS tabGroup state becomes + // consistent which causes our tab labeling to be off when the "+" button + // is used in the tab bar. This fixes that. If we can find a more robust + // solution we should do that. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + controller.relabelTabs() + } + + // Setup our undo + if let undoManager = parentController.undoManager { + undoManager.setActionName("New Tab") + undoManager.registerUndo( + withTarget: controller, + expiresAfter: controller.undoExpiration + ) { target in + // Close the tab when undoing + undoManager.disableUndoRegistration { + target.closeTab(nil) + } + + // Register redo action + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: target.undoExpiration + ) { ghostty in + _ = TerminalController.newTab( + ghostty, + from: parent, + withBaseConfig: baseConfig) + } + } + } + + return controller + } + //MARK: - Methods @objc private func ghosttyConfigDidChange(_ notification: Notification) { @@ -149,8 +378,8 @@ class TerminalController: BaseTerminalController { // If we have no surfaces in our window (is that possible?) then we update // our window appearance based on the root config. If we have surfaces, we - // don't call this because the TODO - if surfaceTree == nil { + // don't call this because focused surface changes will trigger appearance updates. + if surfaceTree.isEmpty { syncAppearance(.init(config)) } @@ -160,7 +389,7 @@ class TerminalController: BaseTerminalController { // This is a surface-level config update. If we have the surface, we // update our appearance based on it. guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: surfaceView) ?? false else { return } + guard surfaceTree.contains(surfaceView) else { return } // We can't use surfaceView.derivedConfig because it may not be updated // yet since it also responds to notifications. @@ -172,28 +401,25 @@ class TerminalController: BaseTerminalController { /// changes, when a window is closed, and when tabs are reordered /// with the mouse. func relabelTabs() { - // Reset this to false. It'll be set back to true later. - tabListenForFrame = false - - guard let windows = self.window?.tabbedWindows as? [TerminalWindow] else { return } - // We only listen for frame changes if we have more than 1 window, // otherwise the accessory view doesn't matter. - tabListenForFrame = windows.count > 1 + tabListenForFrame = window?.tabbedWindows?.count ?? 0 > 1 - for (tab, window) in zip(1..., windows) { - // We need to clear any windows beyond this because they have had - // a keyEquivalent set previously. - guard tab <= 9 else { - window.keyEquivalent = "" - continue - } + if let windows = window?.tabbedWindows as? [TerminalWindow] { + for (tab, window) in zip(1..., windows) { + // We need to clear any windows beyond this because they have had + // a keyEquivalent set previously. + guard tab <= 9 else { + window.keyEquivalent = "" + continue + } - let action = "goto_tab:\(tab)" - if let equiv = ghostty.config.keyboardShortcut(for: action) { - window.keyEquivalent = "\(equiv)" - } else { - window.keyEquivalent = "" + let action = "goto_tab:\(tab)" + if let equiv = ghostty.config.keyboardShortcut(for: action) { + window.keyEquivalent = "\(equiv)" + } else { + window.keyEquivalent = "" + } } } } @@ -226,18 +452,11 @@ class TerminalController: BaseTerminalController { } private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { - guard let window = self.window as? TerminalWindow else { return } + // Let our window handle its own appearance + guard let window = window as? TerminalWindow else { return } - // Set our explicit appearance if we need to based on the configuration. - window.appearance = surfaceConfig.windowAppearance - - // Update our window light/darkness based on our updated background color - window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor - - // If our window is not visible, then we do nothing. Some things such as blurring - // have no effect if the window is not visible. Ultimately, we'll have this called - // at some point when a surface becomes focused. - guard window.isVisible else { return } + // Sync our zoom state for splits + window.surfaceIsZoomed = surfaceTree.zoomed != nil // Set the font for the window and tab titles. if let titleFontName = surfaceConfig.windowTitleFontFamily { @@ -246,85 +465,8 @@ class TerminalController: BaseTerminalController { window.titlebarFont = nil } - // If we have window transparency then set it transparent. Otherwise set it opaque. - - // Window transparency only takes effect if our window is not native fullscreen. - // In native fullscreen we disable transparency/opacity because the background - // becomes gray and widgets show through. - if (!window.styleMask.contains(.fullScreen) && - surfaceConfig.backgroundOpacity < 1 - ) { - window.isOpaque = false - - // This is weird, but we don't use ".clear" because this creates a look that - // matches Terminal.app much more closer. This lets users transition from - // Terminal.app more easily. - window.backgroundColor = .white.withAlphaComponent(0.001) - - ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) - } else { - window.isOpaque = true - window.backgroundColor = .windowBackgroundColor - } - - window.hasShadow = surfaceConfig.macosWindowShadow - - guard window.hasStyledTabs else { return } - - // Our background color depends on if our focused surface borders the top or not. - // If it does, we match the focused surface. If it doesn't, we use the app - // configuration. - let backgroundColor: OSColor - if let surfaceTree { - if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) { - // Similar to above, an alpha component of "0" causes compositor issues, so - // we use 0.001. See: https://github.com/ghostty-org/ghostty/pull/4308 - backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.001) - } else { - // We don't have a focused surface or our surface doesn't border the - // top. We choose to match the color of the top-left most surface. - backgroundColor = OSColor(surfaceTree.topLeft().backgroundColor ?? derivedConfig.backgroundColor) - } - } else { - backgroundColor = OSColor(self.derivedConfig.backgroundColor) - } - window.titlebarColor = backgroundColor.withAlphaComponent(surfaceConfig.backgroundOpacity) - - if (window.isOpaque) { - // Bg color is only synced if we have no transparency. This is because - // the transparency is handled at the surface level (window.backgroundColor - // ignores alpha components) - window.backgroundColor = backgroundColor - - // If there is transparency, calling this will make the titlebar opaque - // so we only call this if we are opaque. - window.updateTabBar() - } - } - - private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { - guard let window else { return } - - // If we don't have an X/Y then we try to use the previously saved window pos. - guard let x, let y else { - if (!LastWindowPosition.shared.restore(window)) { - window.center() - } - - return - } - - // Prefer the screen our window is being placed on otherwise our primary screen. - guard let screen = window.screen ?? NSScreen.screens.first else { - window.center() - return - } - - // Orient based on the top left of the primary monitor - let frame = screen.visibleFrame - window.setFrameOrigin(.init( - x: frame.minX + CGFloat(x), - y: frame.maxY - (CGFloat(y) + window.frame.height))) + // Call this last in case it uses any of the properties above. + window.syncAppearance(surfaceConfig) } /// Returns the default size of the window. This is contextual based on the focused surface because @@ -376,6 +518,291 @@ class TerminalController: BaseTerminalController { return frame } + /// This is called anytime a node in the surface tree is being removed. + override func closeSurfaceNode( + _ node: SplitTree.Node, + withConfirmation: Bool = true + ) { + // If this isn't the root then we're dealing with a split closure. + if surfaceTree.root != node { + super.closeSurfaceNode(node, withConfirmation: withConfirmation) + return + } + + // More than 1 window means we have tabs and we're closing a tab + if window?.tabGroup?.windows.count ?? 0 > 1 { + closeTab(nil) + return + } + + // 1 window, closing the window + closeWindow(nil) + } + + private func closeTabImmediately() { + guard let window = window else { return } + guard let tabGroup = window.tabGroup, + tabGroup.windows.count > 1 else { + closeWindowImmediately() + return + } + + // Undo + if let undoManager, let undoState { + // Register undo action to restore the tab + undoManager.setActionName("Close Tab") + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: undoExpiration + ) { ghostty in + let newController = TerminalController(ghostty, with: undoState) + + // Register redo action + undoManager.registerUndo( + withTarget: newController, + expiresAfter: newController.undoExpiration + ) { target in + target.closeTabImmediately() + } + } + } + + window.close() + } + + /// Closes the current window (including any other tabs) immediately and without + /// confirmation. This will setup proper undo state so the action can be undone. + private func closeWindowImmediately() { + guard let window = window else { return } + + registerUndoForCloseWindow() + + if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 { + tabGroup.windows.forEach { window in + // Clear out the surfacetree to ensure there is no undo state. + // This prevents unnecessary undos registered since AppKit may + // process them on later ticks so we can't just disable undo registration. + if let controller = window.windowController as? TerminalController { + controller.surfaceTree = .init() + } + + window.close() + } + } else { + window.close() + } + } + + /// Registers undo for closing window(s), handling both single windows and tab groups. + private func registerUndoForCloseWindow() { + guard let undoManager, undoManager.isUndoRegistrationEnabled else { return } + guard let window else { return } + + // If we don't have a tab group or we don't have multiple tabs, then + // do a normal single window close. + guard let tabGroup = window.tabGroup, + tabGroup.windows.count > 1 else { + // No tabs, just save this window's state + if let undoState { + // Register undo action to restore the window + undoManager.setActionName("Close Window") + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: undoExpiration) { ghostty in + // Restore the undo state + let newController = TerminalController(ghostty, with: undoState) + + // Register redo action + undoManager.registerUndo( + withTarget: newController, + expiresAfter: newController.undoExpiration) { target in + target.closeWindowImmediately() + } + } + } + + return + } + + // Multiple windows in tab group - collect all undo states in sorted order + // by tab ordering. Also track which window was key. + let undoStates = tabGroup.windows + .compactMap { tabWindow -> UndoState? in + guard let controller = tabWindow.windowController as? TerminalController, + var undoState = controller.undoState else { return nil } + // Clear the tab group reference since it is unneeded. It should be + // garbage collected but we want to be extra sure we don't try to + // restore into it because we're going to recreate it. + undoState.tabGroup = nil + return undoState + } + .sorted { (lhs, rhs) in + switch (lhs.tabIndex, rhs.tabIndex) { + case let (l?, r?): return l < r + case (_?, nil): return true + case (nil, _?): return false + case (nil, nil): return true + } + } + + // Find the index of the key window in our sorted states. This is a bit verbose + // but we only need this for this style of undo so we don't want to add it to + // UndoState. + let keyWindowIndex: Int? + if let keyWindow = tabGroup.windows.first(where: { $0.isKeyWindow }), + let keyController = keyWindow.windowController as? TerminalController, + let keyUndoState = keyController.undoState { + keyWindowIndex = undoStates.firstIndex { + $0.tabIndex == keyUndoState.tabIndex } + } else { + keyWindowIndex = nil + } + + // Register undo action to restore all windows + guard !undoStates.isEmpty else { return } + + undoManager.setActionName("Close Window") + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: undoExpiration + ) { ghostty in + // Restore all windows in the tab group + let controllers = undoStates.map { undoState in + TerminalController(ghostty, with: undoState) + } + + // The first controller becomes the parent window for all tabs. + // If we don't have a first controller (shouldn't be possible?) + // then we can't restore tabs. + guard let firstController = controllers.first else { return } + + // Add all subsequent controllers as tabs to the first window + for controller in controllers.dropFirst() { + controller.showWindow(nil) + if let firstWindow = firstController.window, + let newWindow = controller.window { + firstWindow.addTabbedWindow(newWindow, ordered: .above) + } + } + + // Make the appropriate window key. If we had a key window, restore it. + // Otherwise, make the last window key. + if let keyWindowIndex, keyWindowIndex < controllers.count { + controllers[keyWindowIndex].window?.makeKeyAndOrderFront(nil) + } else { + controllers.last?.window?.makeKeyAndOrderFront(nil) + } + + // Register redo action on the first controller + undoManager.registerUndo( + withTarget: firstController, + expiresAfter: firstController.undoExpiration + ) { target in + target.closeWindowImmediately() + } + } + } + + /// Close all windows, asking for confirmation if necessary. + static func closeAllWindows() { + let needsConfirm: Bool = all.contains { + $0.surfaceTree.contains { $0.needsConfirmQuit } + } + + if (!needsConfirm) { + closeAllWindowsImmediately() + return + } + + // If we don't have a main window, we just close all windows because + // we have no window to show the modal on top of. I'm sure there's a way + // to do an app-level alert but I don't know how and this case should never + // really happen. + guard let alertWindow = preferredParent?.window else { + closeAllWindowsImmediately() + return + } + + // If we need confirmation by any, show one confirmation for all windows + let alert = NSAlert() + alert.messageText = "Close All Windows?" + alert.informativeText = "All terminal sessions will be terminated." + alert.addButton(withTitle: "Close All Windows") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: alertWindow, completionHandler: { response in + if (response == .alertFirstButtonReturn) { + closeAllWindowsImmediately() + } + }) + } + + static private func closeAllWindowsImmediately() { + let undoManager = (NSApp.delegate as? AppDelegate)?.undoManager + undoManager?.beginUndoGrouping() + all.forEach { $0.closeWindowImmediately() } + undoManager?.setActionName("Close All Windows") + undoManager?.endUndoGrouping() + } + + // MARK: Undo/Redo + + /// The state that we require to recreate a TerminalController from an undo. + struct UndoState { + let frame: NSRect + let surfaceTree: SplitTree + let focusedSurface: UUID? + let tabIndex: Int? + weak var tabGroup: NSWindowTabGroup? + } + + convenience init(_ ghostty: Ghostty.App, + with undoState: UndoState + ) { + self.init(ghostty, withSurfaceTree: undoState.surfaceTree) + + // Show the window and restore its frame + showWindow(nil) + if let window { + window.setFrame(undoState.frame, display: true) + + // If we have a tab group and index, restore the tab to its original position + if let tabGroup = undoState.tabGroup, + let tabIndex = undoState.tabIndex { + if tabIndex < tabGroup.windows.count { + // Find the window that is currently at that index + let currentWindow = tabGroup.windows[tabIndex] + currentWindow.addTabbedWindow(window, ordered: .below) + } else { + tabGroup.windows.last?.addTabbedWindow(window, ordered: .above) + } + + // Make it the key window + window.makeKeyAndOrderFront(nil) + } + + // Restore focus to the previously focused surface + if let focusedUUID = undoState.focusedSurface, + let focusTarget = surfaceTree.first(where: { $0.uuid == focusedUUID }) { + DispatchQueue.main.async { + Ghostty.moveFocus(to: focusTarget, from: nil) + } + } + } + } + + /// The current undo state for this controller + var undoState: UndoState? { + guard let window else { return nil } + guard !surfaceTree.isEmpty else { return nil } + return .init( + frame: window.frame, + surfaceTree: surfaceTree, + focusedSurface: focusedSurface?.uuid, + tabIndex: window.tabGroup?.windows.firstIndex(of: window), + tabGroup: window.tabGroup) + } + //MARK: - NSWindowController override func windowWillLoad() { @@ -383,46 +810,9 @@ class TerminalController: BaseTerminalController { shouldCascadeWindows = false } - fileprivate func applyHiddenTitlebarStyle() { - guard let window else { return } - - window.styleMask = [ - // We need `titled` in the mask to get the normal window frame - .titled, - - // Full size content view so we can extend - // content in to the hidden titlebar's area - .fullSizeContentView, - - .resizable, - .closable, - .miniaturizable, - ] - - // Hide the title - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - - // Hide the traffic lights (window control buttons) - window.standardWindowButton(.closeButton)?.isHidden = true - window.standardWindowButton(.miniaturizeButton)?.isHidden = true - window.standardWindowButton(.zoomButton)?.isHidden = true - - // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. - window.tabbingMode = .disallowed - - // Nuke it from orbit -- hide the titlebar container entirely, just in case. There are - // some operations that appear to bring back the titlebar visibility so this ensures - // it is gone forever. - if let themeFrame = window.contentView?.superview, - let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { - titleBarContainer.isHidden = true - } - } - override func windowDidLoad() { super.windowDidLoad() - guard let window = window as? TerminalWindow else { return } + guard let window else { return } // Store our initial frame so we can know our default later. initialFrame = window.frame @@ -440,55 +830,18 @@ class TerminalController: BaseTerminalController { window.identifier = .init(String(describing: TerminalWindowRestoration.self)) } - // If window decorations are disabled, remove our title - if (!config.windowDecorations) { window.styleMask.remove(.titled) } - // If we have only a single surface (no splits) and there is a default size then // we should resize to that default size. - if case let .leaf(leaf) = surfaceTree { + if case let .leaf(view) = surfaceTree.root { // If this is our first surface then our focused surface will be nil // so we force the focused surface to the leaf. - focusedSurface = leaf.surface + focusedSurface = view if let defaultSize { window.setFrame(defaultSize, display: true) } } - // Set our window positioning to coordinates if config value exists, otherwise - // fallback to original centering behavior - setInitialWindowPosition( - x: config.windowPositionX, - y: config.windowPositionY, - windowDecorations: config.windowDecorations) - - // Make sure our theme is set on the window so styling is correct. - if let windowTheme = config.windowTheme { - window.windowTheme = .init(rawValue: windowTheme) - } - - // Handle titlebar tabs config option. Something about what we do while setting up the - // titlebar tabs interferes with the window restore process unless window.tabbingMode - // is set to .preferred, so we set it, and switch back to automatic as soon as we can. - if (config.macosTitlebarStyle == "tabs") { - window.tabbingMode = .preferred - window.titlebarTabs = true - DispatchQueue.main.async { - window.tabbingMode = .automatic - } - } else if (config.macosTitlebarStyle == "transparent") { - window.transparentTabs = true - } - - if window.hasStyledTabs { - // Set the background color of the window - let backgroundColor = NSColor(config.backgroundColor) - window.backgroundColor = backgroundColor - - // This makes sure our titlebar renders correctly when there is a transparent background - window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity) - } - // Initialize our content view to the SwiftUI root window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, @@ -496,11 +849,6 @@ class TerminalController: BaseTerminalController { delegate: self )) - // If our titlebar style is "hidden" we adjust the style appropriately - if (config.macosTitlebarStyle == "hidden") { - applyHiddenTitlebarStyle() - } - // In various situations, macOS automatically tabs new windows. Ghostty handles // its own tabbing so we DONT want this behavior. This detects this scenario and undoes // it. @@ -536,9 +884,50 @@ class TerminalController: BaseTerminalController { //MARK: - NSWindowDelegate + override func windowShouldClose(_ sender: NSWindow) -> Bool { + // If we have tabs, then this should only close the tab. + if window?.tabGroup?.windows.count ?? 0 > 1 { + closeTab(sender) + } else { + closeWindow(sender) + } + + // We will always explicitly close the window using the above + return false + } + override func windowWillClose(_ notification: Notification) { super.windowWillClose(notification) self.relabelTabs() + + // If we remove a window, we reset the cascade point to the key window so that + // the next window cascade's from that one. + if let focusedWindow = NSApplication.shared.keyWindow { + // If we are NOT the focused window, then we are a tabbed window. If we + // are closing a tabbed window, we want to set the cascade point to be + // the next cascade point from this window. + if focusedWindow != window { + // The cascadeTopLeft call below should NOT move the window. Starting with + // macOS 15, we found that specifically when used with the new window snapping + // features of macOS 15, this WOULD move the frame. So we keep track of the + // old frame and restore it if necessary. Issue: + // https://github.com/ghostty-org/ghostty/issues/2565 + let oldFrame = focusedWindow.frame + + Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint) + + if focusedWindow.frame != oldFrame { + focusedWindow.setFrame(oldFrame, display: true) + } + + return + } + + // If we are the focused window, then we set the last cascade point to + // our own frame so that it shows up in the same spot. + let frame = focusedWindow.frame + Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) + } } override func windowDidBecomeKey(_ notification: Notification) { @@ -585,47 +974,24 @@ class TerminalController: BaseTerminalController { ghostty.newTab(surface: surface) } - private func confirmClose( - window: NSWindow, - messageText: String, - informativeText: String, - completion: @escaping () -> Void - ) { - // If we need confirmation by any, show one confirmation for all windows - // in the tab group. - let alert = NSAlert() - alert.messageText = messageText - alert.informativeText = informativeText - alert.addButton(withTitle: "Close") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window) { response in - if response == .alertFirstButtonReturn { - completion() - } - } - } - @IBAction func closeTab(_ sender: Any?) { guard let window = window else { return } - guard window.tabGroup != nil else { - // No tabs, no tab group, just perform a normal close. - window.performClose(sender) + guard window.tabGroup?.windows.count ?? 0 > 1 else { + closeWindow(sender) return } - if surfaceTree?.needsConfirmQuit() ?? false { - confirmClose( - window: window, - messageText: "Close Tab?", - informativeText: "The terminal still has a running process. If you close the tab the process will be killed." - ) { - window.close() - } + guard surfaceTree.contains(where: { $0.needsConfirmQuit }) else { + closeTabImmediately() return } - window.close() + confirmClose( + messageText: "Close Tab?", + informativeText: "The terminal still has a running process. If you close the tab the process will be killed." + ) { + self.closeTabImmediately() + } } @IBAction func returnToDefaultSize(_ sender: Any?) { @@ -637,13 +1003,13 @@ class TerminalController: BaseTerminalController { guard let window = window else { return } guard let tabGroup = window.tabGroup else { // No tabs, no tab group, just perform a normal close. - window.performClose(sender) + closeWindowImmediately() return } // If have one window then we just do a normal close if tabGroup.windows.count == 1 { - window.performClose(sender) + closeWindowImmediately() return } @@ -652,21 +1018,20 @@ class TerminalController: BaseTerminalController { guard let controller = tabWindow.windowController as? TerminalController else { return false } - return controller.surfaceTree?.needsConfirmQuit() ?? false + return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) } // If none need confirmation then we can just close all the windows. if !needsConfirm { - tabGroup.windows.forEach { $0.close() } + closeWindowImmediately() return } confirmClose( - window: window, messageText: "Close Window?", informativeText: "All terminal sessions in this window will be terminated." ) { - tabGroup.windows.forEach { $0.close() } + self.closeWindowImmediately() } } @@ -681,35 +1046,7 @@ class TerminalController: BaseTerminalController { } //MARK: - TerminalViewDelegate - - override func titleDidChange(to: String) { - super.titleDidChange(to: to) - - guard let window = window as? TerminalWindow else { return } - - // Custom toolbar-based title used when titlebar tabs are enabled. - if let toolbar = window.toolbar as? TerminalToolbar { - if (window.titlebarTabs || derivedConfig.macosTitlebarStyle == "hidden") { - // Updating the title text as above automatically reveals the - // native title view in macOS 15.0 and above. Since we're using - // a custom view instead, we need to re-hide it. - window.titleVisibility = .hidden - } - toolbar.titleText = to - } - } - - override func surfaceTreeDidChange() { - // Whenever our surface tree changes in any way (new split, close split, etc.) - // we want to invalidate our state. - invalidateRestorableState() - } - - override func zoomStateDidChange(to: Bool) { - guard let window = window as? TerminalWindow else { return } - window.surfaceIsZoomed = to - } - + override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { super.focusedSurfaceDidChange(to: to) @@ -842,19 +1179,19 @@ class TerminalController: BaseTerminalController { @objc private func onCloseTab(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: target) ?? false else { return } + guard surfaceTree.contains(target) else { return } closeTab(self) } @objc private func onCloseWindow(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: target) ?? false else { return } + guard surfaceTree.contains(target) else { return } closeWindow(self) } @objc private func onResetWindowSize(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: target) ?? false else { return } + guard surfaceTree.contains(target) else { return } returnToDefaultSize(nil) } @@ -875,36 +1212,29 @@ class TerminalController: BaseTerminalController { toggleFullscreen(mode: fullscreenMode) } - @objc private func onEqualizeSplits(_ notification: Notification) { - guard let target = notification.object as? Ghostty.SurfaceView else { return } - - // Check if target surface is in current controller's tree - guard surfaceTree?.contains(view: target) ?? false else { return } - - if case .split(let container) = surfaceTree { - _ = container.equalize() - } - } - struct DerivedConfig { let backgroundColor: Color + let macosWindowButtons: Ghostty.MacOSWindowButtons let macosTitlebarStyle: String let maximize: Bool init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) + self.macosWindowButtons = .visible self.macosTitlebarStyle = "system" self.maximize = false } init(_ config: Ghostty.Config) { self.backgroundColor = config.backgroundColor + self.macosWindowButtons = config.macosWindowButtons self.macosTitlebarStyle = config.macosTitlebarStyle self.maximize = config.maximize } } } +// MARK: NSMenuItemValidation extension TerminalController: NSMenuItemValidation { func validateMenuItem(_ item: NSMenuItem) -> Bool { diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift deleted file mode 100644 index 07735cb58..000000000 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ /dev/null @@ -1,372 +0,0 @@ -import Cocoa -import SwiftUI -import GhosttyKit -import Combine - -/// Manages a set of terminal windows. This is effectively an array of TerminalControllers. -/// This abstraction helps manage tabs and multi-window scenarios. -class TerminalManager { - struct Window { - let controller: TerminalController - let closePublisher: AnyCancellable - } - - let ghostty: Ghostty.App - - /// The currently focused surface of the main window. - var focusedSurface: Ghostty.SurfaceView? { mainWindow?.controller.focusedSurface } - - /// The set of windows we currently have. - var windows: [Window] = [] - - // Keep track of the last point that our window was launched at so that new - // windows "cascade" over each other and don't just launch directly on top - // of each other. - private static var lastCascadePoint = NSPoint(x: 0, y: 0) - - /// Returns the main window of the managed window stack. If there is no window - /// then an arbitrary window will be chosen. - private var mainWindow: Window? { - for window in windows { - if (window.controller.window?.isMainWindow ?? false) { - return window - } - } - - // If we have no main window, just use the last window. - return windows.last - } - - /// The configuration derived from the Ghostty config so we don't need to rely on references. - private var derivedConfig: DerivedConfig - - init(_ ghostty: Ghostty.App) { - self.ghostty = ghostty - self.derivedConfig = DerivedConfig(ghostty.config) - - let center = NotificationCenter.default - center.addObserver( - self, - selector: #selector(onNewTab), - name: Ghostty.Notification.ghosttyNewTab, - object: nil) - center.addObserver( - self, - selector: #selector(onNewWindow), - name: Ghostty.Notification.ghosttyNewWindow, - object: nil) - center.addObserver( - self, - selector: #selector(ghosttyConfigDidChange(_:)), - name: .ghosttyConfigDidChange, - object: nil) - } - - deinit { - let center = NotificationCenter.default - center.removeObserver(self) - } - - // MARK: - Window Management - - /// Create a new terminal window. - func newWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { - let c = createWindow(withBaseConfig: base) - let window = c.window! - - // If the previous focused window was native fullscreen, the new window also - // becomes native fullscreen. - if let parent = focusedSurface?.window, - parent.styleMask.contains(.fullScreen) { - window.toggleFullScreen(nil) - } else if derivedConfig.windowFullscreen { - switch (derivedConfig.windowFullscreenMode) { - case .native: - // Native has to be done immediately so that our stylemask contains - // fullscreen for the logic later in this method. - c.toggleFullscreen(mode: .native) - - case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch: - // If we're non-native then we have to do it on a later loop - // so that the content view is setup. - DispatchQueue.main.async { - c.toggleFullscreen(mode: self.derivedConfig.windowFullscreenMode) - } - } - } - - // All new_window actions force our app to be active. - NSApp.activate(ignoringOtherApps: true) - - // We're dispatching this async because otherwise the lastCascadePoint doesn't - // take effect. Our best theory is there is some next-event-loop-tick logic - // that Cocoa is doing that we need to be after. - DispatchQueue.main.async { - // Only cascade if we aren't fullscreen. - if (!window.styleMask.contains(.fullScreen)) { - Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) - } - - c.showWindow(self) - } - } - - /// Creates a new tab in the current main window. If there are no windows, a window - /// is created. - func newTab(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { - // If there is no main window, just create a new window - guard let parent = mainWindow?.controller.window else { - newWindow(withBaseConfig: base) - return - } - - // Create a new window and add it to the parent - newTab(to: parent, withBaseConfig: base) - } - - private func newTab(to parent: NSWindow, withBaseConfig base: Ghostty.SurfaceConfiguration?) { - // Making sure that we're dealing with a TerminalController - guard parent.windowController is TerminalController else { return } - - // If our parent is in non-native fullscreen, then new tabs do not work. - // See: https://github.com/mitchellh/ghostty/issues/392 - if let controller = parent.windowController as? TerminalController, - let fullscreenStyle = controller.fullscreenStyle, - fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs { - let alert = NSAlert() - alert.messageText = "Cannot Create New Tab" - alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again." - alert.addButton(withTitle: "OK") - alert.alertStyle = .warning - alert.beginSheetModal(for: parent) - return - } - - // Create a new window and add it to the parent - let controller = createWindow(withBaseConfig: base) - let window = controller.window! - - // If the parent is miniaturized, then macOS exhibits really strange behaviors - // so we have to bring it back out. - if (parent.isMiniaturized) { parent.deminiaturize(self) } - - // If our parent tab group already has this window, macOS added it and - // we need to remove it so we can set the correct order in the next line. - // If we don't do this, macOS gets really confused and the tabbedWindows - // state becomes incorrect. - // - // At the time of writing this code, the only known case this happens - // is when the "+" button is clicked in the tab bar. - if let tg = parent.tabGroup, tg.windows.firstIndex(of: window) != nil { - tg.removeWindow(window) - } - - // Our windows start out invisible. We need to make it visible. If we - // don't do this then various features such as window blur won't work because - // the macOS APIs only work on a visible window. - controller.showWindow(self) - - // If we have the "hidden" titlebar style we want to create new - // tabs as windows instead, so just skip adding it to the parent. - if (derivedConfig.macosTitlebarStyle != "hidden") { - // Add the window to the tab group and show it. - switch derivedConfig.windowNewTabPosition { - case "end": - // If we already have a tab group and we want the new tab to open at the end, - // then we use the last window in the tab group as the parent. - if let last = parent.tabGroup?.windows.last { - last.addTabbedWindow(window, ordered: .above) - } else { - fallthrough - } - case "current": fallthrough - default: - parent.addTabbedWindow(window, ordered: .above) - - } - } - - window.makeKeyAndOrderFront(self) - - // It takes an event loop cycle until the macOS tabGroup state becomes - // consistent which causes our tab labeling to be off when the "+" button - // is used in the tab bar. This fixes that. If we can find a more robust - // solution we should do that. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { controller.relabelTabs() } - } - - /// Creates a window controller, adds it to our managed list, and returns it. - func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: Ghostty.SplitNode? = nil) -> TerminalController { - // Initialize our controller to load the window - let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree) - - // Create a listener for when the window is closed so we can remove it. - let pubClose = NotificationCenter.default.publisher( - for: NSWindow.willCloseNotification, - object: c.window! - ).sink { notification in - guard let window = notification.object as? NSWindow else { return } - guard let c = window.windowController as? TerminalController else { return } - self.removeWindow(c) - } - - // Keep track of every window we manage - windows.append(Window( - controller: c, - closePublisher: pubClose - )) - - return c - } - - func removeWindow(_ controller: TerminalController) { - // Remove it from our managed set - guard let idx = self.windows.firstIndex(where: { $0.controller == controller }) else { return } - let w = self.windows[idx] - self.windows.remove(at: idx) - - // Ensure any publishers we have are cancelled - w.closePublisher.cancel() - - // If we remove a window, we reset the cascade point to the key window so that - // the next window cascade's from that one. - if let focusedWindow = NSApplication.shared.keyWindow { - // If we are NOT the focused window, then we are a tabbed window. If we - // are closing a tabbed window, we want to set the cascade point to be - // the next cascade point from this window. - if focusedWindow != controller.window { - // The cascadeTopLeft call below should NOT move the window. Starting with - // macOS 15, we found that specifically when used with the new window snapping - // features of macOS 15, this WOULD move the frame. So we keep track of the - // old frame and restore it if necessary. Issue: - // https://github.com/ghostty-org/ghostty/issues/2565 - let oldFrame = focusedWindow.frame - - Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint) - - if focusedWindow.frame != oldFrame { - focusedWindow.setFrame(oldFrame, display: true) - } - - return - } - - // If we are the focused window, then we set the last cascade point to - // our own frame so that it shows up in the same spot. - let frame = focusedWindow.frame - Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) - } - - // I don't think we strictly have to do this but if a window is - // closed I want to make sure that the app state is invalided so - // we don't reopen closed windows. - NSApplication.shared.invalidateRestorableState() - } - - /// Close all windows, asking for confirmation if necessary. - func closeAllWindows() { - var needsConfirm: Bool = false - for w in self.windows { - if (w.controller.surfaceTree?.needsConfirmQuit() ?? false) { - needsConfirm = true - break - } - } - - if (!needsConfirm) { - for w in self.windows { - w.controller.close() - } - - return - } - - // If we don't have a main window, we just close all windows because - // we have no window to show the modal on top of. I'm sure there's a way - // to do an app-level alert but I don't know how and this case should never - // really happen. - guard let alertWindow = mainWindow?.controller.window else { - for w in self.windows { - w.controller.close() - } - - return - } - - // If we need confirmation by any, show one confirmation for all windows - let alert = NSAlert() - alert.messageText = "Close All Windows?" - alert.informativeText = "All terminal sessions will be terminated." - alert.addButton(withTitle: "Close All Windows") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: alertWindow, completionHandler: { response in - if (response == .alertFirstButtonReturn) { - for w in self.windows { - w.controller.close() - } - } - }) - } - - /// Relabels all the tabs with the proper keyboard shortcut. - func relabelAllTabs() { - for w in windows { - w.controller.relabelTabs() - } - } - - // MARK: - Notifications - - @objc private func onNewWindow(notification: SwiftUI.Notification) { - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? Ghostty.SurfaceConfiguration - self.newWindow(withBaseConfig: config) - } - - @objc private func onNewTab(notification: SwiftUI.Notification) { - guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard let window = surfaceView.window else { return } - - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? Ghostty.SurfaceConfiguration - - self.newTab(to: window, withBaseConfig: config) - } - - @objc private func ghosttyConfigDidChange(_ notification: Notification) { - // We only care if the configuration is a global configuration, not a - // surface-specific one. - guard notification.object == nil else { return } - - // Get our managed configuration object out - guard let config = notification.userInfo?[ - Notification.Name.GhosttyConfigChangeKey - ] as? Ghostty.Config else { return } - - // Update our derived config - self.derivedConfig = DerivedConfig(config) - } - - private struct DerivedConfig { - let windowFullscreen: Bool - let windowFullscreenMode: FullscreenMode - let macosTitlebarStyle: String - let windowNewTabPosition: String - - init() { - self.windowFullscreen = false - self.windowFullscreenMode = .native - self.macosTitlebarStyle = "transparent" - self.windowNewTabPosition = "" - } - - init(_ config: Ghostty.Config) { - self.windowFullscreen = config.windowFullscreen - self.windowFullscreenMode = config.windowFullscreenMode - self.macosTitlebarStyle = config.macosTitlebarStyle - self.windowNewTabPosition = config.windowNewTabPosition - } - } -} diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index b9d9b0ac0..9d9b7ffb1 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,10 +4,10 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 2 + static let version: Int = 3 let focusedSurface: String? - let surfaceTree: Ghostty.SplitNode? + let surfaceTree: SplitTree init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.uuid.uuidString @@ -83,18 +83,29 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // can be found for events from libghostty. This uses the low-level // createWindow so that AppKit can place the window wherever it should // be. - let c = appDelegate.terminalManager.createWindow(withSurfaceTree: state.surfaceTree) + let c = TerminalController.init( + appDelegate.ghostty, + withSurfaceTree: state.surfaceTree) guard let window = c.window else { completionHandler(nil, TerminalRestoreError.windowDidNotLoad) return } // Setup our restored state on the controller - if let focusedStr = state.focusedSurface, - let focusedUUID = UUID(uuidString: focusedStr), - let view = c.surfaceTree?.findUUID(uuid: focusedUUID) { - c.focusedSurface = view - restoreFocus(to: view, inWindow: window) + // Find the focused surface in surfaceTree + if let focusedStr = state.focusedSurface { + var foundView: Ghostty.SurfaceView? + for view in c.surfaceTree { + if view.uuid.uuidString == focusedStr { + foundView = view + break + } + } + + if let view = foundView { + c.focusedSurface = view + restoreFocus(to: view, inWindow: window) + } } completionHandler(window, nil) diff --git a/macos/Sources/Features/Terminal/TerminalToolbar.swift b/macos/Sources/Features/Terminal/TerminalToolbar.swift deleted file mode 100644 index aa4ca31cd..000000000 --- a/macos/Sources/Features/Terminal/TerminalToolbar.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Cocoa - -// Custom NSToolbar subclass that displays a centered window title, -// in order to accommodate the titlebar tabs feature. -class TerminalToolbar: NSToolbar, NSToolbarDelegate { - private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty") - - var titleText: String { - get { - titleTextField.stringValue - } - - set { - titleTextField.stringValue = newValue - } - } - - var titleFont: NSFont? { - get { - titleTextField.font - } - - set { - titleTextField.font = newValue - } - } - - override init(identifier: NSToolbar.Identifier) { - super.init(identifier: identifier) - - delegate = self - centeredItemIdentifiers.insert(.titleText) - } - - func toolbar(_ toolbar: NSToolbar, - itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, - willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { - var item: NSToolbarItem - - switch itemIdentifier { - case .titleText: - item = NSToolbarItem(itemIdentifier: .titleText) - item.view = self.titleTextField - item.visibilityPriority = .user - - // This ensures the title text field doesn't disappear when shrinking the view - self.titleTextField.translatesAutoresizingMaskIntoConstraints = false - self.titleTextField.setContentHuggingPriority(.defaultLow, for: .horizontal) - self.titleTextField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - - // Add constraints to the toolbar item's view - NSLayoutConstraint.activate([ - // Set the height constraint to match the toolbar's height - self.titleTextField.heightAnchor.constraint(equalToConstant: 22), // Adjust as needed - ]) - - item.isEnabled = true - case .resetZoom: - item = NSToolbarItem(itemIdentifier: .resetZoom) - default: - item = NSToolbarItem(itemIdentifier: itemIdentifier) - } - - return item - } - - func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - return [.titleText, .flexibleSpace, .space, .resetZoom] - } - - func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - // These space items are here to ensure that the title remains centered when it starts - // getting smaller than the max size so starts clipping. Lucky for us, two of the - // built-in spacers plus the un-zoom button item seems to exactly match the space - // on the left that's reserved for the window buttons. - return [.flexibleSpace, .titleText, .flexibleSpace] - } -} - -/// A label that expands to fit whatever text you put in it and horizontally centers itself in the current window. -fileprivate class CenteredDynamicLabel: NSTextField { - override func viewDidMoveToSuperview() { - // Configure the text field - isEditable = false - isBordered = false - drawsBackground = false - alignment = .center - lineBreakMode = .byTruncatingTail - cell?.truncatesLastVisibleLine = true - - // Use Auto Layout - translatesAutoresizingMaskIntoConstraints = false - - // Set content hugging and compression resistance priorities - setContentHuggingPriority(.defaultLow, for: .horizontal) - setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - } - - // Vertically center the text - override func draw(_ dirtyRect: NSRect) { - guard let attributedString = self.attributedStringValue.mutableCopy() as? NSMutableAttributedString else { - super.draw(dirtyRect) - return - } - - let textSize = attributedString.size() - - let yOffset = (self.bounds.height - textSize.height) / 2 - 1 // -1 to center it better - - let centeredRect = NSRect(x: self.bounds.origin.x, y: self.bounds.origin.y + yOffset, - width: self.bounds.width, height: textSize.height) - - attributedString.draw(in: centeredRect) - } -} - -extension NSToolbarItem.Identifier { - static let resetZoom = NSToolbarItem.Identifier("ResetZoom") - static let titleText = NSToolbarItem.Identifier("TitleText") -} diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 7caceb071..cb6f11bce 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -14,15 +14,11 @@ protocol TerminalViewDelegate: AnyObject { /// The cell size changed. func cellSizeDidChange(to: NSSize) - /// The surface tree did change in some way, i.e. a split was added, removed, etc. This is - /// not called initially. - func surfaceTreeDidChange() - - /// This is called when a split is zoomed. - func zoomStateDidChange(to: Bool) - /// Perform an action. At the time of writing this is only triggered by the command palette. func performAction(_ action: String, on: Ghostty.SurfaceView) + + /// A split is resizing to a given value. + func splitDidResize(node: SplitTree.Node, to newRatio: Double) } /// The view model is a required implementation for TerminalView callers. This contains @@ -31,7 +27,7 @@ protocol TerminalViewDelegate: AnyObject { protocol TerminalViewModel: ObservableObject { /// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView /// and children. This should be @Published. - var surfaceTree: Ghostty.SplitNode? { get set } + var surfaceTree: SplitTree { get set } /// The command palette state. var commandPaletteIsShowing: Bool { get set } @@ -57,7 +53,6 @@ struct TerminalView: View { // Various state values sent back up from the currently focused terminals. @FocusedValue(\.ghosttySurfaceView) private var focusedSurface @FocusedValue(\.ghosttySurfacePwd) private var surfacePwd - @FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit @FocusedValue(\.ghosttySurfaceCellSize) private var cellSize // The pwd of the focused surface as a URL @@ -81,7 +76,9 @@ struct TerminalView: View { DebugBuildWarningView() } - Ghostty.TerminalSplit(node: $viewModel.surfaceTree) + TerminalSplitTreeView( + tree: viewModel.surfaceTree, + onResize: { delegate?.splitDidResize(node: $0, to: $1) }) .environmentObject(ghostty) .focused($focused) .onAppear { self.focused = true } @@ -100,15 +97,6 @@ struct TerminalView: View { guard let size = newValue else { return } self.delegate?.cellSizeDidChange(to: size) } - .onChange(of: viewModel.surfaceTree?.hashValue) { _ in - // This is funky, but its the best way I could think of to detect - // ANY CHANGE within the deeply nested surface tree -- detecting a change - // in the hash value. - self.delegate?.surfaceTreeDidChange() - } - .onChange(of: zoomedSplit) { newValue in - self.delegate?.zoomStateDidChange(to: newValue ?? false) - } } // Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style .ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : []) diff --git a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift new file mode 100644 index 000000000..5f4d6b177 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift @@ -0,0 +1,89 @@ +import AppKit + +class HiddenTitlebarTerminalWindow: TerminalWindow { + override func awakeFromNib() { + super.awakeFromNib() + + // Setup our initial style + reapplyHiddenStyle() + + // Notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(fullscreenDidExit(_:)), + name: .fullscreenDidExit, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + /// Apply the hidden titlebar style. + private func reapplyHiddenStyle() { + styleMask = [ + // We need `titled` in the mask to get the normal window frame + .titled, + + // Full size content view so we can extend + // content in to the hidden titlebar's area + .fullSizeContentView, + + .resizable, + .closable, + .miniaturizable, + ] + + // Hide the title + titleVisibility = .hidden + titlebarAppearsTransparent = true + + // Hide the traffic lights (window control buttons) + standardWindowButton(.closeButton)?.isHidden = true + standardWindowButton(.miniaturizeButton)?.isHidden = true + standardWindowButton(.zoomButton)?.isHidden = true + + // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. + tabbingMode = .disallowed + + // Nuke it from orbit -- hide the titlebar container entirely, just in case. There are + // some operations that appear to bring back the titlebar visibility so this ensures + // it is gone forever. + if let themeFrame = contentView?.superview, + let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { + titleBarContainer.isHidden = true + } + } + + // MARK: NSWindow + + override var title: String { + didSet { + // Updating the title text as above automatically reveals the + // native title view in macOS 15.0 and above. Since we're using + // a custom view instead, we need to re-hide it. + reapplyHiddenStyle() + } + } + + // We override this so that with the hidden titlebar style the titlebar + // area is not draggable. + override var contentLayoutRect: CGRect { + var rect = super.contentLayoutRect + rect.origin.y = 0 + rect.size.height = self.frame.height + return rect + } + + // MARK: Notifications + + @objc private func fullscreenDidExit(_ notification: Notification) { + // Make sure they're talking about our window + guard let fullscreen = notification.object as? FullscreenBase else { return } + guard fullscreen.window == self else { return } + + // On exit we need to reapply the style because macOS breaks it usually. + // This is safe to call repeatedly so if its not broken its still safe. + reapplyHiddenStyle() + } +} diff --git a/macos/Sources/Features/Terminal/Terminal.xib b/macos/Sources/Features/Terminal/Window Styles/Terminal.xib similarity index 86% rename from macos/Sources/Features/Terminal/Terminal.xib rename to macos/Sources/Features/Terminal/Window Styles/Terminal.xib index 65b03b6eb..cfbb2221c 100644 --- a/macos/Sources/Features/Terminal/Terminal.xib +++ b/macos/Sources/Features/Terminal/Window Styles/Terminal.xib @@ -1,8 +1,8 @@ - + - + @@ -17,10 +17,10 @@ - + - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib new file mode 100644 index 000000000..eb4675657 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib new file mode 100644 index 000000000..deaeded9f --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib new file mode 100644 index 000000000..bf53a4510 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib new file mode 100644 index 000000000..25922e2f3 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift new file mode 100644 index 000000000..e24323113 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -0,0 +1,477 @@ +import AppKit +import SwiftUI +import GhosttyKit + +/// The base class for all standalone, "normal" terminal windows. This sets the basic +/// style and configuration of the window based on the app configuration. +class TerminalWindow: NSWindow { + /// This is the key in UserDefaults to use for the default `level` value. This is + /// used by the manual float on top menu item feature. + static let defaultLevelKey: String = "TerminalDefaultLevel" + + /// The view model for SwiftUI views + private var viewModel = ViewModel() + + /// Reset split zoom button in titlebar + private let resetZoomAccessory = NSTitlebarAccessoryViewController() + + /// The configuration derived from the Ghostty config so we don't need to rely on references. + private(set) var derivedConfig: DerivedConfig = .init() + + /// Gets the terminal controller from the window controller. + var terminalController: TerminalController? { + windowController as? TerminalController + } + + // MARK: NSWindow Overrides + + override var toolbar: NSToolbar? { + didSet { + DispatchQueue.main.async { + // When we have a toolbar, our SwiftUI view needs to know for layout + self.viewModel.hasToolbar = self.toolbar != nil + } + } + } + + override func awakeFromNib() { + guard let appDelegate = NSApp.delegate as? AppDelegate else { return } + + // All new windows are based on the app config at the time of creation. + let config = appDelegate.ghostty.config + + // Setup our initial config + derivedConfig = .init(config) + + // If window decorations are disabled, remove our title + if (!config.windowDecorations) { styleMask.remove(.titled) } + + // Set our window positioning to coordinates if config value exists, otherwise + // fallback to original centering behavior + setInitialWindowPosition( + x: config.windowPositionX, + y: config.windowPositionY, + windowDecorations: config.windowDecorations) + + // If our traffic buttons should be hidden, then hide them + if config.macosWindowButtons == .hidden { + hideWindowButtons() + } + + // Create our reset zoom titlebar accessory. + resetZoomAccessory.layoutAttribute = .right + resetZoomAccessory.view = NSHostingView(rootView: ResetZoomAccessoryView( + viewModel: viewModel, + action: { [weak self] in + guard let self else { return } + self.terminalController?.splitZoom(self) + })) + addTitlebarAccessoryViewController(resetZoomAccessory) + resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false + + // Setup the accessory view for tabs that shows our keyboard shortcuts, + // zoomed state, etc. Note I tried to use SwiftUI here but ran into issues + // where buttons were not clickable. + let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton]) + stackView.setHuggingPriority(.defaultHigh, for: .horizontal) + stackView.spacing = 3 + tab.accessoryView = stackView + + // Get our saved level + level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal + } + + // Both of these must be true for windows without decorations to be able to + // still become key/main and receive events. + override var canBecomeKey: Bool { return true } + override var canBecomeMain: Bool { return true } + + override func becomeKey() { + super.becomeKey() + resetZoomTabButton.contentTintColor = .controlAccentColor + } + + override func resignKey() { + super.resignKey() + resetZoomTabButton.contentTintColor = .secondaryLabelColor + } + + override func becomeMain() { + super.becomeMain() + + // Its possible we miss the accessory titlebar call so we check again + // whenever the window becomes main. Both of these are idempotent. + if hasTabBar { + tabBarDidAppear() + } else { + tabBarDidDisappear() + } + } + + override func mergeAllWindows(_ sender: Any?) { + super.mergeAllWindows(sender) + + // It takes an event loop cycle to merge all the windows so we set a + // short timer to relabel the tabs (issue #1902) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.terminalController?.relabelTabs() + } + } + + override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { + super.addTitlebarAccessoryViewController(childViewController) + + // Tab bar is attached as a titlebar accessory view controller (layout bottom). We + // can detect when it is shown or hidden by overriding add/remove and searching for + // it. This has been verified to work on macOS 12 to 26 + if isTabBar(childViewController) { + childViewController.identifier = Self.tabBarIdentifier + tabBarDidAppear() + } + } + + override func removeTitlebarAccessoryViewController(at index: Int) { + if let childViewController = titlebarAccessoryViewControllers[safe: index], isTabBar(childViewController) { + tabBarDidDisappear() + } + + super.removeTitlebarAccessoryViewController(at: index) + } + + // MARK: Tab Bar + + /// This identifier is attached to the tab bar view controller when we detect it being + /// added. + static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + + /// Returns true if there is a tab bar visible on this window. + var hasTabBar: Bool { + contentView?.firstViewFromRoot(withClassName: "NSTabBar") != nil + } + + func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool { + if childViewController.identifier == nil { + // The good case + if childViewController.view.contains(className: "NSTabBar") { + return true + } + + // When a new window is attached to an existing tab group, AppKit adds + // an empty NSView as an accessory view and adds the tab bar later. If + // we're at the bottom and are a single NSView we assume its a tab bar. + if childViewController.layoutAttribute == .bottom && + childViewController.view.className == "NSView" && + childViewController.view.subviews.isEmpty { + return true + } + + return false + } + + // View controllers should be tagged with this as soon as possible to + // increase our accuracy. We do this manually. + return childViewController.identifier == Self.tabBarIdentifier + } + + private func tabBarDidAppear() { + // Remove our reset zoom accessory. For some reason having a SwiftUI + // titlebar accessory causes our content view scaling to be wrong. + // Removing it fixes it, we just need to remember to add it again later. + if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) { + removeTitlebarAccessoryViewController(at: idx) + } + } + + private func tabBarDidDisappear() { + if styleMask.contains(.titled) { + if titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) == nil { + addTitlebarAccessoryViewController(resetZoomAccessory) + } + } + } + + // MARK: Tab Key Equivalents + + var keyEquivalent: String? = nil { + didSet { + // When our key equivalent is set, we must update the tab label. + guard let keyEquivalent else { + keyEquivalentLabel.attributedStringValue = NSAttributedString() + return + } + + keyEquivalentLabel.attributedStringValue = NSAttributedString( + string: "\(keyEquivalent) ", + attributes: [ + .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), + .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, + ]) + } + } + + /// The label that has the key equivalent for tab views. + private lazy var keyEquivalentLabel: NSTextField = { + let label = NSTextField(labelWithAttributedString: NSAttributedString()) + label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal) + label.postsFrameChangedNotifications = true + return label + }() + + // MARK: Surface Zoom + + /// Set to true if a surface is currently zoomed to show the reset zoom button. + var surfaceIsZoomed: Bool = false { + didSet { + // Show/hide our reset zoom button depending on if we're zoomed. + // We want to show it if we are zoomed. + resetZoomTabButton.isHidden = !surfaceIsZoomed + + DispatchQueue.main.async { + self.viewModel.isSurfaceZoomed = self.surfaceIsZoomed + } + } + } + + private lazy var resetZoomTabButton: NSButton = generateResetZoomButton() + + private func generateResetZoomButton() -> NSButton { + let button = NSButton() + button.isHidden = true + button.target = terminalController + button.action = #selector(TerminalController.splitZoom(_:)) + button.isBordered = false + button.allowsExpansionToolTips = true + button.toolTip = "Reset Zoom" + button.contentTintColor = .controlAccentColor + button.state = .on + button.image = NSImage(named:"ResetZoom") + button.frame = NSRect(x: 0, y: 0, width: 20, height: 20) + button.translatesAutoresizingMaskIntoConstraints = false + button.widthAnchor.constraint(equalToConstant: 20).isActive = true + button.heightAnchor.constraint(equalToConstant: 20).isActive = true + return button + } + + // MARK: Title Text + + override var title: String { + didSet { + // Whenever we change the window title we must also update our + // tab title if we're using custom fonts. + tab.attributedTitle = attributedTitle + } + } + + // Used to set the titlebar font. + var titlebarFont: NSFont? { + didSet { + let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) + + titlebarTextField?.font = font + tab.attributedTitle = attributedTitle + } + } + + // Find the NSTextField responsible for displaying the titlebar's title. + private var titlebarTextField: NSTextField? { + titlebarContainer? + .firstDescendant(withClassName: "NSTitlebarView")? + .firstDescendant(withClassName: "NSTextField") as? NSTextField + } + + // Return a styled representation of our title property. + var attributedTitle: NSAttributedString? { + guard let titlebarFont = titlebarFont else { return nil } + + let attributes: [NSAttributedString.Key: Any] = [ + .font: titlebarFont, + .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, + ] + return NSAttributedString(string: title, attributes: attributes) + } + + var titlebarContainer: NSView? { + // If we aren't fullscreen then the titlebar container is part of our window. + if !styleMask.contains(.fullScreen) { + return contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") + } + + // If we are fullscreen, the titlebar container view is part of a separate + // "fullscreen window", we need to find the window and then get the view. + for window in NSApplication.shared.windows { + // This is the private window class that contains the toolbar + guard window.className == "NSToolbarFullScreenWindow" else { continue } + + // The parent will match our window. This is used to filter the correct + // fullscreen window if we have multiple. + guard window.parent == self else { continue } + + return window.contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") + } + + return nil + } + + // MARK: Positioning And Styling + + /// This is called by the controller when there is a need to reset the window appearance. + func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + // If our window is not visible, then we do nothing. Some things such as blurring + // have no effect if the window is not visible. Ultimately, we'll have this called + // at some point when a surface becomes focused. + guard isVisible else { return } + + // Basic properties + appearance = surfaceConfig.windowAppearance + hasShadow = surfaceConfig.macosWindowShadow + + // Window transparency only takes effect if our window is not native fullscreen. + // In native fullscreen we disable transparency/opacity because the background + // becomes gray and widgets show through. + if !styleMask.contains(.fullScreen) && + surfaceConfig.backgroundOpacity < 1 + { + isOpaque = false + + // This is weird, but we don't use ".clear" because this creates a look that + // matches Terminal.app much more closer. This lets users transition from + // Terminal.app more easily. + backgroundColor = .white.withAlphaComponent(0.001) + + if let appDelegate = NSApp.delegate as? AppDelegate { + ghostty_set_window_background_blur( + appDelegate.ghostty.app, + Unmanaged.passUnretained(self).toOpaque()) + } + } else { + isOpaque = true + + let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor) + self.backgroundColor = backgroundColor.withAlphaComponent(1) + } + } + + /// The preferred window background color. The current window background color may not be set + /// to this, since this is dynamic based on the state of the surface tree. + /// + /// This background color will include alpha transparency if set. If the caller doesn't want that, + /// change the alpha channel again manually. + var preferredBackgroundColor: NSColor? { + if let terminalController, !terminalController.surfaceTree.isEmpty { + let surface: Ghostty.SurfaceView? + + // If our focused surface borders the top then we prefer its background color + if let focusedSurface = terminalController.focusedSurface, + let treeRoot = terminalController.surfaceTree.root, + let focusedNode = treeRoot.node(view: focusedSurface), + treeRoot.spatial().doesBorder(side: .up, from: focusedNode) { + surface = focusedSurface + } else { + // If it doesn't border the top, we use the top-left leaf + surface = terminalController.surfaceTree.root?.leftmostLeaf() + } + + if let surface { + let backgroundColor = surface.backgroundColor ?? surface.derivedConfig.backgroundColor + let alpha = surface.derivedConfig.backgroundOpacity.clamped(to: 0.001...1) + return NSColor(backgroundColor).withAlphaComponent(alpha) + } + } + + let alpha = derivedConfig.backgroundOpacity.clamped(to: 0.001...1) + return derivedConfig.backgroundColor.withAlphaComponent(alpha) + } + + private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { + // If we don't have an X/Y then we try to use the previously saved window pos. + guard let x, let y else { + if (!LastWindowPosition.shared.restore(self)) { + center() + } + + return + } + + // Prefer the screen our window is being placed on otherwise our primary screen. + guard let screen = screen ?? NSScreen.screens.first else { + center() + return + } + + // Orient based on the top left of the primary monitor + let frame = screen.visibleFrame + setFrameOrigin(.init( + x: frame.minX + CGFloat(x), + y: frame.maxY - (CGFloat(y) + frame.height))) + } + + private func hideWindowButtons() { + standardWindowButton(.closeButton)?.isHidden = true + standardWindowButton(.miniaturizeButton)?.isHidden = true + standardWindowButton(.zoomButton)?.isHidden = true + } + + // MARK: Config + + struct DerivedConfig { + let backgroundColor: NSColor + let backgroundOpacity: Double + let macosWindowButtons: Ghostty.MacOSWindowButtons + + init() { + self.backgroundColor = NSColor.windowBackgroundColor + self.backgroundOpacity = 1 + self.macosWindowButtons = .visible + } + + init(_ config: Ghostty.Config) { + self.backgroundColor = NSColor(config.backgroundColor) + self.backgroundOpacity = config.backgroundOpacity + self.macosWindowButtons = config.macosWindowButtons + } + } +} + +// MARK: SwiftUI View + +extension TerminalWindow { + class ViewModel: ObservableObject { + @Published var isSurfaceZoomed: Bool = false + @Published var hasToolbar: Bool = false + } + + struct ResetZoomAccessoryView: View { + @ObservedObject var viewModel: ViewModel + let action: () -> Void + + // The padding from the top that the view appears. This was all just manually + // measured based on the OS. + var topPadding: CGFloat { + if #available(macOS 26.0, *), hasLiquidGlass() { + return viewModel.hasToolbar ? 10 : 5 + } else { + return viewModel.hasToolbar ? 9 : 4 + } + } + + var body: some View { + if viewModel.isSurfaceZoomed { + VStack { + Button(action: action) { + Image("ResetZoom") + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .help("Reset Split Zoom") + .frame(width: 20, height: 20) + Spacer() + } + // With a toolbar, the window title is taller, so we need more padding + // to properly align. + .padding(.top, topPadding) + // We always need space at the end of the titlebar + .padding(.trailing, 10) + } + } + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift new file mode 100644 index 000000000..9381f7329 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -0,0 +1,262 @@ +import AppKit +import SwiftUI + +/// `macos-titlebar-style = tabs` for macOS 26 (Tahoe) and later. +/// +/// This inherits from transparent styling so that the titlebar matches the background color +/// of the window. +class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { + /// The view model for SwiftUI views + private var viewModel = ViewModel() + + deinit { + tabBarObserver = nil + } + + // MARK: NSWindow + + override var title: String { + didSet { + viewModel.title = title + } + } + + override func awakeFromNib() { + super.awakeFromNib() + + // We must hide the title since we're going to be moving tabs into + // the titlebar which have their own title. + titleVisibility = .hidden + + // Create a toolbar + let toolbar = NSToolbar(identifier: "TerminalToolbar") + toolbar.delegate = self + toolbar.centeredItemIdentifiers.insert(.title) + self.toolbar = toolbar + toolbarStyle = .unifiedCompact + } + + override func becomeMain() { + super.becomeMain() + + // Check if we have a tab bar and set it up if we have to. See the comment + // on this function to learn why we need to check this here. + setupTabBar() + } + + // This is called by macOS for native tabbing in order to add the tab bar. We hook into + // this, detect the tab bar being added, and override its behavior. + override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { + // If this is the tab bar then we need to set it up for the titlebar + guard isTabBar(childViewController) else { + super.addTitlebarAccessoryViewController(childViewController) + return + } + + // Some setup needs to happen BEFORE it is added, such as layout. If + // we don't do this before the call below, we'll trigger an AppKit + // assertion. + childViewController.layoutAttribute = .right + + super.addTitlebarAccessoryViewController(childViewController) + + // Setup the tab bar to go into the titlebar. + DispatchQueue.main.async { + // HACK: wait a tick before doing anything, to avoid edge cases during startup... :/ + // If we don't do this then on launch windows with restored state with tabs will end + // up with messed up tab bars that don't show all tabs. + self.setupTabBar() + } + } + + override func removeTitlebarAccessoryViewController(at index: Int) { + guard let childViewController = titlebarAccessoryViewControllers[safe: index], + isTabBar(childViewController) else { + super.removeTitlebarAccessoryViewController(at: index) + return + } + + super.removeTitlebarAccessoryViewController(at: index) + + removeTabBar() + } + + // MARK: Tab Bar Setup + + private var tabBarObserver: NSObjectProtocol? { + didSet { + // When we change this we want to clear our old observer + guard let oldValue else { return } + NotificationCenter.default.removeObserver(oldValue) + } + } + + /// Take the NSTabBar that is on the window and convert it into titlebar tabs. + /// + /// Let me explain more background on what is happening here. When a tab bar is created, only the + /// main window actually has an NSTabBar. When an NSWindow in the tab group gains main, AppKit + /// creates/moves (unsure which) the NSTabBar for it and shows it. When it loses main, the tab bar + /// is removed from the view hierarchy. + /// + /// We can't reliably detect this via `addTitlebarAccessoryViewController` because AppKit + /// creates an accessory view controller for every window in the tab group, but only attaches + /// the actual NSTabBar to the main window's accessory view. + /// + /// The best way I've found to detect this is to search for and setup the tab bar anytime the + /// window gains focus. There are probably edge cases to check but to resolve all this I made + /// this function which is idempotent to call. + /// + /// There are more scenarios to look out for and they're documented within the method. + func setupTabBar() { + // We only want to setup the observer once + guard tabBarObserver == nil else { return } + + // Find our tab bar. If it doesn't exist we don't do anything. + guard let tabBar = contentView?.rootView.firstDescendant(withClassName: "NSTabBar") else { return } + + // View model updates must happen on their own ticks. + DispatchQueue.main.async { + self.viewModel.hasTabBar = true + } + + // Find our clip view + guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } + guard let accessoryView = clipView.subviews[safe: 0] else { return } + guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return } + guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } + + // The container is the view that we'll constrain our tab bar within. + let container = toolbarView + + // The padding for the tab bar. If we're showing window buttons then + // we need to offset the window buttons. + let leftPadding: CGFloat = switch(self.derivedConfig.macosWindowButtons) { + case .hidden: 0 + case .visible: 70 + } + + // Constrain the accessory clip view (the parent of the accessory view + // usually that clips the children) to the container view. + clipView.translatesAutoresizingMaskIntoConstraints = false + accessoryView.translatesAutoresizingMaskIntoConstraints = false + + // Setup all our constraints + NSLayoutConstraint.activate([ + clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: leftPadding), + clipView.rightAnchor.constraint(equalTo: container.rightAnchor), + clipView.topAnchor.constraint(equalTo: container.topAnchor, constant: 2), + clipView.heightAnchor.constraint(equalTo: container.heightAnchor), + accessoryView.leftAnchor.constraint(equalTo: clipView.leftAnchor), + accessoryView.rightAnchor.constraint(equalTo: clipView.rightAnchor), + accessoryView.topAnchor.constraint(equalTo: clipView.topAnchor), + accessoryView.heightAnchor.constraint(equalTo: clipView.heightAnchor), + ]) + + clipView.needsLayout = true + accessoryView.needsLayout = true + + // Setup an observer for the NSTabBar frame. When system appearance changes or + // other events occur, the tab bar can temporarily become zero-sized. When this + // happens, we need to remove our custom constraints and re-apply them once the + // tab bar has proper dimensions again to avoid constraint conflicts. + tabBar.postsFrameChangedNotifications = true + tabBarObserver = NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: tabBar, + queue: .main + ) { [weak self] _ in + guard let self else { return } + + // Check if either width or height is zero + guard tabBar.frame.size.width == 0 || tabBar.frame.size.height == 0 else { return } + + // Remove the observer so we can call setup again. + self.tabBarObserver = nil + + // Wait a tick to let the new tab bars appear and then set them up. + DispatchQueue.main.async { + self.setupTabBar() + } + } + } + + func removeTabBar() { + // View model needs to be updated on another tick because it + // triggers view updates. + DispatchQueue.main.async { + self.viewModel.hasTabBar = false + } + + // Clear our observations + self.tabBarObserver = nil + } + + // MARK: NSToolbarDelegate + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.title, .flexibleSpace, .space] + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.flexibleSpace, .title, .flexibleSpace] + } + + func toolbar(_ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + switch itemIdentifier { + case .title: + let item = NSToolbarItem(itemIdentifier: .title) + item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel)) + item.visibilityPriority = .user + item.isEnabled = true + + // This is the documented way to avoid the glass view on an item. + // We don't want glass on our title. + item.isBordered = false + + return item + default: + return NSToolbarItem(itemIdentifier: itemIdentifier) + } + } + + // MARK: SwiftUI + + class ViewModel: ObservableObject { + @Published var title: String = "👻 Ghostty" + @Published var hasTabBar: Bool = false + } +} + +extension NSToolbarItem.Identifier { + /// Displays the title of the window + static let title = NSToolbarItem.Identifier("Title") +} + +extension TitlebarTabsTahoeTerminalWindow { + /// Displays the window title + struct TitleItem: View { + @ObservedObject var viewModel: ViewModel + + var title: String { + // An empty title makes this view zero-sized and NSToolbar on macOS + // tahoe just deletes the item when that happens. So we use a space + // instead to ensure there's always some size. + return viewModel.title.isEmpty ? " " : viewModel.title + } + + var body: some View { + if !viewModel.hasTabBar { + Text(title) + .lineLimit(1) + .truncationMode(.tail) + } else { + // 1x1.gif strikes again! For real: if we render a zero-sized + // view here then the toolbar just disappears our view. I don't + // know. + Color.clear.frame(width: 1, height: 1) + } + } + } +} diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift similarity index 69% rename from macos/Sources/Features/Terminal/TerminalWindow.swift rename to macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 62b8dc5bf..99111b55b 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -1,14 +1,10 @@ import Cocoa -class TerminalWindow: NSWindow { - /// This is the key in UserDefaults to use for the default `level` value. - static let defaultLevelKey: String = "TerminalDefaultLevel" - - @objc dynamic var keyEquivalent: String = "" - +/// Titlebar tabs for macOS 13 to 15. +class TitlebarTabsVenturaTerminalWindow: TerminalWindow { /// This is used to determine if certain elements should be drawn light or dark and should /// be updated whenever the window background color or surrounding elements changes. - var isLightTheme: Bool = false + fileprivate var isLightTheme: Bool = false lazy var titlebarColor: NSColor = backgroundColor { didSet { @@ -18,131 +14,39 @@ class TerminalWindow: NSWindow { } } - private lazy var keyEquivalentLabel: NSTextField = { - let label = NSTextField(labelWithAttributedString: NSAttributedString()) - label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal) - label.postsFrameChangedNotifications = true + // false if all three traffic lights are missing/hidden, otherwise true + private var hasWindowButtons: Bool { + get { + // if standardWindowButton(.theButton) == nil, the button isn't there, so coalesce to true + let closeIsHidden = standardWindowButton(.closeButton)?.isHiddenOrHasHiddenAncestor ?? true + let miniaturizeIsHidden = standardWindowButton(.miniaturizeButton)?.isHiddenOrHasHiddenAncestor ?? true + let zoomIsHidden = standardWindowButton(.zoomButton)?.isHiddenOrHasHiddenAncestor ?? true + return !(closeIsHidden && miniaturizeIsHidden && zoomIsHidden) + } + } - return label - }() - - private lazy var bindings = [ - observe(\.surfaceIsZoomed, options: [.initial, .new]) { [weak self] window, _ in - guard let tabGroup = self?.tabGroup else { return } - - self?.resetZoomTabButton.isHidden = !window.surfaceIsZoomed - self?.updateResetZoomTitlebarButtonVisibility() - }, - - observe(\.keyEquivalent, options: [.initial, .new]) { [weak self] window, _ in - let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), - .foregroundColor: window.isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, - ] - let attributedString = NSAttributedString(string: " \(window.keyEquivalent) ", attributes: attributes) - - self?.keyEquivalentLabel.attributedStringValue = attributedString - }, - ] - - // Both of these must be true for windows without decorations to be able to - // still become key/main and receive events. - override var canBecomeKey: Bool { return true } - override var canBecomeMain: Bool { return true } - - // MARK: - Lifecycle + // MARK: NSWindow override func awakeFromNib() { super.awakeFromNib() - _ = bindings - - // Create the tab accessory view that houses the key-equivalent label and optional un-zoom button - let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton]) - stackView.setHuggingPriority(.defaultHigh, for: .horizontal) - stackView.spacing = 3 - tab.accessoryView = stackView - - if titlebarTabs { - generateToolbar() - } - - level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal - } - - deinit { - bindings.forEach() { $0.invalidate() } - } - - // MARK: Titlebar Helpers - // These helpers are generic to what we're trying to achieve (i.e. titlebar - // style tabs, titlebar styling, etc.). They're just here to make it easier. - - private var titlebarContainer: NSView? { - // If we aren't fullscreen then the titlebar container is part of our window. - if !styleMask.contains(.fullScreen) { - guard let view = contentView?.superview ?? contentView else { return nil } - return titlebarContainerView(in: view) + // Handle titlebar tabs config option. Something about what we do while setting up the + // titlebar tabs interferes with the window restore process unless window.tabbingMode + // is set to .preferred, so we set it, and switch back to automatic as soon as we can. + tabbingMode = .preferred + DispatchQueue.main.async { + self.tabbingMode = .automatic } - // If we are fullscreen, the titlebar container view is part of a separate - // "fullscreen window", we need to find the window and then get the view. - for window in NSApplication.shared.windows { - // This is the private window class that contains the toolbar - guard window.className == "NSToolbarFullScreenWindow" else { continue } + titlebarTabs = true - // The parent will match our window. This is used to filter the correct - // fullscreen window if we have multiple. - guard window.parent == self else { continue } + // Set the background color of the window + backgroundColor = derivedConfig.backgroundColor - guard let view = window.contentView else { continue } - return titlebarContainerView(in: view) - } - - return nil + // This makes sure our titlebar renders correctly when there is a transparent background + titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) } - private func titlebarContainerView(in view: NSView) -> NSView? { - if view.className == "NSTitlebarContainerView" { - return view - } - - for subview in view.subviews { - if let found = titlebarContainerView(in: subview) { - return found - } - } - - return nil - } - - // MARK: - NSWindow - - override var title: String { - didSet { - tab.attributedTitle = attributedTitle - } - } - - // We override this so that with the hidden titlebar style the titlebar - // area is not draggable. - override var contentLayoutRect: CGRect { - var rect = super.contentLayoutRect - - // If we are using a hidden titlebar style, the content layout is the - // full frame making it so that it is not draggable. - if let controller = windowController as? TerminalController, - controller.derivedConfig.macosTitlebarStyle == "hidden" { - rect.origin.y = 0 - rect.size.height = self.frame.height - } - return rect - } - - // The window theme configuration from Ghostty. This is used to control some - // behaviors that don't look quite right in certain situations. - var windowTheme: TerminalWindowTheme? - // We only need to set this once, but need to do it after the window has been created in order // to determine if the theme is using a very dark background, in which case we don't want to // remove the effect view if the default tab bar is being used since the effect created in @@ -153,13 +57,12 @@ class TerminalWindow: NSWindow { // This is required because the removeTitlebarAccessoryViewController hook does not // catch the creation of a new window by "tearing off" a tab from a tabbed window. if let tabGroup = self.tabGroup, tabGroup.windows.count < 2 { - hideCustomTabBarViews() + resetCustomTabBarViews() } super.becomeKey() updateNewTabButtonOpacity() - resetZoomTabButton.contentTintColor = .controlAccentColor resetZoomToolbarButton.contentTintColor = .controlAccentColor tab.attributedTitle = attributedTitle } @@ -168,7 +71,6 @@ class TerminalWindow: NSWindow { super.resignKey() updateNewTabButtonOpacity() - resetZoomTabButton.contentTintColor = .secondaryLabelColor resetZoomToolbarButton.contentTintColor = .tertiaryLabelColor tab.attributedTitle = attributedTitle } @@ -197,11 +99,6 @@ class TerminalWindow: NSWindow { } } - updateResetZoomTitlebarButtonVisibility() - - // The remainder of this function only applies to styled tabs. - guard hasStyledTabs else { return } - titlebarSeparatorStyle = tabbedWindows != nil && !titlebarTabs ? .line : .none if titlebarTabs { hideToolbarOverflowButton() @@ -246,20 +143,29 @@ class TerminalWindow: NSWindow { } } - // MARK: - Tab Bar Styling + // MARK: Appearance - // This is true if we should apply styles to the titlebar or tab bar. - var hasStyledTabs: Bool { - // If we have titlebar tabs then we always style. - guard !titlebarTabs else { return true } + override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + super.syncAppearance(surfaceConfig) - // We style the tabs if they're transparent - return transparentTabs + // Update our window light/darkness based on our updated background color + isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor + + // Update our titlebar color + if let preferredBackgroundColor { + titlebarColor = preferredBackgroundColor + } else { + titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) + } + + if (isOpaque) { + // If there is transparency, calling this will make the titlebar opaque + // so we only call this if we are opaque. + updateTabBar() + } } - // Set to true if the background color should bleed through the titlebar/tab bar. - // This only applies to non-titlebar tabs. - var transparentTabs: Bool = false + // MARK: Tab Bar Styling var hasVeryDarkBackground: Bool { backgroundColor.luminance < 0.05 @@ -274,8 +180,7 @@ class TerminalWindow: NSWindow { // We can only update titlebar tabs if there is a titlebar. Without the // styleMask check the app will crash (issue #1876) if titlebarTabs && styleMask.contains(.titled) { - guard let tabBarAccessoryViewController = titlebarAccessoryViewControllers.first(where: { $0.identifier == Self.TabBarController}) else { return } - + guard let tabBarAccessoryViewController = titlebarAccessoryViewControllers.first(where: { $0.identifier == Self.tabBarIdentifier}) else { return } tabBarAccessoryViewController.layoutAttribute = .right pushTabsToTitlebar(tabBarAccessoryViewController) } @@ -342,53 +247,8 @@ class TerminalWindow: NSWindow { // MARK: - Split Zoom Button - @objc dynamic var surfaceIsZoomed: Bool = false - private lazy var resetZoomToolbarButton: NSButton = generateResetZoomButton() - private lazy var resetZoomTabButton: NSButton = { - let button = generateResetZoomButton() - button.action = #selector(selectTabAndZoom(_:)) - return button - }() - - private lazy var resetZoomTitlebarAccessoryViewController: NSTitlebarAccessoryViewController? = { - guard let titlebarContainer else { return nil } - let size = NSSize(width: titlebarContainer.bounds.height, height: titlebarContainer.bounds.height) - let view = NSView(frame: NSRect(origin: .zero, size: size)) - - let button = generateResetZoomButton() - button.frame.origin.x = size.width/2 - button.bounds.width/2 - button.frame.origin.y = size.height/2 - button.bounds.height/2 - view.addSubview(button) - - let titlebarAccessoryViewController = NSTitlebarAccessoryViewController() - titlebarAccessoryViewController.view = view - titlebarAccessoryViewController.layoutAttribute = .right - - return titlebarAccessoryViewController - }() - - private func updateResetZoomTitlebarButtonVisibility() { - guard let tabGroup, let resetZoomTitlebarAccessoryViewController else { return } - - let isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed - - if titlebarTabs { - resetZoomToolbarButton.isHidden = isHidden - - for (index, vc) in titlebarAccessoryViewControllers.enumerated() { - guard vc == resetZoomTitlebarAccessoryViewController else { return } - removeTitlebarAccessoryViewController(at: index) - } - } else { - if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) { - addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController) - } - resetZoomTitlebarAccessoryViewController.view.isHidden = isHidden - } - } - private func generateResetZoomButton() -> NSButton { let button = NSButton() button.target = nil @@ -424,46 +284,19 @@ class TerminalWindow: NSWindow { // MARK: - Titlebar Font // Used to set the titlebar font. - var titlebarFont: NSFont? { + override var titlebarFont: NSFont? { didSet { - let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) - - titlebarTextField?.font = font - tab.attributedTitle = attributedTitle - - if let toolbar = toolbar as? TerminalToolbar { - toolbar.titleFont = font - } + guard let toolbar = toolbar as? TerminalToolbar else { return } + toolbar.titleFont = titlebarFont ?? .titleBarFont(ofSize: NSFont.systemFontSize) } } - // Find the NSTextField responsible for displaying the titlebar's title. - private var titlebarTextField: NSTextField? { - guard let titlebarView = titlebarContainer?.subviews - .first(where: { $0.className == "NSTitlebarView" }) else { return nil } - return titlebarView.subviews.first(where: { $0 is NSTextField }) as? NSTextField - } - - // Return a styled representation of our title property. - private var attributedTitle: NSAttributedString? { - guard let titlebarFont else { return nil } - - let attributes: [NSAttributedString.Key: Any] = [ - .font: titlebarFont, - .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, - ] - return NSAttributedString(string: title, attributes: attributes) - } - // MARK: - Titlebar Tabs private var windowButtonsBackdrop: WindowButtonsBackdropView? = nil private var windowDragHandle: WindowDragView? = nil - // The tab bar controller ID from macOS - static private let TabBarController = NSUserInterfaceItemIdentifier("_tabBarController") - // Used by the window controller to enable/disable titlebar tabs. var titlebarTabs = false { didSet { @@ -476,6 +309,18 @@ class TerminalWindow: NSWindow { } } + override var title: String { + didSet { + // Updating the title text as above automatically reveals the + // native title view in macOS 15.0 and above. Since we're using + // a custom view instead, we need to re-hide it. + titleVisibility = .hidden + if let toolbar = toolbar as? TerminalToolbar { + toolbar.titleText = title + } + } + } + // We have to regenerate a toolbar when the titlebar tabs setting changes since our // custom toolbar conditionally generates the items based on this setting. I tried to // invalidate the toolbar items and force a refresh, but as far as I can tell that @@ -491,7 +336,6 @@ class TerminalWindow: NSWindow { resetZoomItem.view!.widthAnchor.constraint(equalToConstant: 22).isActive = true resetZoomItem.view!.heightAnchor.constraint(equalToConstant: 20).isActive = true } - updateResetZoomTitlebarButtonVisibility() } // For titlebar tabs, we want to hide the separator view so that we get rid @@ -520,10 +364,7 @@ class TerminalWindow: NSWindow { // This is called by macOS for native tabbing in order to add the tab bar. We hook into // this, detect the tab bar being added, and override its behavior. override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { - let isTabBar = self.titlebarTabs && ( - childViewController.layoutAttribute == .bottom || - childViewController.identifier == Self.TabBarController - ) + let isTabBar = self.titlebarTabs && isTabBar(childViewController) if (isTabBar) { // Ensure it has the right layoutAttribute to force it next to our titlebar @@ -535,7 +376,7 @@ class TerminalWindow: NSWindow { // Mark the controller for future reference so we can easily find it. Otherwise // the tab bar has no ID by default. - childViewController.identifier = Self.TabBarController + childViewController.identifier = Self.tabBarIdentifier } super.addTitlebarAccessoryViewController(childViewController) @@ -546,20 +387,25 @@ class TerminalWindow: NSWindow { } override func removeTitlebarAccessoryViewController(at index: Int) { - let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.TabBarController + let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.tabBarIdentifier super.removeTitlebarAccessoryViewController(at: index) if (isTabBar) { - hideCustomTabBarViews() + resetCustomTabBarViews() } } // To be called immediately after the tab bar is disabled. - private func hideCustomTabBarViews() { + private func resetCustomTabBarViews() { // Hide the window buttons backdrop. windowButtonsBackdrop?.isHidden = true // Hide the window drag handle. windowDragHandle?.isHidden = true + + // Reenable the main toolbar title + if let toolbar = toolbar as? TerminalToolbar { + toolbar.titleIsHidden = false + } } private func pushTabsToTitlebar(_ tabBarController: NSTitlebarAccessoryViewController) { @@ -568,6 +414,11 @@ class TerminalWindow: NSWindow { generateToolbar() } + // The main title conflicts with titlebar tabs, so hide it + if let toolbar = toolbar as? TerminalToolbar { + toolbar.titleIsHidden = true + } + // HACK: wait a tick before doing anything, to avoid edge cases during startup... :/ // If we don't do this then on launch windows with restored state with tabs will end // up with messed up tab bars that don't show all tabs. @@ -614,7 +465,7 @@ class TerminalWindow: NSWindow { view.translatesAutoresizingMaskIntoConstraints = false view.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true - view.rightAnchor.constraint(equalTo: toolbarView.leftAnchor, constant: 78).isActive = true + view.rightAnchor.constraint(equalTo: toolbarView.leftAnchor, constant: hasWindowButtons ? 78 : 0).isActive = true view.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true view.heightAnchor.constraint(equalTo: toolbarView.heightAnchor).isActive = true @@ -692,7 +543,7 @@ fileprivate class WindowDragView: NSView { fileprivate class WindowButtonsBackdropView: NSView { // This must be weak because the window has this view. Otherwise // a retain cycle occurs. - private weak var terminalWindow: TerminalWindow? + private weak var terminalWindow: TitlebarTabsVenturaTerminalWindow? private let isLightTheme: Bool private let overlayLayer = VibrantLayer() @@ -720,7 +571,7 @@ fileprivate class WindowButtonsBackdropView: NSView { fatalError("init(coder:) has not been implemented") } - init(window: TerminalWindow) { + init(window: TitlebarTabsVenturaTerminalWindow) { self.terminalWindow = window self.isLightTheme = window.isLightTheme @@ -736,9 +587,133 @@ fileprivate class WindowButtonsBackdropView: NSView { } } -enum TerminalWindowTheme: String { - case auto - case system - case light - case dark +// MARK: Toolbar + +// Custom NSToolbar subclass that displays a centered window title, +// in order to accommodate the titlebar tabs feature. +fileprivate class TerminalToolbar: NSToolbar, NSToolbarDelegate { + private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty") + + var titleText: String { + get { + titleTextField.stringValue + } + + set { + titleTextField.stringValue = newValue + } + } + + var titleFont: NSFont? { + get { + titleTextField.font + } + + set { + titleTextField.font = newValue + } + } + + var titleIsHidden: Bool { + get { + titleTextField.isHidden + } + + set { + titleTextField.isHidden = newValue + } + } + + override init(identifier: NSToolbar.Identifier) { + super.init(identifier: identifier) + + delegate = self + centeredItemIdentifiers.insert(.titleText) + } + + func toolbar(_ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + var item: NSToolbarItem + + switch itemIdentifier { + case .titleText: + item = NSToolbarItem(itemIdentifier: .titleText) + item.view = self.titleTextField + item.visibilityPriority = .user + + // This ensures the title text field doesn't disappear when shrinking the view + self.titleTextField.translatesAutoresizingMaskIntoConstraints = false + self.titleTextField.setContentHuggingPriority(.defaultLow, for: .horizontal) + self.titleTextField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + // Add constraints to the toolbar item's view + NSLayoutConstraint.activate([ + // Set the height constraint to match the toolbar's height + self.titleTextField.heightAnchor.constraint(equalToConstant: 22), // Adjust as needed + ]) + + item.isEnabled = true + case .resetZoom: + item = NSToolbarItem(itemIdentifier: .resetZoom) + default: + item = NSToolbarItem(itemIdentifier: itemIdentifier) + } + + return item + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.titleText, .flexibleSpace, .space, .resetZoom] + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + // These space items are here to ensure that the title remains centered when it starts + // getting smaller than the max size so starts clipping. Lucky for us, two of the + // built-in spacers plus the un-zoom button item seems to exactly match the space + // on the left that's reserved for the window buttons. + return [.flexibleSpace, .titleText, .flexibleSpace] + } +} + +/// A label that expands to fit whatever text you put in it and horizontally centers itself in the current window. +fileprivate class CenteredDynamicLabel: NSTextField { + override func viewDidMoveToSuperview() { + // Configure the text field + isEditable = false + isBordered = false + drawsBackground = false + alignment = .center + lineBreakMode = .byTruncatingTail + cell?.truncatesLastVisibleLine = true + + // Use Auto Layout + translatesAutoresizingMaskIntoConstraints = false + + // Set content hugging and compression resistance priorities + setContentHuggingPriority(.defaultLow, for: .horizontal) + setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + } + + // Vertically center the text + override func draw(_ dirtyRect: NSRect) { + guard let attributedString = self.attributedStringValue.mutableCopy() as? NSMutableAttributedString else { + super.draw(dirtyRect) + return + } + + let textSize = attributedString.size() + + let yOffset = (self.bounds.height - textSize.height) / 2 - 1 // -1 to center it better + + let centeredRect = NSRect(x: self.bounds.origin.x, y: self.bounds.origin.y + yOffset, + width: self.bounds.width, height: textSize.height) + + attributedString.draw(in: centeredRect) + } +} + +extension NSToolbarItem.Identifier { + static let resetZoom = NSToolbarItem.Identifier("ResetZoom") + static let titleText = NSToolbarItem.Identifier("TitleText") } diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift new file mode 100644 index 000000000..1a92fa024 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -0,0 +1,189 @@ +import AppKit + +/// A terminal window style that provides a transparent titlebar effect. With this effect, the titlebar +/// matches the background color of the window. +class TransparentTitlebarTerminalWindow: TerminalWindow { + /// Stores the last surface configuration to reapply appearance when needed. + /// This is necessary because various macOS operations (tab switching, tab bar + /// visibility changes) can reset the titlebar appearance. + private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig? + + /// KVO observation for tab group window changes. + private var tabGroupWindowsObservation: NSKeyValueObservation? + private var tabBarVisibleObservation: NSKeyValueObservation? + + deinit { + tabGroupWindowsObservation?.invalidate() + tabBarVisibleObservation?.invalidate() + } + + // MARK: NSWindow + + override func awakeFromNib() { + super.awakeFromNib() + + // Setup all the KVO we will use, see the docs for the respective functions + // to learn why we need KVO. + setupKVO() + } + + override func becomeMain() { + super.becomeMain() + + guard let lastSurfaceConfig else { return } + syncAppearance(lastSurfaceConfig) + + // This is a nasty edge case. If we're going from 2 to 1 tab and the tab bar + // automatically disappears, then we need to resync our appearance because + // at some point macOS replaces the tab views. + if tabGroup?.windows.count ?? 0 == 2 { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in + self?.syncAppearance(self?.lastSurfaceConfig ?? lastSurfaceConfig) + } + } + } + + override func update() { + super.update() + + // On macOS 13 to 15, we need to hide the NSVisualEffectView in order to allow our + // titlebar to be truly transparent. + if !effectViewIsHidden && !hasLiquidGlass() { + hideEffectView() + } + } + + // MARK: Appearance + + override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + super.syncAppearance(surfaceConfig) + + // Save our config in case we need to reapply + lastSurfaceConfig = surfaceConfig + + // Everytime we change appearance, set KVO up again in case any of our + // references changed (e.g. tabGroup is new). + setupKVO() + + if #available(macOS 26.0, *), hasLiquidGlass() { + syncAppearanceTahoe(surfaceConfig) + } else { + syncAppearanceVentura(surfaceConfig) + } + } + + @available(macOS 26.0, *) + private func syncAppearanceTahoe(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + // When we have transparency, we need to set the titlebar background to match the + // window background but with opacity. The window background is set using the + // "preferred background color" property. + // + // As an inverse, if we don't have transparency, we don't bother with this because + // the window background will be set to the correct color so we can just hide the + // titlebar completely and we're good to go. + if !isOpaque { + if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") { + titlebarView.wantsLayer = true + titlebarView.layer?.backgroundColor = preferredBackgroundColor?.cgColor + } + } + + // In all cases, we have to hide the background view since this has multiple subviews + // that force a background color. + titlebarBackgroundView?.isHidden = true + } + + @available(macOS 13.0, *) + private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + guard let titlebarContainer else { return } + titlebarContainer.wantsLayer = true + titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor + effectViewIsHidden = false + } + + // MARK: View Finders + + private var titlebarBackgroundView: NSView? { + titlebarContainer?.firstDescendant(withClassName: "NSTitlebarBackgroundView") + } + + // MARK: Tab Group Observation + + private func setupKVO() { + // See the docs for the respective setup functions for why. + setupTabGroupObservation() + setupTabBarVisibleObservation() + } + + /// Monitors the tabGroup windows value for any changes and resyncs the appearance on change. + /// This is necessary because when the windows change, the tab bar and titlebar are recreated + /// which breaks our changes. + private func setupTabGroupObservation() { + // Remove existing observation if any + tabGroupWindowsObservation?.invalidate() + tabGroupWindowsObservation = nil + + // Check if tabGroup is available + guard let tabGroup else { return } + + // Set up KVO observation for the windows array. Whenever it changes + // we resync the appearance because it can cause macOS to redraw the + // tab bar. + tabGroupWindowsObservation = tabGroup.observe( + \.windows, + options: [.new] + ) { [weak self] _, change in + // NOTE: At one point, I guarded this on only if we went from 0 to N + // or N to 0 under the assumption that the tab bar would only get + // replaced on those cases. This turned out to be false (Tahoe). + // It's cheap enough to always redraw this so we should just do it + // unconditionally. + + guard let self else { return } + guard let lastSurfaceConfig else { return } + self.syncAppearance(lastSurfaceConfig) + } + } + + /// Monitors the tab bar for visibility. This lets the "Show/Hide Tab Bar" manual menu item + /// to not break our appearance. + private func setupTabBarVisibleObservation() { + // Remove existing observation if any + tabBarVisibleObservation?.invalidate() + tabBarVisibleObservation = nil + + // Set up KVO observation for isTabBarVisible + tabBarVisibleObservation = tabGroup?.observe( + \.isTabBarVisible, + options: [.new] + ) { [weak self] _, change in + guard let self else { return } + guard let lastSurfaceConfig else { return } + self.syncAppearance(lastSurfaceConfig) + } + } + + // MARK: macOS 13 to 15 + + // We only need to set this once, but need to do it after the window has been created in order + // to determine if the theme is using a very dark background, in which case we don't want to + // remove the effect view if the default tab bar is being used since the effect created in + // `updateTabsForVeryDarkBackgrounds` creates a confusing visual design. + private var effectViewIsHidden = false + + private func hideEffectView() { + guard !effectViewIsHidden else { return } + + // By hiding the visual effect view, we allow the window's (or titlebar's in this case) + // background color to show through. If we were to set `titlebarAppearsTransparent` to true + // the selected tab would look fine, but the unselected ones and new tab button backgrounds + // would be an opaque color. When the titlebar isn't transparent, however, the system applies + // a compositing effect to the unselected tab backgrounds, which makes them blend with the + // titlebar's/window's background. + if let effectView = titlebarContainer?.descendants(withClassName: "NSVisualEffectView").first { + effectView.isHidden = true + } + + effectViewIsHidden = true + } +} diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 65e91ce83..ba0b95212 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -550,6 +550,15 @@ extension Ghostty { case GHOSTTY_ACTION_RING_BELL: ringBell(app, target: target) + case GHOSTTY_ACTION_CHECK_FOR_UPDATES: + checkForUpdates(app) + + case GHOSTTY_ACTION_UNDO: + return undo(app, target: target) + + case GHOSTTY_ACTION_REDO: + return redo(app, target: target) + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: @@ -588,6 +597,56 @@ extension Ghostty { #endif } + private static func checkForUpdates( + _ app: ghostty_app_t + ) { + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + appDelegate.checkForUpdates(nil) + } + } + + private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool { + let undoManager: UndoManager? + switch (target.tag) { + case GHOSTTY_TARGET_APP: + undoManager = (NSApp.delegate as? AppDelegate)?.undoManager + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + undoManager = surfaceView.undoManager + + default: + assertionFailure() + return false + } + + guard let undoManager, undoManager.canUndo else { return false } + undoManager.undo() + return true + } + + private static func redo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool { + let undoManager: UndoManager? + switch (target.tag) { + case GHOSTTY_TARGET_APP: + undoManager = (NSApp.delegate as? AppDelegate)?.undoManager + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + undoManager = surfaceView.undoManager + + default: + assertionFailure() + return false + } + + guard let undoManager, undoManager.canRedo else { return false } + undoManager.redo() + return true + } + private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) { switch (target.tag) { case GHOSTTY_TARGET_APP: @@ -734,7 +793,7 @@ extension Ghostty { guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } guard let mode = FullscreenMode.from(ghostty: raw) else { - Ghostty.logger.warning("unknow fullscreen mode raw=\(raw.rawValue)") + Ghostty.logger.warning("unknown fullscreen mode raw=\(raw.rawValue)") return } NotificationCenter.default.post( @@ -910,7 +969,7 @@ extension Ghostty { // we should only be returning true if we actually performed the action, // but this handles the most common case of caring about goto_split performability // which is the no-split case. - guard controller.surfaceTree?.isSplit ?? false else { return false } + guard controller.surfaceTree.isSplit else { return false } NotificationCenter.default.post( name: Notification.ghosttyFocusSplit, @@ -1071,7 +1130,7 @@ extension Ghostty { guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } guard let window = surfaceView.window as? TerminalWindow else { return } - + switch (mode) { case .on: window.level = .floating diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index d7be4eb5b..fcbea2a12 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -250,6 +250,17 @@ extension Ghostty { return String(cString: ptr) } + var macosWindowButtons: MacOSWindowButtons { + let defaultValue = MacOSWindowButtons.visible + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = nil + let key = "macos-window-buttons" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard let ptr = v else { return defaultValue } + let str = String(cString: ptr) + return MacOSWindowButtons(rawValue: str) ?? defaultValue + } + var macosTitlebarStyle: String { let defaultValue = "transparent" guard let config = self.config else { return defaultValue } @@ -495,6 +506,14 @@ extension Ghostty { return v; } + var undoTimeout: Duration { + guard let config = self.config else { return .seconds(5) } + var v: UInt = 0 + let key = "undo-timeout" + _ = ghostty_config_get(config, &v, key, UInt(key.count)) + return .milliseconds(v) + } + var autoUpdate: AutoUpdate? { guard let config = self.config else { return nil } var v: UnsafePointer? = nil @@ -555,6 +574,9 @@ extension Ghostty.Config { let rawValue: CUnsignedInt static let system = BellFeatures(rawValue: 1 << 0) + static let audio = BellFeatures(rawValue: 1 << 1) + static let attention = BellFeatures(rawValue: 1 << 2) + static let title = BellFeatures(rawValue: 1 << 3) } enum MacHidden : String { diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 0be579122..942ca5973 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -5,12 +5,6 @@ import GhosttyKit extension Ghostty { // MARK: Keyboard Shortcuts - /// Returns the SwiftUI KeyEquivalent for a given key. Note that not all keys known by - /// Ghostty have a macOS equivalent since macOS doesn't allow all keys as equivalents. - static func keyEquivalent(key: ghostty_input_key_e) -> KeyEquivalent? { - return Self.keyToEquivalent[key] - } - /// Return the key equivalent for the given trigger. /// /// Returns nil if the trigger doesn't have an equivalent KeyboardShortcut. This is possible @@ -22,16 +16,11 @@ extension Ghostty { static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? { let key: KeyEquivalent switch (trigger.tag) { - case GHOSTTY_TRIGGER_TRANSLATED: - if let v = Ghostty.keyEquivalent(key: trigger.key.translated) { - key = v - } else { - return nil - } - case GHOSTTY_TRIGGER_PHYSICAL: - if let v = Ghostty.keyEquivalent(key: trigger.key.physical) { - key = v + // Only functional keys can be converted to a KeyboardShortcut. Other physical + // mappings cannot because KeyboardShortcut in Swift is inherently layout-dependent. + if let equiv = Self.keyToEquivalent[trigger.key.physical] { + key = equiv } else { return nil } @@ -86,64 +75,11 @@ extension Ghostty { /// not all ghostty key enum values are represented here because not all of them can be /// mapped to a KeyEquivalent. static let keyToEquivalent: [ghostty_input_key_e : KeyEquivalent] = [ - // 0-9 - GHOSTTY_KEY_ZERO: "0", - GHOSTTY_KEY_ONE: "1", - GHOSTTY_KEY_TWO: "2", - GHOSTTY_KEY_THREE: "3", - GHOSTTY_KEY_FOUR: "4", - GHOSTTY_KEY_FIVE: "5", - GHOSTTY_KEY_SIX: "6", - GHOSTTY_KEY_SEVEN: "7", - GHOSTTY_KEY_EIGHT: "8", - GHOSTTY_KEY_NINE: "9", - - // a-z - GHOSTTY_KEY_A: "a", - GHOSTTY_KEY_B: "b", - GHOSTTY_KEY_C: "c", - GHOSTTY_KEY_D: "d", - GHOSTTY_KEY_E: "e", - GHOSTTY_KEY_F: "f", - GHOSTTY_KEY_G: "g", - GHOSTTY_KEY_H: "h", - GHOSTTY_KEY_I: "i", - GHOSTTY_KEY_J: "j", - GHOSTTY_KEY_K: "k", - GHOSTTY_KEY_L: "l", - GHOSTTY_KEY_M: "m", - GHOSTTY_KEY_N: "n", - GHOSTTY_KEY_O: "o", - GHOSTTY_KEY_P: "p", - GHOSTTY_KEY_Q: "q", - GHOSTTY_KEY_R: "r", - GHOSTTY_KEY_S: "s", - GHOSTTY_KEY_T: "t", - GHOSTTY_KEY_U: "u", - GHOSTTY_KEY_V: "v", - GHOSTTY_KEY_W: "w", - GHOSTTY_KEY_X: "x", - GHOSTTY_KEY_Y: "y", - GHOSTTY_KEY_Z: "z", - - // Symbols - GHOSTTY_KEY_APOSTROPHE: "'", - GHOSTTY_KEY_BACKSLASH: "\\", - GHOSTTY_KEY_COMMA: ",", - GHOSTTY_KEY_EQUAL: "=", - GHOSTTY_KEY_GRAVE_ACCENT: "`", - GHOSTTY_KEY_LEFT_BRACKET: "[", - GHOSTTY_KEY_MINUS: "-", - GHOSTTY_KEY_PERIOD: ".", - GHOSTTY_KEY_RIGHT_BRACKET: "]", - GHOSTTY_KEY_SEMICOLON: ";", - GHOSTTY_KEY_SLASH: "/", - // Function keys - GHOSTTY_KEY_UP: .upArrow, - GHOSTTY_KEY_DOWN: .downArrow, - GHOSTTY_KEY_LEFT: .leftArrow, - GHOSTTY_KEY_RIGHT: .rightArrow, + GHOSTTY_KEY_ARROW_UP: .upArrow, + GHOSTTY_KEY_ARROW_DOWN: .downArrow, + GHOSTTY_KEY_ARROW_LEFT: .leftArrow, + GHOSTTY_KEY_ARROW_RIGHT: .rightArrow, GHOSTTY_KEY_HOME: .home, GHOSTTY_KEY_END: .end, GHOSTTY_KEY_DELETE: .delete, @@ -153,104 +89,22 @@ extension Ghostty { GHOSTTY_KEY_ENTER: .return, GHOSTTY_KEY_TAB: .tab, GHOSTTY_KEY_BACKSPACE: .delete, - ] - - static let asciiToKey: [UInt8 : ghostty_input_key_e] = [ - // 0-9 - 0x30: GHOSTTY_KEY_ZERO, - 0x31: GHOSTTY_KEY_ONE, - 0x32: GHOSTTY_KEY_TWO, - 0x33: GHOSTTY_KEY_THREE, - 0x34: GHOSTTY_KEY_FOUR, - 0x35: GHOSTTY_KEY_FIVE, - 0x36: GHOSTTY_KEY_SIX, - 0x37: GHOSTTY_KEY_SEVEN, - 0x38: GHOSTTY_KEY_EIGHT, - 0x39: GHOSTTY_KEY_NINE, - - // A-Z - 0x41: GHOSTTY_KEY_A, - 0x42: GHOSTTY_KEY_B, - 0x43: GHOSTTY_KEY_C, - 0x44: GHOSTTY_KEY_D, - 0x45: GHOSTTY_KEY_E, - 0x46: GHOSTTY_KEY_F, - 0x47: GHOSTTY_KEY_G, - 0x48: GHOSTTY_KEY_H, - 0x49: GHOSTTY_KEY_I, - 0x4A: GHOSTTY_KEY_J, - 0x4B: GHOSTTY_KEY_K, - 0x4C: GHOSTTY_KEY_L, - 0x4D: GHOSTTY_KEY_M, - 0x4E: GHOSTTY_KEY_N, - 0x4F: GHOSTTY_KEY_O, - 0x50: GHOSTTY_KEY_P, - 0x51: GHOSTTY_KEY_Q, - 0x52: GHOSTTY_KEY_R, - 0x53: GHOSTTY_KEY_S, - 0x54: GHOSTTY_KEY_T, - 0x55: GHOSTTY_KEY_U, - 0x56: GHOSTTY_KEY_V, - 0x57: GHOSTTY_KEY_W, - 0x58: GHOSTTY_KEY_X, - 0x59: GHOSTTY_KEY_Y, - 0x5A: GHOSTTY_KEY_Z, - - // a-z - 0x61: GHOSTTY_KEY_A, - 0x62: GHOSTTY_KEY_B, - 0x63: GHOSTTY_KEY_C, - 0x64: GHOSTTY_KEY_D, - 0x65: GHOSTTY_KEY_E, - 0x66: GHOSTTY_KEY_F, - 0x67: GHOSTTY_KEY_G, - 0x68: GHOSTTY_KEY_H, - 0x69: GHOSTTY_KEY_I, - 0x6A: GHOSTTY_KEY_J, - 0x6B: GHOSTTY_KEY_K, - 0x6C: GHOSTTY_KEY_L, - 0x6D: GHOSTTY_KEY_M, - 0x6E: GHOSTTY_KEY_N, - 0x6F: GHOSTTY_KEY_O, - 0x70: GHOSTTY_KEY_P, - 0x71: GHOSTTY_KEY_Q, - 0x72: GHOSTTY_KEY_R, - 0x73: GHOSTTY_KEY_S, - 0x74: GHOSTTY_KEY_T, - 0x75: GHOSTTY_KEY_U, - 0x76: GHOSTTY_KEY_V, - 0x77: GHOSTTY_KEY_W, - 0x78: GHOSTTY_KEY_X, - 0x79: GHOSTTY_KEY_Y, - 0x7A: GHOSTTY_KEY_Z, - - // Symbols - 0x27: GHOSTTY_KEY_APOSTROPHE, - 0x5C: GHOSTTY_KEY_BACKSLASH, - 0x2C: GHOSTTY_KEY_COMMA, - 0x3D: GHOSTTY_KEY_EQUAL, - 0x60: GHOSTTY_KEY_GRAVE_ACCENT, - 0x5B: GHOSTTY_KEY_LEFT_BRACKET, - 0x2D: GHOSTTY_KEY_MINUS, - 0x2E: GHOSTTY_KEY_PERIOD, - 0x5D: GHOSTTY_KEY_RIGHT_BRACKET, - 0x3B: GHOSTTY_KEY_SEMICOLON, - 0x2F: GHOSTTY_KEY_SLASH, + GHOSTTY_KEY_SPACE: .space, ] // Mapping of event keyCode to ghostty input key values. This is cribbed from // glfw mostly since we started as a glfw-based app way back in the day! static let keycodeToKey: [UInt16 : ghostty_input_key_e] = [ - 0x1D: GHOSTTY_KEY_ZERO, - 0x12: GHOSTTY_KEY_ONE, - 0x13: GHOSTTY_KEY_TWO, - 0x14: GHOSTTY_KEY_THREE, - 0x15: GHOSTTY_KEY_FOUR, - 0x17: GHOSTTY_KEY_FIVE, - 0x16: GHOSTTY_KEY_SIX, - 0x1A: GHOSTTY_KEY_SEVEN, - 0x1C: GHOSTTY_KEY_EIGHT, - 0x19: GHOSTTY_KEY_NINE, + 0x1D: GHOSTTY_KEY_DIGIT_0, + 0x12: GHOSTTY_KEY_DIGIT_1, + 0x13: GHOSTTY_KEY_DIGIT_2, + 0x14: GHOSTTY_KEY_DIGIT_3, + 0x15: GHOSTTY_KEY_DIGIT_4, + 0x17: GHOSTTY_KEY_DIGIT_5, + 0x16: GHOSTTY_KEY_DIGIT_6, + 0x1A: GHOSTTY_KEY_DIGIT_7, + 0x1C: GHOSTTY_KEY_DIGIT_8, + 0x19: GHOSTTY_KEY_DIGIT_9, 0x00: GHOSTTY_KEY_A, 0x0B: GHOSTTY_KEY_B, 0x08: GHOSTTY_KEY_C, @@ -278,22 +132,22 @@ extension Ghostty { 0x10: GHOSTTY_KEY_Y, 0x06: GHOSTTY_KEY_Z, - 0x27: GHOSTTY_KEY_APOSTROPHE, + 0x27: GHOSTTY_KEY_QUOTE, 0x2A: GHOSTTY_KEY_BACKSLASH, 0x2B: GHOSTTY_KEY_COMMA, 0x18: GHOSTTY_KEY_EQUAL, - 0x32: GHOSTTY_KEY_GRAVE_ACCENT, - 0x21: GHOSTTY_KEY_LEFT_BRACKET, + 0x32: GHOSTTY_KEY_BACKQUOTE, + 0x21: GHOSTTY_KEY_BRACKET_LEFT, 0x1B: GHOSTTY_KEY_MINUS, 0x2F: GHOSTTY_KEY_PERIOD, - 0x1E: GHOSTTY_KEY_RIGHT_BRACKET, + 0x1E: GHOSTTY_KEY_BRACKET_RIGHT, 0x29: GHOSTTY_KEY_SEMICOLON, 0x2C: GHOSTTY_KEY_SLASH, 0x33: GHOSTTY_KEY_BACKSPACE, 0x39: GHOSTTY_KEY_CAPS_LOCK, 0x75: GHOSTTY_KEY_DELETE, - 0x7D: GHOSTTY_KEY_DOWN, + 0x7D: GHOSTTY_KEY_ARROW_DOWN, 0x77: GHOSTTY_KEY_END, 0x24: GHOSTTY_KEY_ENTER, 0x35: GHOSTTY_KEY_ESCAPE, @@ -319,39 +173,39 @@ extension Ghostty { 0x5A: GHOSTTY_KEY_F20, 0x73: GHOSTTY_KEY_HOME, 0x72: GHOSTTY_KEY_INSERT, - 0x7B: GHOSTTY_KEY_LEFT, - 0x3A: GHOSTTY_KEY_LEFT_ALT, - 0x3B: GHOSTTY_KEY_LEFT_CONTROL, - 0x38: GHOSTTY_KEY_LEFT_SHIFT, - 0x37: GHOSTTY_KEY_LEFT_SUPER, + 0x7B: GHOSTTY_KEY_ARROW_LEFT, + 0x3A: GHOSTTY_KEY_ALT_LEFT, + 0x3B: GHOSTTY_KEY_CONTROL_LEFT, + 0x38: GHOSTTY_KEY_SHIFT_LEFT, + 0x37: GHOSTTY_KEY_META_LEFT, 0x47: GHOSTTY_KEY_NUM_LOCK, 0x79: GHOSTTY_KEY_PAGE_DOWN, 0x74: GHOSTTY_KEY_PAGE_UP, - 0x7C: GHOSTTY_KEY_RIGHT, - 0x3D: GHOSTTY_KEY_RIGHT_ALT, - 0x3E: GHOSTTY_KEY_RIGHT_CONTROL, - 0x3C: GHOSTTY_KEY_RIGHT_SHIFT, - 0x36: GHOSTTY_KEY_RIGHT_SUPER, + 0x7C: GHOSTTY_KEY_ARROW_RIGHT, + 0x3D: GHOSTTY_KEY_ALT_RIGHT, + 0x3E: GHOSTTY_KEY_CONTROL_RIGHT, + 0x3C: GHOSTTY_KEY_SHIFT_RIGHT, + 0x36: GHOSTTY_KEY_META_RIGHT, 0x31: GHOSTTY_KEY_SPACE, 0x30: GHOSTTY_KEY_TAB, - 0x7E: GHOSTTY_KEY_UP, + 0x7E: GHOSTTY_KEY_ARROW_UP, - 0x52: GHOSTTY_KEY_KP_0, - 0x53: GHOSTTY_KEY_KP_1, - 0x54: GHOSTTY_KEY_KP_2, - 0x55: GHOSTTY_KEY_KP_3, - 0x56: GHOSTTY_KEY_KP_4, - 0x57: GHOSTTY_KEY_KP_5, - 0x58: GHOSTTY_KEY_KP_6, - 0x59: GHOSTTY_KEY_KP_7, - 0x5B: GHOSTTY_KEY_KP_8, - 0x5C: GHOSTTY_KEY_KP_9, - 0x45: GHOSTTY_KEY_KP_ADD, - 0x41: GHOSTTY_KEY_KP_DECIMAL, - 0x4B: GHOSTTY_KEY_KP_DIVIDE, - 0x4C: GHOSTTY_KEY_KP_ENTER, - 0x51: GHOSTTY_KEY_KP_EQUAL, - 0x43: GHOSTTY_KEY_KP_MULTIPLY, - 0x4E: GHOSTTY_KEY_KP_SUBTRACT, + 0x52: GHOSTTY_KEY_NUMPAD_0, + 0x53: GHOSTTY_KEY_NUMPAD_1, + 0x54: GHOSTTY_KEY_NUMPAD_2, + 0x55: GHOSTTY_KEY_NUMPAD_3, + 0x56: GHOSTTY_KEY_NUMPAD_4, + 0x57: GHOSTTY_KEY_NUMPAD_5, + 0x58: GHOSTTY_KEY_NUMPAD_6, + 0x59: GHOSTTY_KEY_NUMPAD_7, + 0x5B: GHOSTTY_KEY_NUMPAD_8, + 0x5C: GHOSTTY_KEY_NUMPAD_9, + 0x45: GHOSTTY_KEY_NUMPAD_ADD, + 0x41: GHOSTTY_KEY_NUMPAD_DECIMAL, + 0x4B: GHOSTTY_KEY_NUMPAD_DIVIDE, + 0x4C: GHOSTTY_KEY_NUMPAD_ENTER, + 0x51: GHOSTTY_KEY_NUMPAD_EQUAL, + 0x43: GHOSTTY_KEY_NUMPAD_MULTIPLY, + 0x4E: GHOSTTY_KEY_NUMPAD_SUBTRACT, ]; } diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift deleted file mode 100644 index 95c019b1f..000000000 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ /dev/null @@ -1,494 +0,0 @@ -import SwiftUI -import Combine -import GhosttyKit - -extension Ghostty { - /// This enum represents the possible states that a node in the split tree can be in. It is either: - /// - /// - noSplit - This is an unsplit, single pane. This contains only a "leaf" which has a single - /// terminal surface to render. - /// - horizontal/vertical - This is split into the horizontal or vertical direction. This contains a - /// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These - /// values can further be split infinitely. - /// - enum SplitNode: Equatable, Hashable, Codable, Sequence { - case leaf(Leaf) - case split(Container) - - /// The parent of this node. - var parent: Container? { - get { - switch (self) { - case .leaf(let leaf): - return leaf.parent - - case .split(let container): - return container.parent - } - } - - set { - switch (self) { - case .leaf(let leaf): - leaf.parent = newValue - - case .split(let container): - container.parent = newValue - } - } - } - - /// Returns true if the tree is split. - var isSplit: Bool { - return if case .leaf = self { - false - } else { - true - } - } - - func topLeft() -> SurfaceView { - switch (self) { - case .leaf(let leaf): - return leaf.surface - - case .split(let container): - return container.topLeft.topLeft() - } - } - - /// Returns the view that would prefer receiving focus in this tree. This is always the - /// top-left-most view. This is used when creating a split or closing a split to find the - /// next view to send focus to. - func preferredFocus(_ direction: SplitFocusDirection = .up) -> SurfaceView { - let container: Container - switch (self) { - case .leaf(let leaf): - // noSplit is easy because there is only one thing to focus - return leaf.surface - - case .split(let c): - container = c - } - - let node: SplitNode - switch (direction) { - case .previous, .up, .left: - node = container.bottomRight - - case .next, .down, .right: - node = container.topLeft - } - - return node.preferredFocus(direction) - } - - /// When direction is either next or previous, return the first or last - /// leaf. This can be used when the focus needs to move to a leaf even - /// after hitting the bottom-right-most or top-left-most surface. - /// When the direction is not next or previous (such as top, bottom, - /// left, right), it will be ignored and no leaf will be returned. - func firstOrLast(_ direction: SplitFocusDirection) -> Leaf? { - // If there is no parent, simply ignore. - guard let root = self.parent?.rootContainer() else { return nil } - - switch (direction) { - case .next: - return root.firstLeaf() - case .previous: - return root.lastLeaf() - default: - return nil - } - } - - /// Close the surface associated with this node. This will likely deinitialize the - /// surface. At this point, the surface view in this node tree can never be used again. - func close() { - switch (self) { - case .leaf(let leaf): - leaf.surface.close() - - case .split(let container): - container.topLeft.close() - container.bottomRight.close() - } - } - - /// Returns true if any surface in the split stack requires quit confirmation. - func needsConfirmQuit() -> Bool { - switch (self) { - case .leaf(let leaf): - return leaf.surface.needsConfirmQuit - - case .split(let container): - return container.topLeft.needsConfirmQuit() || - container.bottomRight.needsConfirmQuit() - } - } - - /// Returns true if the split tree contains the given view. - func contains(view: SurfaceView) -> Bool { - return leaf(for: view) != nil - } - - /// Find a surface view by UUID. - func findUUID(uuid: UUID) -> SurfaceView? { - switch (self) { - case .leaf(let leaf): - if (leaf.surface.uuid == uuid) { - return leaf.surface - } - - return nil - - case .split(let container): - return container.topLeft.findUUID(uuid: uuid) ?? - container.bottomRight.findUUID(uuid: uuid) - } - } - - /// Returns true if the surface borders the top. Assumes the view is in the tree. - func doesBorderTop(view: SurfaceView) -> Bool { - switch (self) { - case .leaf(let leaf): - return leaf.surface == view - - case .split(let container): - switch (container.direction) { - case .vertical: - return container.topLeft.doesBorderTop(view: view) - - case .horizontal: - return container.topLeft.doesBorderTop(view: view) || - container.bottomRight.doesBorderTop(view: view) - } - } - } - - /// Return the node for the given view if its in the tree. - func leaf(for view: SurfaceView) -> Leaf? { - switch (self) { - case .leaf(let leaf): - if leaf.surface == view { - return leaf - } else { - return nil - } - - case .split(let container): - return container.topLeft.leaf(for: view) ?? - container.bottomRight.leaf(for: view) - } - } - - // MARK: - Sequence - - func makeIterator() -> IndexingIterator<[Leaf]> { - return leaves().makeIterator() - } - - /// Return all the leaves in this split node. This isn't very efficient but our split trees are never super - /// deep so its not an issue. - private func leaves() -> [Leaf] { - switch (self) { - case .leaf(let leaf): - return [leaf] - - case .split(let container): - return container.topLeft.leaves() + container.bottomRight.leaves() - } - } - - // MARK: - Equatable - - static func == (lhs: SplitNode, rhs: SplitNode) -> Bool { - switch (lhs, rhs) { - case (.leaf(let lhs_v), .leaf(let rhs_v)): - return lhs_v === rhs_v - case (.split(let lhs_v), .split(let rhs_v)): - return lhs_v === rhs_v - default: - return false - } - } - - class Leaf: ObservableObject, Equatable, Hashable, Codable { - let app: ghostty_app_t - @Published var surface: SurfaceView - - weak var parent: SplitNode.Container? - - /// Initialize a new leaf which creates a new terminal surface. - init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { - self.app = app - self.surface = SurfaceView(app, baseConfig: baseConfig, uuid: uuid) - } - - // MARK: - Hashable - - func hash(into hasher: inout Hasher) { - hasher.combine(app) - hasher.combine(surface) - } - - // MARK: - Equatable - - static func == (lhs: Leaf, rhs: Leaf) -> Bool { - return lhs.app == rhs.app && lhs.surface === rhs.surface - } - - // MARK: - Codable - - enum CodingKeys: String, CodingKey { - case pwd - case uuid - } - - required convenience init(from decoder: Decoder) throws { - // Decoding uses the global Ghostty app - guard let del = NSApplication.shared.delegate, - let appDel = del as? AppDelegate, - let app = appDel.ghostty.app else { - throw TerminalRestoreError.delegateInvalid - } - - let container = try decoder.container(keyedBy: CodingKeys.self) - let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid)) - var config = SurfaceConfiguration() - config.workingDirectory = try container.decode(String?.self, forKey: .pwd) - - self.init(app, baseConfig: config, uuid: uuid) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(surface.pwd, forKey: .pwd) - try container.encode(surface.uuid.uuidString, forKey: .uuid) - } - } - - class Container: ObservableObject, Equatable, Hashable, Codable { - let app: ghostty_app_t - let direction: SplitViewDirection - - @Published var topLeft: SplitNode - @Published var bottomRight: SplitNode - @Published var split: CGFloat = 0.5 - - var resizeEvent: PassthroughSubject = .init() - - weak var parent: SplitNode.Container? - - /// A container is always initialized from some prior leaf because a split has to originate - /// from a non-split value. When initializing, we inherit the leaf's surface and then - /// initialize a new surface for the new pane. - init(from: Leaf, direction: SplitViewDirection, baseConfig: SurfaceConfiguration? = nil) { - self.app = from.app - self.direction = direction - self.parent = from.parent - - // Initially, both topLeft and bottomRight are in the "nosplit" - // state since this is a new split. - self.topLeft = .leaf(from) - - let bottomRight: Leaf = .init(app, baseConfig: baseConfig) - self.bottomRight = .leaf(bottomRight) - - from.parent = self - bottomRight.parent = self - } - - // Move the top left node to the bottom right and vice versa, - // preserving the size. - func swap() { - let topLeft: SplitNode = self.topLeft - self.topLeft = bottomRight - self.bottomRight = topLeft - self.split = 1 - self.split - } - - /// Resize the split by moving the split divider in the given - /// direction by the given amount. If this container is not split - /// in the given direction, navigate up the tree until we find a - /// container that is - func resize(direction: SplitResizeDirection, amount: UInt16) { - // We send a resize event to our publisher which will be - // received by the SplitView. - switch (self.direction) { - case .horizontal: - switch (direction) { - case .left: resizeEvent.send(-Double(amount)) - case .right: resizeEvent.send(Double(amount)) - default: parent?.resize(direction: direction, amount: amount) - } - case .vertical: - switch (direction) { - case .up: resizeEvent.send(-Double(amount)) - case .down: resizeEvent.send(Double(amount)) - default: parent?.resize(direction: direction, amount: amount) - } - } - } - - /// Equalize the splits in this container. Each split is equalized - /// based on its weight, i.e. the number of leaves it contains. - /// This function returns the weight of this container. - func equalize() -> UInt { - let topLeftWeight: UInt - switch (topLeft) { - case .leaf: - topLeftWeight = 1 - case .split(let c): - topLeftWeight = c.equalize() - } - - let bottomRightWeight: UInt - switch (bottomRight) { - case .leaf: - bottomRightWeight = 1 - case .split(let c): - bottomRightWeight = c.equalize() - } - - let weight = topLeftWeight + bottomRightWeight - split = Double(topLeftWeight) / Double(weight) - return weight - } - - /// Returns the top most parent, or this container. Because this - /// would fall back to use to self, the return value is guaranteed. - func rootContainer() -> Container { - guard let parent = self.parent else { return self } - return parent.rootContainer() - } - - /// Returns the first leaf from the given container. This is most - /// useful for root container, so that we can find the top-left-most - /// leaf. - func firstLeaf() -> Leaf { - switch (self.topLeft) { - case .leaf(let leaf): - return leaf - case .split(let s): - return s.firstLeaf() - } - } - - /// Returns the last leaf from the given container. This is most - /// useful for root container, so that we can find the bottom-right- - /// most leaf. - func lastLeaf() -> Leaf { - switch (self.bottomRight) { - case .leaf(let leaf): - return leaf - case .split(let s): - return s.lastLeaf() - } - } - - // MARK: - Hashable - - func hash(into hasher: inout Hasher) { - hasher.combine(app) - hasher.combine(direction) - hasher.combine(topLeft) - hasher.combine(bottomRight) - } - - // MARK: - Equatable - - static func == (lhs: Container, rhs: Container) -> Bool { - return lhs.app == rhs.app && - lhs.direction == rhs.direction && - lhs.topLeft == rhs.topLeft && - lhs.bottomRight == rhs.bottomRight - } - - // MARK: - Codable - - enum CodingKeys: String, CodingKey { - case direction - case split - case topLeft - case bottomRight - } - - required init(from decoder: Decoder) throws { - // Decoding uses the global Ghostty app - guard let del = NSApplication.shared.delegate, - let appDel = del as? AppDelegate, - let app = appDel.ghostty.app else { - throw TerminalRestoreError.delegateInvalid - } - - let container = try decoder.container(keyedBy: CodingKeys.self) - self.app = app - self.direction = try container.decode(SplitViewDirection.self, forKey: .direction) - self.split = try container.decode(CGFloat.self, forKey: .split) - self.topLeft = try container.decode(SplitNode.self, forKey: .topLeft) - self.bottomRight = try container.decode(SplitNode.self, forKey: .bottomRight) - - // Fix up the parent references - self.topLeft.parent = self - self.bottomRight.parent = self - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(direction, forKey: .direction) - try container.encode(split, forKey: .split) - try container.encode(topLeft, forKey: .topLeft) - try container.encode(bottomRight, forKey: .bottomRight) - } - } - - /// This keeps track of the "neighbors" of a split: the immediately above/below/left/right - /// nodes. This is purposely weak so we don't have to worry about memory management - /// with this (although, it should always be correct). - struct Neighbors { - var left: SplitNode? - var right: SplitNode? - var up: SplitNode? - var down: SplitNode? - - /// These are the previous/next nodes. It will certainly be one of the above as well - /// but we keep track of these separately because depending on the split direction - /// of the containing node, previous may be left OR up (same for next). - var previous: SplitNode? - var next: SplitNode? - - /// No neighbors, used by the root node. - static let empty: Self = .init() - - /// Get the node for a given direction. - func get(direction: SplitFocusDirection) -> SplitNode? { - let map: [SplitFocusDirection : KeyPath] = [ - .previous: \.previous, - .next: \.next, - .up: \.up, - .down: \.down, - .left: \.left, - .right: \.right, - ] - - guard let path = map[direction] else { return nil } - return self[keyPath: path] - } - - /// Update multiple keys and return a new copy. - func update(_ attrs: [WritableKeyPath: SplitNode?]) -> Self { - var clone = self - attrs.forEach { (key, value) in - clone[keyPath: key] = value - } - return clone - } - - /// True if there are no neighbors - func isEmpty() -> Bool { - return self.previous == nil && self.next == nil - } - } - } -} diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift deleted file mode 100644 index 3e942d774..000000000 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ /dev/null @@ -1,472 +0,0 @@ -import SwiftUI -import GhosttyKit - -extension Ghostty { - /// A spittable terminal view is one where the terminal allows for "splits" (vertical and horizontal) within the - /// view. The terminal starts in the unsplit state (a plain ol' TerminalView) but responds to changes to the - /// split direction by splitting the terminal. - /// - /// This also allows one split to be "zoomed" at any time. - struct TerminalSplit: View { - /// The current state of the root node. This can be set to nil when all surfaces are closed. - @Binding var node: SplitNode? - - /// Non-nil if one of the surfaces in the split tree is currently "zoomed." A zoomed surface - /// becomes "full screen" on the split tree. - @State private var zoomedSurface: SurfaceView? = nil - - var body: some View { - ZStack { - TerminalSplitRoot( - node: $node, - zoomedSurface: $zoomedSurface - ) - - // If we have a zoomed surface, we overlay that on top of our split - // root. Our split root will become clear when there is a zoomed - // surface. We need to keep the split root around so that we don't - // lose all of the surface state so this must be a ZStack. - if let surfaceView = zoomedSurface { - InspectableSurface(surfaceView: surfaceView) - } - } - .focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != nil) - } - } - - /// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever - /// one of these in a split tree. - private struct TerminalSplitRoot: View { - /// The root node that we're rendering. This will be set to nil if all the surfaces in this tree close. - @Binding var node: SplitNode? - - /// Keeps track of whether we're in a zoomed split state or not. If one of the splits we own - /// is in the zoomed state, we clear our body since we expect a zoomed split to overlay - /// this one. - @Binding var zoomedSurface: SurfaceView? - - var body: some View { - let center = NotificationCenter.default - let pubZoom = center.publisher(for: Notification.didToggleSplitZoom) - - // If we're zoomed, we don't render anything, we are transparent. This - // ensures that the View stays around so we don't lose our state, but - // also that the zoomed view on top can see through if background transparency - // is enabled. - if (zoomedSurface == nil) { - ZStack { - switch (node) { - case nil: - Color(.clear) - - case .leaf(let leaf): - TerminalSplitLeaf( - leaf: leaf, - neighbors: .empty, - node: $node - ) - - case .split(let container): - TerminalSplitContainer( - neighbors: .empty, - node: $node, - container: container - ) - .onReceive(pubZoom) { onZoom(notification: $0) } - } - } - .id(node) // Needed for change detection on node - } else { - // On these events we want to reset the split state and call it. - let pubSplit = center.publisher(for: Notification.ghosttyNewSplit, object: zoomedSurface!) - let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: zoomedSurface!) - let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: zoomedSurface!) - - ZStack {} - .onReceive(pubZoom) { onZoomReset(notification: $0) } - .onReceive(pubSplit) { onZoomReset(notification: $0) } - .onReceive(pubClose) { onZoomReset(notification: $0) } - .onReceive(pubFocus) { onZoomReset(notification: $0) } - } - } - - func onZoom(notification: SwiftUI.Notification) { - // Our node must be split to receive zooms. You can't zoom an unsplit terminal. - if case .leaf = node { - preconditionFailure("TerminalSplitRoom must not be zoom-able if no splits exist") - } - - // Make sure the notification has a surface and that this window owns the surface. - guard let surfaceView = notification.object as? SurfaceView else { return } - guard node?.contains(view: surfaceView) ?? false else { return } - - // We are in the zoomed state. - zoomedSurface = surfaceView - - // See onZoomReset, same logic. - DispatchQueue.main.async { Ghostty.moveFocus(to: surfaceView) } - } - - func onZoomReset(notification: SwiftUI.Notification) { - // Make sure the notification has a surface and that this window owns the surface. - guard let surfaceView = notification.object as? SurfaceView else { return } - guard zoomedSurface == surfaceView else { return } - - // We are now unzoomed - zoomedSurface = nil - - // We need to stay focused on this view, but the view is going to change - // superviews. We need to do this async so it happens on the next event loop - // tick. - DispatchQueue.main.async { - Ghostty.moveFocus(to: surfaceView) - - // If the notification is not a toggle zoom notification, we want to re-publish - // it after a short delay so that the split tree has a chance to re-establish - // so the proper view gets this notification. - if (notification.name != Notification.didToggleSplitZoom) { - // We have to wait ANOTHER tick since we just established. - DispatchQueue.main.async { - NotificationCenter.default.post(notification) - } - } - } - } - } - - /// A noSplit leaf node of a split tree. - private struct TerminalSplitLeaf: View { - /// The leaf to draw the surface for. - let leaf: SplitNode.Leaf - - /// The neighbors, used for navigation. - let neighbors: SplitNode.Neighbors - - /// The SplitNode that the leaf belongs to. This will be set to nil when leaf is closed. - @Binding var node: SplitNode? - - var body: some View { - let center = NotificationCenter.default - let pub = center.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface) - let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface) - let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: leaf.surface) - let pubResize = center.publisher(for: Notification.didResizeSplit, object: leaf.surface) - - InspectableSurface(surfaceView: leaf.surface, isSplit: !neighbors.isEmpty()) - .onReceive(pub) { onNewSplit(notification: $0) } - .onReceive(pubClose) { onClose(notification: $0) } - .onReceive(pubFocus) { onMoveFocus(notification: $0) } - .onReceive(pubResize) { onResize(notification: $0) } - } - - private func onClose(notification: SwiftUI.Notification) { - var processAlive = false - if let valueAny = notification.userInfo?["process_alive"] { - if let value = valueAny as? Bool { - processAlive = value - } - } - - // If the child process is not alive, then we exit immediately - guard processAlive else { - node = nil - return - } - - // If we don't have a window to attach our modal to, we also exit immediately. - // This should NOT happen. - guard let window = leaf.surface.window else { - node = nil - return - } - - // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog - // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that - // confirmationDialog allows the user to Cmd-W close the alert, but when doing - // so SwiftUI does not update any of the bindings to note that window is no longer - // being shown, and provides no callback to detect this. - let alert = NSAlert() - alert.messageText = "Close Terminal?" - alert.informativeText = "The terminal still has a running process. If you close the " + - "terminal the process will be killed." - alert.addButton(withTitle: "Close the Terminal") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window, completionHandler: { response in - switch (response) { - case .alertFirstButtonReturn: - alert.window.orderOut(nil) - node = nil - - default: - break - } - }) - } - - private func onNewSplit(notification: SwiftUI.Notification) { - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? SurfaceConfiguration - - // Determine our desired direction - guard let directionAny = notification.userInfo?["direction"] else { return } - guard let direction = directionAny as? ghostty_action_split_direction_e else { return } - let splitDirection: SplitViewDirection - let swap: Bool - switch (direction) { - case GHOSTTY_SPLIT_DIRECTION_RIGHT: - splitDirection = .horizontal - swap = false - case GHOSTTY_SPLIT_DIRECTION_LEFT: - splitDirection = .horizontal - swap = true - case GHOSTTY_SPLIT_DIRECTION_DOWN: - splitDirection = .vertical - swap = false - case GHOSTTY_SPLIT_DIRECTION_UP: - splitDirection = .vertical - swap = true - - default: - return - } - - // Setup our new container since we are now split - let container = SplitNode.Container(from: leaf, direction: splitDirection, baseConfig: config) - - // Change the parent node. This will trigger the parent to relayout our views. - node = .split(container) - - // See moveFocus comment, we have to run this whenever split changes. - Ghostty.moveFocus(to: container.bottomRight.preferredFocus(), from: node!.preferredFocus()) - - // If we are swapping, swap now. We do this after our focus event - // so that focus is in the right place. - if swap { - container.swap() - } - } - - /// This handles the event to move the split focus (i.e. previous/next) from a keyboard event. - private func onMoveFocus(notification: SwiftUI.Notification) { - // Determine our desired direction - guard let directionAny = notification.userInfo?[Notification.SplitDirectionKey] else { return } - guard let direction = directionAny as? SplitFocusDirection else { return } - - // Find the next surface to move to. In most cases this should be - // finding the neighbor in provided direction, and focus it. When - // the neighbor cannot be found based on next or previous direction, - // this would instead search for first or last leaf and focus it - // instead, giving the wrap around effect. - // When other directions are provided, this can be nil, and early - // returned. - guard let nextSurface = neighbors.get(direction: direction)?.preferredFocus(direction) - ?? node?.firstOrLast(direction)?.surface else { return } - - Ghostty.moveFocus( - to: nextSurface - ) - } - - /// Handle a resize event. - private func onResize(notification: SwiftUI.Notification) { - // If this leaf is not part of a split then there is nothing to do - guard let parent = leaf.parent else { return } - - guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return } - guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return } - - guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return } - guard let amount = amountAny as? UInt16 else { return } - - parent.resize(direction: direction, amount: amount) - } - } - - /// This represents a split view that is in the horizontal or vertical split state. - private struct TerminalSplitContainer: View { - @EnvironmentObject var ghostty: Ghostty.App - - let neighbors: SplitNode.Neighbors - @Binding var node: SplitNode? - @StateObject var container: SplitNode.Container - - var body: some View { - SplitView( - container.direction, - $container.split, - dividerColor: ghostty.config.splitDividerColor, - resizeIncrements: .init(width: 1, height: 1), - resizePublisher: container.resizeEvent, - left: { - let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.down - - TerminalSplitNested( - node: closeableTopLeft(), - neighbors: neighbors.update([ - neighborKey: container.bottomRight, - \.next: container.bottomRight, - ]) - ) - }, right: { - let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.left : \.up - - TerminalSplitNested( - node: closeableBottomRight(), - neighbors: neighbors.update([ - neighborKey: container.topLeft, - \.previous: container.topLeft, - ]) - ) - }) - } - - private func closeableTopLeft() -> Binding { - return .init(get: { - container.topLeft - }, set: { newValue in - if let newValue { - container.topLeft = newValue - return - } - - // Closing - container.topLeft.close() - node = container.bottomRight - - switch (node) { - case .leaf(let l): - l.parent = container.parent - case .split(let c): - c.parent = container.parent - case .none: - break - } - - DispatchQueue.main.async { - Ghostty.moveFocus( - to: container.bottomRight.preferredFocus(), - from: container.topLeft.preferredFocus() - ) - } - }) - } - - private func closeableBottomRight() -> Binding { - return .init(get: { - container.bottomRight - }, set: { newValue in - if let newValue { - container.bottomRight = newValue - return - } - - // Closing - container.bottomRight.close() - node = container.topLeft - - switch (node) { - case .leaf(let l): - l.parent = container.parent - case .split(let c): - c.parent = container.parent - case .none: - break - } - - DispatchQueue.main.async { - Ghostty.moveFocus( - to: container.topLeft.preferredFocus(), - from: container.bottomRight.preferredFocus() - ) - } - }) - } - } - - - /// This is like TerminalSplitRoot, but... not the root. This renders a SplitNode in any state but - /// requires there be a binding to the parent node. - private struct TerminalSplitNested: View { - @Binding var node: SplitNode? - let neighbors: SplitNode.Neighbors - - var body: some View { - Group { - switch (node) { - case nil: - Color(.clear) - - case .leaf(let leaf): - TerminalSplitLeaf( - leaf: leaf, - neighbors: neighbors, - node: $node - ) - - case .split(let container): - TerminalSplitContainer( - neighbors: neighbors, - node: $node, - container: container - ) - } - } - .id(node) - } - } - - /// When changing the split state, or going full screen (native or non), the terminal view - /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't - /// figure it out so we're going to do this hacky thing to bring focus back to the terminal - /// that should have it. - static func moveFocus( - to: SurfaceView, - from: SurfaceView? = nil, - delay: TimeInterval? = nil - ) { - // The whole delay machinery is a bit of a hack to work around a - // situation where the window is destroyed and the surface view - // will never be attached to a window. Realistically, we should - // handle this upstream but we also don't want this function to be - // a source of infinite loops. - - // Our max delay before we give up - let maxDelay: TimeInterval = 0.5 - guard (delay ?? 0) < maxDelay else { return } - - // We start at a 50 millisecond delay and do a doubling backoff - let nextDelay: TimeInterval = if let delay { - delay * 2 - } else { - // 100 milliseconds - 0.05 - } - - let work: DispatchWorkItem = .init { - // If the callback runs before the surface is attached to a view - // then the window will be nil. We just reschedule in that case. - guard let window = to.window else { - moveFocus(to: to, from: from, delay: nextDelay) - return - } - - // If we had a previously focused node and its not where we're sending - // focus, make sure that we explicitly tell it to lose focus. In theory - // we should NOT have to do this but the focus callback isn't getting - // called for some reason. - if let from = from { - _ = from.resignFirstResponder() - } - - window.makeFirstResponder(to) - } - - let queue = DispatchQueue.main - if let delay { - queue.asyncAfter(deadline: .now() + delay, execute: work) - } else { - queue.async(execute: work) - } - } -} diff --git a/macos/Sources/Ghostty/NSEvent+Extension.swift b/macos/Sources/Ghostty/NSEvent+Extension.swift index 058e7aace..b67c1932e 100644 --- a/macos/Sources/Ghostty/NSEvent+Extension.swift +++ b/macos/Sources/Ghostty/NSEvent+Extension.swift @@ -56,15 +56,22 @@ extension NSEvent { // If we have no characters associated with this event we do nothing. guard let characters else { return nil } - // If we have a single control character, then we return the characters - // without control pressed. We do this because we handle control character - // encoding directly within Ghostty's KeyEncoder. if characters.count == 1, - let scalar = characters.unicodeScalars.first, - scalar.value < 0x20 { - return self.characters(byApplyingModifiers: modifierFlags.subtracting(.control)) + let scalar = characters.unicodeScalars.first { + // If we have a single control character, then we return the characters + // without control pressed. We do this because we handle control character + // encoding directly within Ghostty's KeyEncoder. + if scalar.value < 0x20 { + return self.characters(byApplyingModifiers: modifierFlags.subtracting(.control)) + } + + // If we have a single value in the PUA, then it's a function key and + // we don't want to send PUA ranges down to Ghostty. + if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { + return nil + } } - return nil + return characters } } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 30d5573df..82721c17e 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -239,6 +239,12 @@ extension Ghostty { case chrome } + /// Enum for the macos-window-buttons config option + enum MacOSWindowButtons: String { + case visible + case hidden + } + /// Enum for the macos-titlebar-proxy-icon config option enum MacOSTitlebarProxyIcon: String { case visible diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 1e9a4cfef..f830da4ef 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -59,7 +59,7 @@ extension Ghostty { var title: String { var result = surfaceView.title - if (surfaceView.bell) { + if (surfaceView.bell && ghostty.config.bellFeatures.contains(.title)) { result = "🔔 \(result)" } @@ -301,8 +301,12 @@ extension Ghostty { if let instant = focusInstant { let d = instant.duration(to: ContinuousClock.now) if (d < .milliseconds(500)) { - // Avoid this size completely. - lastSize = geoSize + // Avoid this size completely. We can't set values during + // view updates so we have to defer this to another tick. + DispatchQueue.main.async { + lastSize = geoSize + } + return true; } } @@ -460,6 +464,62 @@ extension Ghostty { return config } } + + #if canImport(AppKit) + /// When changing the split state, or going full screen (native or non), the terminal view + /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't + /// figure it out so we're going to do this hacky thing to bring focus back to the terminal + /// that should have it. + static func moveFocus( + to: SurfaceView, + from: SurfaceView? = nil, + delay: TimeInterval? = nil + ) { + // The whole delay machinery is a bit of a hack to work around a + // situation where the window is destroyed and the surface view + // will never be attached to a window. Realistically, we should + // handle this upstream but we also don't want this function to be + // a source of infinite loops. + + // Our max delay before we give up + let maxDelay: TimeInterval = 0.5 + guard (delay ?? 0) < maxDelay else { return } + + // We start at a 50 millisecond delay and do a doubling backoff + let nextDelay: TimeInterval = if let delay { + delay * 2 + } else { + // 100 milliseconds + 0.05 + } + + let work: DispatchWorkItem = .init { + // If the callback runs before the surface is attached to a view + // then the window will be nil. We just reschedule in that case. + guard let window = to.window else { + moveFocus(to: to, from: from, delay: nextDelay) + return + } + + // If we had a previously focused node and its not where we're sending + // focus, make sure that we explicitly tell it to lose focus. In theory + // we should NOT have to do this but the focus callback isn't getting + // called for some reason. + if let from = from { + _ = from.resignFirstResponder() + } + + window.makeFirstResponder(to) + } + + let queue = DispatchQueue.main + if let delay { + queue.asyncAfter(deadline: .now() + delay, execute: work) + } else { + queue.async(execute: work) + } + } + #endif } // MARK: Surface Environment Keys @@ -502,15 +562,6 @@ extension FocusedValues { typealias Value = String } - var ghosttySurfaceZoomed: Bool? { - get { self[FocusedGhosttySurfaceZoomed.self] } - set { self[FocusedGhosttySurfaceZoomed.self] = newValue } - } - - struct FocusedGhosttySurfaceZoomed: FocusedValueKey { - typealias Value = Bool - } - var ghosttySurfaceCellSize: OSSize? { get { self[FocusedGhosttySurfaceCellSize.self] } set { self[FocusedGhosttySurfaceCellSize.self] = newValue } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 921c32c8b..3e87176fc 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -6,7 +6,7 @@ import GhosttyKit extension Ghostty { /// The NSView implementation for a terminal surface. - class SurfaceView: OSView, ObservableObject { + class SurfaceView: OSView, ObservableObject, Codable { /// Unique ID per surface let uuid: UUID @@ -92,6 +92,12 @@ extension Ghostty { return ghostty_surface_needs_confirm_quit(surface) } + // Returns true if the process in this surface has exited. + var processExited: Bool { + guard let surface = self.surface else { return true } + return ghostty_surface_process_exited(surface) + } + // Returns the inspector instance for this surface, or nil if the // surface has been closed. var inspector: ghostty_inspector_t? { @@ -279,22 +285,14 @@ extension Ghostty { // Remove ourselves from secure input if we have to SecureInput.shared.removeScoped(ObjectIdentifier(self)) - guard let surface = self.surface else { return } - ghostty_surface_free(surface) - } - - /// Close the surface early. This will free the associated Ghostty surface and the view will - /// no longer render. The view can never be used again. This is a way for us to free the - /// Ghostty resources while references may still be held to this view. I've found that SwiftUI - /// tends to hold this view longer than it should so we free the expensive stuff explicitly. - func close() { // Remove any notifications associated with this surface let identifiers = Array(self.notificationIdentifiers) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) - guard let surface = self.surface else { return } - ghostty_surface_free(surface) - self.surface = nil + // Free our core surface resources + if let surface = self.surface { + ghostty_surface_free(surface) + } } func focusDidChange(_ focused: Bool) { @@ -314,6 +312,14 @@ extension Ghostty { // We unset our bell state if we gained focus bell = false + + // Remove any notifications for this surface once we gain focus. + if !notificationIdentifiers.isEmpty { + UNUserNotificationCenter.current() + .removeDeliveredNotifications( + withIdentifiers: Array(notificationIdentifiers)) + self.notificationIdentifiers = [] + } } } @@ -1043,12 +1049,16 @@ extension Ghostty { } // If this event as-is would result in a key binding then we send it. - if let surface, - ghostty_surface_key_is_binding( - surface, - event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { - self.keyDown(with: event) - return true + if let surface { + var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) + let match = (event.characters ?? "").withCString { ptr in + ghosttyEvent.text = ptr + return ghostty_surface_key_is_binding(surface, ghosttyEvent) + } + if match { + self.keyDown(with: event) + return true + } } let equivalent: String @@ -1062,6 +1072,16 @@ extension Ghostty { equivalent = "\r" + case "/": + // Treat C-/ as C-_. We do this because C-/ makes macOS make a beep + // sound and we don't like the beep sound. + if (!event.modifierFlags.contains(.control) || + !event.modifierFlags.isDisjoint(with: [.shift, .command, .option])) { + return false + } + + equivalent = "_" + default: // It looks like some part of AppKit sometimes generates synthetic NSEvents // with a zero timestamp. We never process these at this point. Concretely, @@ -1261,6 +1281,10 @@ extension Ghostty { let menu = NSMenu() + // We just use a floating var so we can easily setup metadata on each item + // in a row without storing it all. + var item: NSMenuItem + // If we have a selection, add copy if self.selectedRange().length > 0 { menu.addItem(withTitle: "Copy", action: #selector(copy(_:)), keyEquivalent: "") @@ -1268,16 +1292,23 @@ extension Ghostty { menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "") menu.addItem(.separator()) - menu.addItem(withTitle: "Split Right", action: #selector(splitRight(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Split Left", action: #selector(splitLeft(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Split Down", action: #selector(splitDown(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Split Up", action: #selector(splitUp(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Split Right", action: #selector(splitRight(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled") + item = menu.addItem(withTitle: "Split Left", action: #selector(splitLeft(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled") + item = menu.addItem(withTitle: "Split Down", action: #selector(splitDown(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled") + item = menu.addItem(withTitle: "Split Up", action: #selector(splitUp(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled") menu.addItem(.separator()) - menu.addItem(withTitle: "Reset Terminal", action: #selector(resetTerminal(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Reset Terminal", action: #selector(resetTerminal(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise") + item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "scope") menu.addItem(.separator()) - menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "pencil.line") return menu } @@ -1382,13 +1413,29 @@ extension Ghostty { trigger: nil ) - UNUserNotificationCenter.current().add(request) { error in + // Note the callback may be executed on a background thread as documented + // so we need @MainActor since we're reading/writing view state. + UNUserNotificationCenter.current().add(request) { @MainActor error in if let error = error { AppDelegate.logger.error("Error scheduling user notification: \(error)") return } + // We need to keep track of this notification so we can remove it + // under certain circumstances self.notificationIdentifiers.insert(uuid) + + // If we're focused then we schedule to remove the notification + // after a few seconds. If we gain focus we automatically remove it + // in focusDidChange. + if (self.focused) { + Task { @MainActor [weak self] in + try await Task.sleep(for: .seconds(3)) + self?.notificationIdentifiers.remove(uuid) + UNUserNotificationCenter.current() + .removeDeliveredNotifications(withIdentifiers: [uuid]) + } + } } } @@ -1425,6 +1472,35 @@ extension Ghostty { self.windowAppearance = .init(ghosttyConfig: config) } } + + // MARK: - Codable + + enum CodingKeys: String, CodingKey { + case pwd + case uuid + } + + required convenience init(from decoder: Decoder) throws { + // Decoding uses the global Ghostty app + guard let del = NSApplication.shared.delegate, + let appDel = del as? AppDelegate, + let app = appDel.ghostty.app else { + throw TerminalRestoreError.delegateInvalid + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid)) + var config = Ghostty.SurfaceConfiguration() + config.workingDirectory = try container.decode(String?.self, forKey: .pwd) + + self.init(app, baseConfig: config, uuid: uuid) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(pwd, forKey: .pwd) + try container.encode(uuid.uuidString, forKey: .uuid) + } } } diff --git a/macos/Sources/Helpers/AppInfo.swift b/macos/Sources/Helpers/AppInfo.swift new file mode 100644 index 000000000..cf66e332d --- /dev/null +++ b/macos/Sources/Helpers/AppInfo.swift @@ -0,0 +1,44 @@ +import Foundation + +/// True if we appear to be running in Xcode. +func isRunningInXcode() -> Bool { + if let _ = ProcessInfo.processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] { + return true + } + + return false +} + +/// True if we have liquid glass available. +func hasLiquidGlass() -> Bool { + // Can't have liquid glass unless we're in macOS 26+ + if #unavailable(macOS 26.0) { + return false + } + + // If we aren't running SDK 26.0 or later then we definitely + // do not have liquid glass. + guard let sdkName = Bundle.main.infoDictionary?["DTSDKName"] as? String else { + // If we don't have this, we assume we're built against the latest + // since we're on macOS 26+ + return true + } + + // If the SDK doesn't start with macosx then we just assume we + // have it because we already verified we're on macOS above. + guard sdkName.hasPrefix("macosx") else { + return true + } + + // The SDK version must be at least 26 + let versionString = String(sdkName.dropFirst("macosx".count)) + guard let major = if let dotIndex = versionString.firstIndex(of: ".") { + Int(String(versionString[..= 26 +} diff --git a/macos/Sources/Helpers/ExpiringUndoManager.swift b/macos/Sources/Helpers/ExpiringUndoManager.swift new file mode 100644 index 000000000..5fde0e870 --- /dev/null +++ b/macos/Sources/Helpers/ExpiringUndoManager.swift @@ -0,0 +1,148 @@ +/// An UndoManager subclass that supports registering undo operations that automatically expire after a specified duration. +/// +/// This class extends the standard UndoManager to add time-based expiration for undo operations. +/// When an undo operation expires, it is automatically removed from the undo stack and cannot be invoked. +/// +/// Example usage: +/// ```swift +/// let undoManager = ExpiringUndoManager() +/// undoManager.registerUndo(withTarget: myObject, expiresAfter: .seconds(30)) { target in +/// // Undo operation that expires after 30 seconds +/// target.restorePreviousState() +/// } +/// ``` +class ExpiringUndoManager: UndoManager { + /// The set of expiring targets so we can properly clean them up when removeAllActions + /// is called with the real target. + private lazy var expiringTargets: Set = [] + + /// Registers an undo operation that automatically expires after the specified duration. + /// + /// - Parameters: + /// - target: The target object for the undo operation. The undo operation will be removed + /// if this object is deallocated before the operation is invoked. + /// - duration: The duration after which the undo operation should expire and be removed from the undo stack. + /// - handler: The closure to execute when the undo operation is invoked. The closure receives + /// the target object as its parameter. + func registerUndo( + withTarget target: TargetType, + expiresAfter duration: Duration, + handler: @escaping (TargetType) -> Void + ) { + // Ignore instantly expiring undos + guard duration.timeInterval > 0 else { return } + + // Ignore when undo registration is disabled. UndoManager still lets + // registration happen then cancels later but I was seeing some + // weird behavior with this so let's just guard on it. + guard self.isUndoRegistrationEnabled else { return } + + let expiringTarget = ExpiringTarget( + target, + expiresAfter: duration, + in: self) + expiringTargets.insert(expiringTarget) + + super.registerUndo(withTarget: expiringTarget) { [weak self] expiringTarget in + self?.expiringTargets.remove(expiringTarget) + guard let target = expiringTarget.target as? TargetType else { return } + handler(target) + } + } + + /// Removes all undo and redo operations from the undo manager. + /// + /// This override ensures that all expiring targets are also cleared when + /// the undo manager is reset. + override func removeAllActions() { + super.removeAllActions() + expiringTargets = [] + } + + /// Removes all undo and redo operations involving the specified target. + /// + /// This override ensures that when actions are removed for a target, any associated + /// expiring targets are also properly cleaned up. + /// + /// - Parameter target: The target object whose actions should be removed. + override func removeAllActions(withTarget target: Any) { + // Call super to handle standard removal + super.removeAllActions(withTarget: target) + + // If the target is an expiring target, remove it. + if let expiring = target as? ExpiringTarget { + expiringTargets.remove(expiring) + } else { + // Find and remove any ExpiringTarget instances that wrap this target. + expiringTargets + .filter { $0.target == nil || $0.target === (target as AnyObject) } + .forEach { + // Technically they'll always expire when they get deinitialized + // but we want to make sure it happens right now. + $0.expire() + expiringTargets.remove($0) + } + } + } +} + +/// A target object for ExpiringUndoManager that removes itself from the +/// undo manager after it expires. +/// +/// This class acts as a proxy for the real target object in undo operations. +/// It holds a weak reference to the actual target and automatically removes +/// all associated undo operations when either: +/// - The specified duration expires +/// - The ExpiringTarget instance is deallocated +/// - The expire() method is called manually +private class ExpiringTarget { + /// The actual target object for the undo operation, held weakly to avoid retain cycles. + private(set) weak var target: AnyObject? + + /// Timer that triggers expiration after the specified duration. + private var timer: Timer? + + /// The undo manager from which to remove actions when this target expires. + private weak var undoManager: UndoManager? + + /// Creates an expiring target that will automatically remove undo actions after the specified duration. + /// + /// - Parameters: + /// - target: The target object to hold weakly. + /// - duration: The time after which the target should expire. + /// - undoManager: The UndoManager from which to remove actions when expired. + init(_ target: AnyObject? = nil, expiresAfter duration: Duration, in undoManager: UndoManager) { + self.target = target + self.undoManager = undoManager + self.timer = Timer.scheduledTimer( + withTimeInterval: duration.timeInterval, + repeats: false) { [weak self] _ in + self?.expire() + } + } + + /// Manually expires the target, removing all associated undo actions and invalidating the timer. + /// + /// This method is called automatically when the timer fires, but can also be called manually + /// to expire the target before the timer duration has elapsed. + func expire() { + target = nil + undoManager?.removeAllActions(withTarget: self) + timer?.invalidate() + timer = nil + } + + deinit { + expire() + } +} + +extension ExpiringTarget: Hashable, Equatable { + static func == (lhs: ExpiringTarget, rhs: ExpiringTarget) -> Bool { + return lhs === rhs + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} diff --git a/macos/Sources/Helpers/Extensions/Array+Extension.swift b/macos/Sources/Helpers/Extensions/Array+Extension.swift new file mode 100644 index 000000000..12f2de43d --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Array+Extension.swift @@ -0,0 +1,23 @@ +extension Array { + subscript(safe index: Int) -> Element? { + return indices.contains(index) ? self[index] : nil + } + + /// Returns the index before i, with wraparound. Assumes i is a valid index. + func indexWrapping(before i: Int) -> Int { + if i == 0 { + return count - 1 + } + + return i - 1 + } + + /// Returns the index after i, with wraparound. Assumes i is a valid index. + func indexWrapping(after i: Int) -> Int { + if i == count - 1 { + return 0 + } + + return i + 1 + } +} diff --git a/macos/Sources/Helpers/Extensions/Double+Extension.swift b/macos/Sources/Helpers/Extensions/Double+Extension.swift new file mode 100644 index 000000000..8d1151bac --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Double+Extension.swift @@ -0,0 +1,5 @@ +extension Double { + func clamped(to range: ClosedRange) -> Double { + return Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/macos/Sources/Helpers/Extensions/Duration+Extension.swift b/macos/Sources/Helpers/Extensions/Duration+Extension.swift new file mode 100644 index 000000000..43eca6b79 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Duration+Extension.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Duration { + var timeInterval: TimeInterval { + return TimeInterval(self.components.seconds) + + TimeInterval(self.components.attoseconds) / 1_000_000_000_000_000_000 + } +} diff --git a/macos/Sources/Helpers/EventModifiers+Extension.swift b/macos/Sources/Helpers/Extensions/EventModifiers+Extension.swift similarity index 100% rename from macos/Sources/Helpers/EventModifiers+Extension.swift rename to macos/Sources/Helpers/Extensions/EventModifiers+Extension.swift diff --git a/macos/Sources/Helpers/KeyboardShortcut+Extension.swift b/macos/Sources/Helpers/Extensions/KeyboardShortcut+Extension.swift similarity index 100% rename from macos/Sources/Helpers/KeyboardShortcut+Extension.swift rename to macos/Sources/Helpers/Extensions/KeyboardShortcut+Extension.swift diff --git a/macos/Sources/Helpers/NSAppearance+Extension.swift b/macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSAppearance+Extension.swift rename to macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift diff --git a/macos/Sources/Helpers/NSApplication+Extension.swift b/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift similarity index 99% rename from macos/Sources/Helpers/NSApplication+Extension.swift rename to macos/Sources/Helpers/Extensions/NSApplication+Extension.swift index d8e41523a..0bc79fb6a 100644 --- a/macos/Sources/Helpers/NSApplication+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift @@ -1,3 +1,4 @@ +import AppKit import Cocoa // MARK: Presentation Options diff --git a/macos/Sources/Helpers/NSImage+Extension.swift b/macos/Sources/Helpers/Extensions/NSImage+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSImage+Extension.swift rename to macos/Sources/Helpers/Extensions/NSImage+Extension.swift diff --git a/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift new file mode 100644 index 000000000..e512904ef --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift @@ -0,0 +1,11 @@ +import AppKit + +extension NSMenuItem { + /// Sets the image property from a symbol if we want images on our menu items. + func setImageIfDesired(systemSymbolName symbol: String) { + // We only set on macOS 26 when icons on menu items became the norm. + if #available(macOS 26, *) { + image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) + } + } +} diff --git a/macos/Sources/Helpers/NSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSPasteboard+Extension.swift rename to macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift diff --git a/macos/Sources/Helpers/NSScreen+Extension.swift b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSScreen+Extension.swift rename to macos/Sources/Helpers/Extensions/NSScreen+Extension.swift diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift new file mode 100644 index 000000000..b3628d406 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -0,0 +1,202 @@ +import AppKit + +extension NSView { + /// Returns true if this view is currently in the responder chain + var isInResponderChain: Bool { + var responder = window?.firstResponder + while let currentResponder = responder { + if currentResponder === self { + return true + } + responder = currentResponder.nextResponder + } + + return false + } +} + +// MARK: View Traversal and Search + +extension NSView { + /// Returns the absolute root view by walking up the superview chain. + var rootView: NSView { + var root: NSView = self + while let superview = root.superview { + root = superview + } + return root + } + + /// Checks if a view contains another view in its hierarchy. + func contains(_ view: NSView) -> Bool { + if self == view { + return true + } + + for subview in subviews { + if subview.contains(view) { + return true + } + } + + return false + } + + /// Checks if the view contains the given class in its hierarchy. + func contains(className name: String) -> Bool { + if String(describing: type(of: self)) == name { + return true + } + + for subview in subviews { + if subview.contains(className: name) { + return true + } + } + + return false + } + + /// Finds the superview with the given class name. + func firstSuperview(withClassName name: String) -> NSView? { + guard let superview else { return nil } + if String(describing: type(of: superview)) == name { + return superview + } + + return superview.firstSuperview(withClassName: name) + } + + /// Recursively finds and returns the first descendant view that has the given class name. + func firstDescendant(withClassName name: String) -> NSView? { + for subview in subviews { + if String(describing: type(of: subview)) == name { + return subview + } else if let found = subview.firstDescendant(withClassName: name) { + return found + } + } + + return nil + } + + /// Recursively finds and returns descendant views that have the given class name. + func descendants(withClassName name: String) -> [NSView] { + var result = [NSView]() + + for subview in subviews { + if String(describing: type(of: subview)) == name { + result.append(subview) + } + + result += subview.descendants(withClassName: name) + } + + return result + } + + /// Recursively finds and returns the first descendant view that has the given identifier. + func firstDescendant(withID id: String) -> NSView? { + for subview in subviews { + if subview.identifier == NSUserInterfaceItemIdentifier(id) { + return subview + } else if let found = subview.firstDescendant(withID: id) { + return found + } + } + + return nil + } + + /// Finds and returns the first view with the given class name starting from the absolute root of the view hierarchy. + /// This includes private views like title bar views. + func firstViewFromRoot(withClassName name: String) -> NSView? { + let root = rootView + + // Check if the root view itself matches + if String(describing: type(of: root)) == name { + return root + } + + // Otherwise search descendants + return root.firstDescendant(withClassName: name) + } +} + +// MARK: Debug + +extension NSView { + /// Prints the view hierarchy from the root in a tree-like ASCII format. + /// + /// I need this because the "Capture View Hierarchy" was broken under some scenarios in + /// Xcode 26 (FB17912569). But, I kept it around because it might be useful to print out + /// the view hierarchy without halting the program. + func printViewHierarchy() { + let root = rootView + print("View Hierarchy from Root:") + print(root.viewHierarchyDescription()) + } + + /// Returns a string representation of the view hierarchy in a tree-like format. + func viewHierarchyDescription(indent: String = "", isLast: Bool = true) -> String { + var result = "" + + // Add the tree branch characters + result += indent + if !indent.isEmpty { + result += isLast ? "└── " : "├── " + } + + // Add the class name and optional identifier + let className = String(describing: type(of: self)) + result += className + + // Add identifier if present + if let identifier = self.identifier { + result += " (id: \(identifier.rawValue))" + } + + // Add frame info + result += " [frame: \(frame)]" + + // Add visual properties + var properties: [String] = [] + + // Hidden status + if isHidden { + properties.append("hidden") + } + + // Opaque status + properties.append(isOpaque ? "opaque" : "transparent") + + // Layer backing + if wantsLayer { + properties.append("layer-backed") + if let bgColor = layer?.backgroundColor { + let color = NSColor(cgColor: bgColor) + if let rgb = color?.usingColorSpace(.deviceRGB) { + properties.append(String(format: "bg:rgba(%.0f,%.0f,%.0f,%.2f)", + rgb.redComponent * 255, + rgb.greenComponent * 255, + rgb.blueComponent * 255, + rgb.alphaComponent)) + } else { + properties.append("bg:\(bgColor)") + } + } + } + + result += " [\(properties.joined(separator: ", "))]" + result += "\n" + + // Process subviews + for (index, subview) in subviews.enumerated() { + let isLastSubview = index == subviews.count - 1 + let newIndent = indent + (isLast ? " " : "│ ") + result += subview.viewHierarchyDescription(indent: newIndent, isLast: isLastSubview) + } + + return result + } +} diff --git a/macos/Sources/Helpers/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSWindow+Extension.swift rename to macos/Sources/Helpers/Extensions/NSWindow+Extension.swift diff --git a/macos/Sources/Helpers/OSColor+Extension.swift b/macos/Sources/Helpers/Extensions/OSColor+Extension.swift similarity index 100% rename from macos/Sources/Helpers/OSColor+Extension.swift rename to macos/Sources/Helpers/Extensions/OSColor+Extension.swift diff --git a/macos/Sources/Helpers/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift similarity index 100% rename from macos/Sources/Helpers/String+Extension.swift rename to macos/Sources/Helpers/Extensions/String+Extension.swift diff --git a/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift b/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift new file mode 100644 index 000000000..6c7c1e9f1 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift @@ -0,0 +1,20 @@ +import Foundation + +extension UndoManager { + /// A Boolean value that indicates whether the undo manager is currently performing + /// either an undo or redo operation. + var isUndoingOrRedoing: Bool { + isUndoing || isRedoing + } + + /// Temporarily disables undo registration while executing the provided handler. + /// + /// This method provides a convenient way to perform operations without recording them + /// in the undo stack. It ensures that undo registration is properly re-enabled even + /// if the handler throws an error. + func disableUndoRegistration(handler: () -> Void) { + disableUndoRegistration() + handler() + enableUndoRegistration() + } +} diff --git a/macos/Sources/Helpers/View+Extension.swift b/macos/Sources/Helpers/Extensions/View+Extension.swift similarity index 100% rename from macos/Sources/Helpers/View+Extension.swift rename to macos/Sources/Helpers/Extensions/View+Extension.swift diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 6094bf844..f3940a9aa 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -45,10 +45,6 @@ protocol FullscreenDelegate: AnyObject { func fullscreenDidChange() } -extension FullscreenDelegate { - func fullscreenDidChange() {} -} - /// The base class for fullscreen implementations, cannot be used as a FullscreenStyle on its own. class FullscreenBase { let window: NSWindow @@ -78,10 +74,12 @@ class FullscreenBase { } @objc private func didEnterFullScreenNotification(_ notification: Notification) { + NotificationCenter.default.post(name: .fullscreenDidEnter, object: self) delegate?.fullscreenDidChange() } @objc private func didExitFullScreenNotification(_ notification: Notification) { + NotificationCenter.default.post(name: .fullscreenDidExit, object: self) delegate?.fullscreenDidChange() } } @@ -150,6 +148,26 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { private var savedState: SavedState? + required init?(_ window: NSWindow) { + super.init(window) + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowWillCloseNotification), + name: NSWindow.willCloseNotification, + object: window) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func windowWillCloseNotification(_ notification: Notification) { + // When the window closes we need to explicitly exit non-native fullscreen + // otherwise some state like the menu bar can remain hidden. + exit() + } + func enter() { // If we are in fullscreen we don't do it again. guard !isFullscreen else { return } @@ -218,6 +236,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.window.makeFirstResponder(firstResponder) } + NotificationCenter.default.post(name: .fullscreenDidEnter, object: self) self.delegate?.fullscreenDidChange() } } @@ -246,13 +265,24 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { window.styleMask = savedState.styleMask window.setFrame(window.frameRect(forContentRect: savedState.contentFrame), display: true) - // This is a hack that I want to remove from this but for now, we need to - // fix up the titlebar tabs here before we do everything below. - if let window = window as? TerminalWindow, - window.titlebarTabs { - window.titlebarTabs = true + // Removing the "titled" style also derefs all our accessory view controllers + // so we need to restore those. + for c in savedState.titlebarAccessoryViewControllers { + // Restoring the tab bar causes all sorts of problems. Its best to just ignore it, + // even though this is kind of a hack. + if let window = window as? TerminalWindow, window.isTabBar(c) { + continue + } + + if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil { + window.addTitlebarAccessoryViewController(c) + } } + // Removing "titled" also clears our toolbar + window.toolbar = savedState.toolbar + window.toolbarStyle = savedState.toolbarStyle + // If the window was previously in a tab group that isn't empty now, // we re-add it. We have to do this because our process of doing non-native // fullscreen removes the window from the tab group. @@ -283,6 +313,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { window.makeKeyAndOrderFront(nil) // Notify the delegate + NotificationCenter.default.post(name: .fullscreenDidExit, object: self) self.delegate?.fullscreenDidChange() } @@ -360,6 +391,9 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { let tabGroupIndex: Int? let contentFrame: NSRect let styleMask: NSWindow.StyleMask + let toolbar: NSToolbar? + let toolbarStyle: NSWindow.ToolbarStyle + let titlebarAccessoryViewControllers: [NSTitlebarAccessoryViewController] let dock: Bool let menu: Bool @@ -371,6 +405,9 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.tabGroupIndex = window.tabGroup?.windows.firstIndex(of: window) self.contentFrame = window.convertToScreen(contentView.frame) self.styleMask = window.styleMask + self.toolbar = window.toolbar + self.toolbarStyle = window.toolbarStyle + self.titlebarAccessoryViewControllers = window.titlebarAccessoryViewControllers self.dock = window.screen?.hasDock ?? false if let cgWindowId = window.cgWindowId { @@ -402,3 +439,8 @@ class NonNativeFullscreenVisibleMenu: NonNativeFullscreen { class NonNativeFullscreenPaddedNotch: NonNativeFullscreen { override var properties: Properties { Properties(paddedNotch: true) } } + +extension Notification.Name { + static let fullscreenDidEnter = Notification.Name("com.mitchellh.fullscreenDidEnter") + static let fullscreenDidExit = Notification.Name("com.mitchellh.fullscreenDidExit") +} diff --git a/macos/Sources/Helpers/NSView+Extension.swift b/macos/Sources/Helpers/NSView+Extension.swift deleted file mode 100644 index b9234a49a..000000000 --- a/macos/Sources/Helpers/NSView+Extension.swift +++ /dev/null @@ -1,44 +0,0 @@ -import AppKit - -extension NSView { - /// Recursively finds and returns the first descendant view that has the given class name. - func firstDescendant(withClassName name: String) -> NSView? { - for subview in subviews { - if String(describing: type(of: subview)) == name { - return subview - } else if let found = subview.firstDescendant(withClassName: name) { - return found - } - } - - return nil - } - - /// Recursively finds and returns descendant views that have the given class name. - func descendants(withClassName name: String) -> [NSView] { - var result = [NSView]() - - for subview in subviews { - if String(describing: type(of: subview)) == name { - result.append(subview) - } - - result += subview.descendants(withClassName: name) - } - - return result - } - - /// Recursively finds and returns the first descendant view that has the given identifier. - func firstDescendant(withID id: String) -> NSView? { - for subview in subviews { - if subview.identifier == NSUserInterfaceItemIdentifier(id) { - return subview - } else if let found = subview.firstDescendant(withID: id) { - return found - } - } - - return nil - } -} diff --git a/macos/Sources/Helpers/Xcode.swift b/macos/Sources/Helpers/Xcode.swift deleted file mode 100644 index 281bad18b..000000000 --- a/macos/Sources/Helpers/Xcode.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -/// True if we appear to be running in Xcode. -func isRunningInXcode() -> Bool { - if let _ = ProcessInfo.processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] { - return true - } - - return false -} diff --git a/nix/build-support/build-inputs.nix b/nix/build-support/build-inputs.nix index 5886cfe30..7c9258675 100644 --- a/nix/build-support/build-inputs.nix +++ b/nix/build-support/build-inputs.nix @@ -28,6 +28,9 @@ pkgs.glib pkgs.gobject-introspection pkgs.gsettings-desktop-schemas + pkgs.gst_all_1.gst-plugins-base + pkgs.gst_all_1.gst-plugins-good + pkgs.gst_all_1.gstreamer pkgs.gtk4 pkgs.libadwaita ] diff --git a/nix/devShell.nix b/nix/devShell.nix index 498102ef4..f4ea62235 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -16,7 +16,7 @@ python3, qemu, scdoc, - snapcraft, + # snapcraft, valgrind, #, vulkan-loader # unused vttest, @@ -35,6 +35,7 @@ gtk4, gtk4-layer-shell, gobject-introspection, + gst_all_1, libadwaita, blueprint-compiler, gettext, @@ -133,7 +134,7 @@ in appstream flatpak-builder gdb - snapcraft + # snapcraft valgrind wraptest @@ -166,6 +167,9 @@ in wayland wayland-scanner wayland-protocols + gst_all_1.gstreamer + gst_all_1.gst-plugins-base + gst_all_1.gst-plugins-good ]; # This should be set onto the rpath of the ghostty binary if you want diff --git a/nix/package.nix b/nix/package.nix index 9368b2cde..08dfd710b 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -36,6 +36,7 @@ buildInputs = import ./build-support/build-inputs.nix { inherit pkgs lib stdenv enableX11 enableWayland; }; + strip = optimize != "Debug" && optimize != "ReleaseSafe"; in stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; @@ -87,6 +88,7 @@ in buildInputs = buildInputs; dontConfigure = true; + dontStrip = !strip; GI_TYPELIB_PATH = gi_typelib_path; @@ -96,6 +98,7 @@ in "-Dversion-string=${finalAttrs.version}-${revision}-nix" "-Dgtk-x11=${lib.boolToString enableX11}" "-Dgtk-wayland=${lib.boolToString enableWayland}" + "-Dstrip=${lib.boolToString strip}" ]; outputs = [ @@ -127,6 +130,10 @@ in mv $out/share/vim/vimfiles "$vim" ln -sf "$vim" "$out/share/vim/vimfiles" echo "$vim" >> "$out/nix-support/propagated-user-env-packages" + + echo "gst_all_1.gstreamer" >> "$out/nix-support/propagated-user-env-packages" + echo "gst_all_1.gst-plugins-base" >> "$out/nix-support/propagated-user-env-packages" + echo "gst_all_1.gst-plugins-good" >> "$out/nix-support/propagated-user-env-packages" ''; meta = { diff --git a/pkg/README.md b/pkg/README.md index 1d6f9f6eb..fddc4b3db 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -12,7 +12,7 @@ paste them into your project. the Ghostty project. This license does not apply to the rest of the Ghostty project.** -Copyright © 2024 Mitchell Hashimoto +Copyright © 2024 Mitchell Hashimoto, Ghostty contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index 1be733dd6..18a6c0968 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -7,12 +7,17 @@ pub fn build(b: *std.Build) !void { _ = optimize; } -/// Add the SDK framework, include, and library paths to the given module. -/// The module target is used to determine the SDK to use so it must have -/// a resolved target. -pub fn addPaths(b: *std.Build, m: *std.Build.Module) !void { +/// Setup the step to point to the proper Apple SDK for libc and +/// frameworks. This expects and relies on the native SDK being +/// installed on the system. Ghostty doesn't support cross-compilation +/// for Apple platforms. +pub fn addPaths( + b: *std.Build, + step: *std.Build.Step.Compile, +) !void { // The cache. This always uses b.allocator and never frees memory - // (which is idiomatic for a Zig build exe). + // (which is idiomatic for a Zig build exe). We cache the libc txt + // file we create because it is expensive to generate (subprocesses). const Cache = struct { const Key = struct { arch: std.Target.Cpu.Arch, @@ -20,27 +25,72 @@ pub fn addPaths(b: *std.Build, m: *std.Build.Module) !void { abi: std.Target.Abi, }; - var map: std.AutoHashMapUnmanaged(Key, ?[]const u8) = .{}; + var map: std.AutoHashMapUnmanaged(Key, ?struct { + libc: std.Build.LazyPath, + framework: []const u8, + system_include: []const u8, + library: []const u8, + }) = .{}; }; - const target = m.resolved_target.?.result; + const target = step.rootModuleTarget(); const gop = try Cache.map.getOrPut(b.allocator, .{ .arch = target.cpu.arch, .os = target.os.tag, .abi = target.abi, }); - // This executes `xcrun` to get the SDK path. We don't want to execute - // this multiple times so we cache the value. if (!gop.found_existing) { - gop.value_ptr.* = std.zig.system.darwin.getSdk( - b.allocator, - m.resolved_target.?.result, - ); + // Detect our SDK using the "findNative" Zig stdlib function. + // This is really important because it forces using `xcrun` to + // find the SDK path. + const libc = try std.zig.LibCInstallation.findNative(.{ + .allocator = b.allocator, + .target = step.rootModuleTarget(), + .verbose = false, + }); + + // Render the file compatible with the `--libc` Zig flag. + var list: std.ArrayList(u8) = .init(b.allocator); + defer list.deinit(); + try libc.render(list.writer()); + + // Create a temporary file to store the libc path because + // `--libc` expects a file path. + const wf = b.addWriteFiles(); + const path = wf.add("libc.txt", list.items); + + // Determine our framework path. Zig has a bug where it doesn't + // parse this from the libc txt file for `-framework` flags: + // https://github.com/ziglang/zig/issues/24024 + const framework_path = framework: { + const down1 = std.fs.path.dirname(libc.sys_include_dir.?).?; + const down2 = std.fs.path.dirname(down1).?; + break :framework try std.fs.path.join(b.allocator, &.{ + down2, + "System", + "Library", + "Frameworks", + }); + }; + + const library_path = library: { + const down1 = std.fs.path.dirname(libc.sys_include_dir.?).?; + break :library try std.fs.path.join(b.allocator, &.{ + down1, + "lib", + }); + }; + + gop.value_ptr.* = .{ + .libc = path, + .framework = framework_path, + .system_include = libc.sys_include_dir.?, + .library = library_path, + }; } - // The active SDK we want to use - const path = gop.value_ptr.* orelse return switch (target.os.tag) { + const value = gop.value_ptr.* orelse return switch (target.os.tag) { // Return a more descriptive error. Before we just returned the // generic error but this was confusing a lot of community members. // It costs us nothing in the build script to return something better. @@ -50,7 +100,12 @@ pub fn addPaths(b: *std.Build, m: *std.Build.Module) !void { .watchos => error.XcodeWatchOSSDKNotFound, else => error.XcodeAppleSDKNotFound, }; - m.addSystemFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/System/Library/Frameworks" }) }); - m.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/include" }) }); - m.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/lib" }) }); + + step.setLibCFile(value.libc); + + // This is only necessary until this bug is fixed: + // https://github.com/ziglang/zig/issues/24024 + step.root_module.addSystemFrameworkPath(.{ .cwd_relative = value.framework }); + step.root_module.addSystemIncludePath(.{ .cwd_relative = value.system_include }); + step.root_module.addLibraryPath(.{ .cwd_relative = value.library }); } diff --git a/pkg/breakpad/build.zig b/pkg/breakpad/build.zig index e2fdec7ad..42247b12c 100644 --- a/pkg/breakpad/build.zig +++ b/pkg/breakpad/build.zig @@ -13,7 +13,7 @@ pub fn build(b: *std.Build) !void { lib.addIncludePath(b.path("vendor")); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/cimgui/build.zig b/pkg/cimgui/build.zig index c76b53966..3ca735383 100644 --- a/pkg/cimgui/build.zig +++ b/pkg/cimgui/build.zig @@ -84,8 +84,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { if (!target.query.isNative()) { - try @import("apple_sdk").addPaths(b, lib.root_module); - try @import("apple_sdk").addPaths(b, module); + try @import("apple_sdk").addPaths(b, lib); } lib.addCSourceFile(.{ .file = imgui.path("backends/imgui_impl_metal.mm"), diff --git a/pkg/fontconfig/pattern.zig b/pkg/fontconfig/pattern.zig index e0ec27a69..3a623e223 100644 --- a/pkg/fontconfig/pattern.zig +++ b/pkg/fontconfig/pattern.zig @@ -44,7 +44,7 @@ pub const Pattern = opaque { &val, ))).toError(); - return Value.init(&val); + return .init(&val); } pub fn delete(self: *Pattern, prop: Property) bool { @@ -138,7 +138,7 @@ pub const Pattern = opaque { return Entry{ .result = @enumFromInt(result), .binding = @enumFromInt(binding), - .value = Value.init(&value), + .value = .init(&value), }; } }; diff --git a/pkg/freetype/build.zig b/pkg/freetype/build.zig index bfe27e5aa..e9f72210a 100644 --- a/pkg/freetype/build.zig +++ b/pkg/freetype/build.zig @@ -69,7 +69,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu lib.linkLibC(); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/glfw/LICENSE b/pkg/glfw/LICENSE index eeeb852fe..8c422bd23 100644 --- a/pkg/glfw/LICENSE +++ b/pkg/glfw/LICENSE @@ -1,5 +1,5 @@ Copyright (c) 2021 Hexops Contributors (given via the Git commit history). -Copyright (c) 2025 Mitchell Hashimoto +Copyright (c) 2025 Mitchell Hashimoto, Ghostty contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/pkg/glfw/Monitor.zig b/pkg/glfw/Monitor.zig index 4accb23cd..3b194965a 100644 --- a/pkg/glfw/Monitor.zig +++ b/pkg/glfw/Monitor.zig @@ -281,7 +281,7 @@ pub inline fn setGamma(self: Monitor, gamma: f32) void { /// see also: monitor_gamma pub inline fn getGammaRamp(self: Monitor) ?GammaRamp { internal_debug.assertInitialized(); - if (c.glfwGetGammaRamp(self.handle)) |ramp| return GammaRamp.fromC(ramp.*); + if (c.glfwGetGammaRamp(self.handle)) |ramp| return .fromC(ramp.*); return null; } diff --git a/pkg/glfw/build.zig b/pkg/glfw/build.zig index cc61f18b2..142a558da 100644 --- a/pkg/glfw/build.zig +++ b/pkg/glfw/build.zig @@ -24,7 +24,7 @@ pub fn build(b: *std.Build) !void { .optimize = optimize, }); if (target.result.os.tag.isDarwin()) { - try apple_sdk.addPaths(b, exe.root_module); + try apple_sdk.addPaths(b, exe); } const tests_run = b.addRunArtifact(exe); @@ -122,8 +122,7 @@ fn buildLib( }, .macos => { - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); // Transitive dependencies, explicit linkage of these works around // ziglang/zig#17130 diff --git a/pkg/glfw/opengl.zig b/pkg/glfw/opengl.zig index 04bc3a65c..8fe2efbed 100644 --- a/pkg/glfw/opengl.zig +++ b/pkg/glfw/opengl.zig @@ -47,7 +47,7 @@ pub inline fn makeContextCurrent(window: ?Window) void { /// see also: context_current, glfwMakeContextCurrent pub inline fn getCurrentContext() ?Window { internal_debug.assertInitialized(); - if (c.glfwGetCurrentContext()) |handle| return Window.from(handle); + if (c.glfwGetCurrentContext()) |handle| return .from(handle); return null; } diff --git a/pkg/glslang/build.zig b/pkg/glslang/build.zig index 629490aa4..747216a39 100644 --- a/pkg/glslang/build.zig +++ b/pkg/glslang/build.zig @@ -16,10 +16,6 @@ pub fn build(b: *std.Build) !void { module.addIncludePath(upstream.path("")); module.addIncludePath(b.path("override")); - if (target.result.os.tag.isDarwin()) { - const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, module); - } if (target.query.isNative()) { const test_exe = b.addTest(.{ @@ -55,7 +51,7 @@ fn buildGlslang( lib.addIncludePath(b.path("override")); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/gtk4-layer-shell/src/main.zig b/pkg/gtk4-layer-shell/src/main.zig index 88d99772b..06936bba2 100644 --- a/pkg/gtk4-layer-shell/src/main.zig +++ b/pkg/gtk4-layer-shell/src/main.zig @@ -27,6 +27,10 @@ pub fn isSupported() bool { return c.gtk_layer_is_supported() != 0; } +pub fn getProtocolVersion() c_uint { + return c.gtk_layer_get_protocol_version(); +} + pub fn initForWindow(window: *gtk.Window) void { c.gtk_layer_init_for_window(@ptrCast(window)); } @@ -46,3 +50,7 @@ pub fn setMargin(window: *gtk.Window, edge: ShellEdge, margin_size: c_int) void pub fn setKeyboardMode(window: *gtk.Window, mode: KeyboardMode) void { c.gtk_layer_set_keyboard_mode(@ptrCast(window), @intFromEnum(mode)); } + +pub fn setNamespace(window: *gtk.Window, name: [:0]const u8) void { + c.gtk_layer_set_namespace(@ptrCast(window), name.ptr); +} diff --git a/pkg/harfbuzz/build.zig b/pkg/harfbuzz/build.zig index d0dd6d01c..3bdc30a32 100644 --- a/pkg/harfbuzz/build.zig +++ b/pkg/harfbuzz/build.zig @@ -93,8 +93,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu lib.linkLibCpp(); if (target.result.os.tag.isDarwin()) { - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); } const dynamic_link_opts = options.dynamic_link_opts; diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index c72ca355f..5036316da 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -23,8 +23,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/libintl/build.zig b/pkg/libintl/build.zig index 53eb67f16..1baed195a 100644 --- a/pkg/libintl/build.zig +++ b/pkg/libintl/build.zig @@ -40,7 +40,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } if (b.lazyDependency("gettext", .{})) |upstream| { diff --git a/pkg/libpng/build.zig b/pkg/libpng/build.zig index d012f2712..8729398f8 100644 --- a/pkg/libpng/build.zig +++ b/pkg/libpng/build.zig @@ -15,7 +15,7 @@ pub fn build(b: *std.Build) !void { } if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } // For dynamic linking, we prefer dynamic linking and to search by diff --git a/pkg/macos/build.zig b/pkg/macos/build.zig index 911664a2f..df76da9b4 100644 --- a/pkg/macos/build.zig +++ b/pkg/macos/build.zig @@ -45,8 +45,7 @@ pub fn build(b: *std.Build) !void { module.linkFramework("CoreVideo", .{}); module.linkFramework("QuartzCore", .{}); - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); } b.installArtifact(lib); @@ -58,7 +57,7 @@ pub fn build(b: *std.Build) !void { .optimize = optimize, }); if (target.result.os.tag.isDarwin()) { - try apple_sdk.addPaths(b, test_exe.root_module); + try apple_sdk.addPaths(b, test_exe); } test_exe.linkLibrary(lib); diff --git a/pkg/oniguruma/build.zig b/pkg/oniguruma/build.zig index 1c93bbf9a..c23d744df 100644 --- a/pkg/oniguruma/build.zig +++ b/pkg/oniguruma/build.zig @@ -67,7 +67,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } if (b.lazyDependency("oniguruma", .{})) |upstream| { diff --git a/pkg/opengl/Texture.zig b/pkg/opengl/Texture.zig index 5804ef538..fa5cf770b 100644 --- a/pkg/opengl/Texture.zig +++ b/pkg/opengl/Texture.zig @@ -70,6 +70,9 @@ pub const InternalFormat = enum(c_int) { rgb = c.GL_RGB, rgba = c.GL_RGBA, + srgb = c.GL_SRGB, + srgba = c.GL_SRGB_ALPHA, + // There are so many more that I haven't filled in. _, }; diff --git a/pkg/sentry/build.zig b/pkg/sentry/build.zig index 3c0019710..0e6993ad4 100644 --- a/pkg/sentry/build.zig +++ b/pkg/sentry/build.zig @@ -20,8 +20,7 @@ pub fn build(b: *std.Build) !void { lib.linkLibC(); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/simdutf/build.zig b/pkg/simdutf/build.zig index 859653443..30de40fea 100644 --- a/pkg/simdutf/build.zig +++ b/pkg/simdutf/build.zig @@ -14,7 +14,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/spirv-cross/build.zig b/pkg/spirv-cross/build.zig index c7d0d2039..ff67e3e72 100644 --- a/pkg/spirv-cross/build.zig +++ b/pkg/spirv-cross/build.zig @@ -44,7 +44,7 @@ fn buildSpirvCross( lib.linkLibCpp(); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/utfcpp/build.zig b/pkg/utfcpp/build.zig index 6b80fec7b..8e1a3cb20 100644 --- a/pkg/utfcpp/build.zig +++ b/pkg/utfcpp/build.zig @@ -13,7 +13,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/wuffs/build.zig b/pkg/wuffs/build.zig index d47771c22..4d144e76a 100644 --- a/pkg/wuffs/build.zig +++ b/pkg/wuffs/build.zig @@ -11,11 +11,6 @@ pub fn build(b: *std.Build) !void { .link_libc = true, }); - if (target.result.os.tag.isDarwin()) { - const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, module); - } - const unit_tests = b.addTest(.{ .root_source_file = b.path("src/main.zig"), .target = target, diff --git a/pkg/zlib/build.zig b/pkg/zlib/build.zig index 28ae62424..28344c989 100644 --- a/pkg/zlib/build.zig +++ b/pkg/zlib/build.zig @@ -12,7 +12,7 @@ pub fn build(b: *std.Build) !void { lib.linkLibC(); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } if (b.lazyDependency("zlib", .{})) |upstream| { diff --git a/po/ca_ES.UTF-8.po b/po/ca_ES.UTF-8.po index 712f0d5af..653439fa2 100644 --- a/po/ca_ES.UTF-8.po +++ b/po/ca_ES.UTF-8.po @@ -1,5 +1,5 @@ # Catalan translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Francesc Arpi , 2025. # diff --git a/po/com.mitchellh.ghostty.pot b/po/com.mitchellh.ghostty.pot index 3892d14d8..da0efbbee 100644 --- a/po/com.mitchellh.ghostty.pot +++ b/po/com.mitchellh.ghostty.pot @@ -1,5 +1,5 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR Mitchell Hashimoto +# Copyright (C) YEAR Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # FIRST AUTHOR , YEAR. # @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2025-04-22 08:57-0700\n" +"POT-Creation-Date: 2025-04-23 16:58+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -50,7 +50,7 @@ msgstr "" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100 msgid "Reload Configuration" msgstr "" @@ -78,6 +78,10 @@ msgstr "" msgid "Split Right" msgstr "" +#: src/apprt/gtk/ui/1.5/command-palette.blp:16 +msgid "Execute a command…" +msgstr "" + #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 msgid "Copy" @@ -115,7 +119,7 @@ msgstr "" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 -#: src/apprt/gtk/Window.zig:248 +#: src/apprt/gtk/Window.zig:255 msgid "New Tab" msgstr "" @@ -143,20 +147,24 @@ msgid "Config" msgstr "" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 msgid "Open Configuration" msgstr "" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 +msgid "Command Palette" +msgstr "" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 msgid "Terminal Inspector" msgstr "" -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:102 -#: src/apprt/gtk/Window.zig:1003 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/Window.zig:1024 msgid "About Ghostty" msgstr "" -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112 msgid "Quit" msgstr "" @@ -197,31 +205,35 @@ msgid "" "commands may be executed." msgstr "" -#: src/apprt/gtk/Window.zig:201 +#: src/apprt/gtk/Window.zig:208 msgid "Main Menu" msgstr "" -#: src/apprt/gtk/Window.zig:222 +#: src/apprt/gtk/Window.zig:229 msgid "View Open Tabs" msgstr "" -#: src/apprt/gtk/Window.zig:249 +#: src/apprt/gtk/Window.zig:256 msgid "New Split" msgstr "" -#: src/apprt/gtk/Window.zig:312 +#: src/apprt/gtk/Window.zig:319 msgid "" "⚠️ You're running a debug build of Ghostty! Performance will be degraded." msgstr "" -#: src/apprt/gtk/Window.zig:744 +#: src/apprt/gtk/Window.zig:765 msgid "Reloaded the configuration" msgstr "" -#: src/apprt/gtk/Window.zig:984 +#: src/apprt/gtk/Window.zig:1005 msgid "Ghostty Developers" msgstr "" +#: src/apprt/gtk/inspector.zig:144 +msgid "Ghostty: Terminal Inspector" +msgstr "" + #: src/apprt/gtk/CloseDialog.zig:47 msgid "Close" msgstr "" @@ -261,7 +273,3 @@ msgstr "" #: src/apprt/gtk/Surface.zig:1243 msgid "Copied to clipboard" msgstr "" - -#: src/apprt/gtk/inspector.zig:144 -msgid "Ghostty: Terminal Inspector" -msgstr "" diff --git a/po/de_DE.UTF-8.po b/po/de_DE.UTF-8.po index 44f3bae39..2d3b96d81 100644 --- a/po/de_DE.UTF-8.po +++ b/po/de_DE.UTF-8.po @@ -1,6 +1,6 @@ # German translations for com.mitchellh.ghostty package # German translation for com.mitchellh.ghostty. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Robin Pfäffle , 2025. # diff --git a/po/es_BO.UTF-8.po b/po/es_BO.UTF-8.po index f3a62748a..077b7dfa1 100644 --- a/po/es_BO.UTF-8.po +++ b/po/es_BO.UTF-8.po @@ -1,5 +1,5 @@ # Spanish translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Miguel Peredo , 2025. # diff --git a/po/fr_FR.UTF-8.po b/po/fr_FR.UTF-8.po index 4db72a23e..aef0d96ac 100644 --- a/po/fr_FR.UTF-8.po +++ b/po/fr_FR.UTF-8.po @@ -1,5 +1,5 @@ # French translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Kirwiisp , 2025. # diff --git a/po/id_ID.UTF-8.po b/po/id_ID.UTF-8.po index d5204d420..f82ec6197 100644 --- a/po/id_ID.UTF-8.po +++ b/po/id_ID.UTF-8.po @@ -1,5 +1,5 @@ # Indonesian translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Satrio Bayu Aji , 2025. # diff --git a/po/ja_JP.UTF-8.po b/po/ja_JP.UTF-8.po index e6e015f8a..73ddd9f5a 100644 --- a/po/ja_JP.UTF-8.po +++ b/po/ja_JP.UTF-8.po @@ -1,6 +1,6 @@ # Japanese translations for com.mitchellh.ghostty package # com.mitchellh.ghostty パッケージに対する和訳. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Lon Sagisawa , 2025. # diff --git a/po/mk_MK.UTF-8.po b/po/mk_MK.UTF-8.po index 39bb72b91..20a43572e 100644 --- a/po/mk_MK.UTF-8.po +++ b/po/mk_MK.UTF-8.po @@ -1,5 +1,5 @@ # Macedonian translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Andrej Daskalov , 2025. # diff --git a/po/nb_NO.UTF-8.po b/po/nb_NO.UTF-8.po index ad76eea3d..045d47a80 100644 --- a/po/nb_NO.UTF-8.po +++ b/po/nb_NO.UTF-8.po @@ -1,5 +1,5 @@ # Norwegian Bokmal translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Hanna Rose , 2025. # Uzair Aftab , 2025. @@ -63,25 +63,25 @@ msgstr "Last konfigurasjon på nytt" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:38 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:50 msgid "Split Up" -msgstr "Splitt opp" +msgstr "Del oppover" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:11 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:43 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:55 msgid "Split Down" -msgstr "Splitt ned" +msgstr "Del nedover" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:16 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:48 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:60 msgid "Split Left" -msgstr "Splitt venstre" +msgstr "Del til venstre" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:21 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:53 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:65 msgid "Split Right" -msgstr "Splitt høyre" +msgstr "Del til høyre" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 @@ -107,7 +107,7 @@ msgstr "Nullstill" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:30 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:42 msgid "Split" -msgstr "Splitt" +msgstr "Del vindu" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:33 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:45 @@ -218,7 +218,7 @@ msgstr "Se åpne faner" #: src/apprt/gtk/Window.zig:249 msgid "New Split" -msgstr "" +msgstr "Del opp vindu" #: src/apprt/gtk/Window.zig:312 msgid "" @@ -251,7 +251,7 @@ msgstr "Lukk fane?" #: src/apprt/gtk/CloseDialog.zig:90 msgid "Close Split?" -msgstr "Lukk splitt?" +msgstr "Lukk delt vindu?" #: src/apprt/gtk/CloseDialog.zig:96 msgid "All terminal sessions will be terminated." diff --git a/po/nl_NL.UTF-8.po b/po/nl_NL.UTF-8.po index 466116352..355bc4a57 100644 --- a/po/nl_NL.UTF-8.po +++ b/po/nl_NL.UTF-8.po @@ -1,5 +1,5 @@ # Dutch translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Nico Geesink , 2025. # diff --git a/po/pl_PL.UTF-8.po b/po/pl_PL.UTF-8.po index 22d2cd975..a68d56818 100644 --- a/po/pl_PL.UTF-8.po +++ b/po/pl_PL.UTF-8.po @@ -1,6 +1,6 @@ # Polish translations for com.mitchellh.ghostty package # Polskie tłumaczenia dla pakietu com.mitchellh.ghostty. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Bartosz Sokorski , 2025. # diff --git a/po/pt_BR.UTF-8.po b/po/pt_BR.UTF-8.po index f6d2f26a2..d2ba0e693 100644 --- a/po/pt_BR.UTF-8.po +++ b/po/pt_BR.UTF-8.po @@ -1,6 +1,6 @@ # Portuguese translations for com.mitchellh.ghostty package # Traduções em português brasileiro para o pacote com.mitchellh.ghostty. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Gustavo Peres , 2025. # diff --git a/po/ru_RU.UTF-8.po b/po/ru_RU.UTF-8.po index 9e9cf8077..0cb533de7 100644 --- a/po/ru_RU.UTF-8.po +++ b/po/ru_RU.UTF-8.po @@ -1,6 +1,6 @@ # Russian translations for com.mitchellh.ghostty package # Русские переводы для пакета com.mitchellh.ghostty. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # blackzeshi , 2025. # diff --git a/po/tr_TR.UTF-8.po b/po/tr_TR.UTF-8.po index ac1bfdfc7..5d761f6a4 100644 --- a/po/tr_TR.UTF-8.po +++ b/po/tr_TR.UTF-8.po @@ -1,5 +1,5 @@ # Turkish translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Emir SARI , 2025. # @@ -216,7 +216,7 @@ msgstr "Açık Sekmeleri Görüntüle" #: src/apprt/gtk/Window.zig:249 msgid "New Split" -msgstr "" +msgstr "Yeni Bölme" #: src/apprt/gtk/Window.zig:312 msgid "" diff --git a/po/uk_UA.UTF-8.po b/po/uk_UA.UTF-8.po index 5a264b537..bde975fc4 100644 --- a/po/uk_UA.UTF-8.po +++ b/po/uk_UA.UTF-8.po @@ -1,5 +1,5 @@ # Ukrainian translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Danylo Zalizchuk , 2025. # diff --git a/po/zh_CN.UTF-8.po b/po/zh_CN.UTF-8.po index 80c3766aa..77be8a351 100644 --- a/po/zh_CN.UTF-8.po +++ b/po/zh_CN.UTF-8.po @@ -1,6 +1,6 @@ # Chinese translations for com.mitchellh.ghostty package # com.mitchellh.ghostty 软件包的简体中文翻译. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Leah , 2025. # @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2025-04-22 08:57-0700\n" +"POT-Creation-Date: 2025-04-23 16:58+0800\n" "PO-Revision-Date: 2025-02-27 09:16+0100\n" "Last-Translator: Leah \n" "Language-Team: Chinese (simplified) \n" @@ -51,7 +51,7 @@ msgstr "忽略" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100 msgid "Reload Configuration" msgstr "重新加载配置" @@ -79,6 +79,10 @@ msgstr "向左分屏" msgid "Split Right" msgstr "向右分屏" +#: src/apprt/gtk/ui/1.5/command-palette.blp:16 +msgid "Execute a command…" +msgstr "选择要执行的命令……" + #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 msgid "Copy" @@ -116,7 +120,7 @@ msgstr "标签页" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 -#: src/apprt/gtk/Window.zig:248 +#: src/apprt/gtk/Window.zig:255 msgid "New Tab" msgstr "新建标签页" @@ -144,20 +148,24 @@ msgid "Config" msgstr "配置" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 msgid "Open Configuration" msgstr "打开配置文件" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 +msgid "Command Palette" +msgstr "命令面板" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 msgid "Terminal Inspector" msgstr "终端调试器" -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:102 -#: src/apprt/gtk/Window.zig:1003 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/Window.zig:1024 msgid "About Ghostty" msgstr "关于 Ghostty" -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112 msgid "Quit" msgstr "退出" @@ -198,31 +206,35 @@ msgid "" "commands may be executed." msgstr "将以下内容粘贴至终端内将可能执行有害命令。" -#: src/apprt/gtk/Window.zig:201 +#: src/apprt/gtk/Window.zig:208 msgid "Main Menu" msgstr "主菜单" -#: src/apprt/gtk/Window.zig:222 +#: src/apprt/gtk/Window.zig:229 msgid "View Open Tabs" msgstr "浏览标签页" -#: src/apprt/gtk/Window.zig:249 +#: src/apprt/gtk/Window.zig:256 msgid "New Split" -msgstr "" +msgstr "新建分屏" -#: src/apprt/gtk/Window.zig:312 +#: src/apprt/gtk/Window.zig:319 msgid "" "⚠️ You're running a debug build of Ghostty! Performance will be degraded." msgstr "⚠️ Ghostty 正在以调试模式运行!性能将大打折扣。" -#: src/apprt/gtk/Window.zig:744 +#: src/apprt/gtk/Window.zig:765 msgid "Reloaded the configuration" msgstr "已重新加载配置" -#: src/apprt/gtk/Window.zig:984 +#: src/apprt/gtk/Window.zig:1005 msgid "Ghostty Developers" msgstr "Ghostty 开发团队" +#: src/apprt/gtk/inspector.zig:144 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty 终端调试器" + #: src/apprt/gtk/CloseDialog.zig:47 msgid "Close" msgstr "关闭" @@ -262,7 +274,3 @@ msgstr "分屏内正在运行中的进程将被终止。" #: src/apprt/gtk/Surface.zig:1243 msgid "Copied to clipboard" msgstr "已复制至剪贴板" - -#: src/apprt/gtk/inspector.zig:144 -msgid "Ghostty: Terminal Inspector" -msgstr "Ghostty 终端调试器" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 8f1a7180a..b57411a6c 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -80,7 +80,7 @@ parts: - gettext override-build: | craftctl set version=$(cat VERSION) - $CRAFT_PART_SRC/../../zig/src/zig build -Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR -Doptimize=ReleaseFast + $CRAFT_PART_SRC/../../zig/src/zig build -Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR -Doptimize=ReleaseFast -Dcpu=baseline cp -rp zig-out/* $CRAFT_PART_INSTALL/ sed -i 's|Icon=com.mitchellh.ghostty|Icon=/snap/ghostty/current/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png|g' $CRAFT_PART_INSTALL/share/applications/com.mitchellh.ghostty.desktop diff --git a/src/App.zig b/src/App.zig index 15859d115..3bbeff2c8 100644 --- a/src/App.zig +++ b/src/App.zig @@ -444,6 +444,11 @@ pub fn performAction( .close_all_windows => _ = try rt_app.performAction(.app, .close_all_windows, {}), .toggle_quick_terminal => _ = try rt_app.performAction(.app, .toggle_quick_terminal, {}), .toggle_visibility => _ = try rt_app.performAction(.app, .toggle_visibility, {}), + .check_for_updates => _ = try rt_app.performAction(.app, .check_for_updates, {}), + .show_gtk_inspector => _ = try rt_app.performAction(.app, .show_gtk_inspector, {}), + .undo => _ = try rt_app.performAction(.app, .undo, {}), + + .redo => _ = try rt_app.performAction(.app, .redo, {}), } } diff --git a/src/Command.zig b/src/Command.zig index e17c1b370..281dcce40 100644 --- a/src/Command.zig +++ b/src/Command.zig @@ -370,7 +370,7 @@ pub fn wait(self: Command, block: bool) !Exit { } }; - return Exit.init(res.status); + return .init(res.status); } /// Sets command->data to data. diff --git a/src/Surface.zig b/src/Surface.zig index 0d4c9d984..9ab7234d6 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -160,7 +160,7 @@ pub const InputEffect = enum { /// Mouse state for the surface. const Mouse = struct { /// The last tracked mouse button state by button. - click_state: [input.MouseButton.max]input.MouseButtonState = .{.release} ** input.MouseButton.max, + click_state: [input.MouseButton.max]input.MouseButtonState = @splat(.release), /// The last mods state when the last mouse button (whatever it was) was /// pressed or release. @@ -253,6 +253,7 @@ const DerivedConfig = struct { mouse_shift_capture: configpkg.MouseShiftCapture, macos_non_native_fullscreen: configpkg.NonNativeFullscreen, macos_option_as_alt: ?configpkg.OptionAsAlt, + selection_clear_on_typing: bool, vt_kam_allowed: bool, window_padding_top: u32, window_padding_bottom: u32, @@ -316,6 +317,7 @@ const DerivedConfig = struct { .mouse_shift_capture = config.@"mouse-shift-capture", .macos_non_native_fullscreen = config.@"macos-non-native-fullscreen", .macos_option_as_alt = config.@"macos-option-as-alt", + .selection_clear_on_typing = config.@"selection-clear-on-typing", .vt_kam_allowed = config.@"vt-kam-allowed", .window_padding_top = config.@"window-padding-y".top_left, .window_padding_bottom = config.@"window-padding-y".bottom_right, @@ -461,7 +463,7 @@ pub fn init( // Create our terminal grid with the initial size const app_mailbox: App.Mailbox = .{ .rt_app = rt_app, .mailbox = &app.mailbox }; var renderer_impl = try Renderer.init(alloc, .{ - .config = try Renderer.DerivedConfig.init(alloc, config), + .config = try .init(alloc, config), .font_grid = font_grid, .size = size, .surface_mailbox = .{ .surface = self, .app = app_mailbox }, @@ -1687,7 +1689,9 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { if (self.renderer_state.preedit != null or preedit_ != null) { - self.setSelection(null) catch {}; + if (self.config.selection_clear_on_typing) { + self.setSelection(null) catch {}; + } } // We always clear our prior preedit @@ -1761,6 +1765,8 @@ pub fn keyEventIsBinding( // sequences) or the root set. const set = self.keyboard.bindings orelse &self.config.keybind.set; + // log.warn("text keyEventIsBinding event={} match={}", .{ event, set.getEvent(event) != null }); + // If we have a keybinding for this event then we return true. return set.getEvent(event) != null; } @@ -1870,7 +1876,7 @@ pub fn keyCallback( // Process the cursor state logic. This will update the cursor shape if // needed, depending on the key state. if ((SurfaceMouse{ - .physical_key = event.physical_key, + .physical_key = event.key, .mouse_event = self.io.terminal.flags.mouse_event, .mouse_shape = self.io.terminal.mouse_shape, .mods = self.mouse.mods, @@ -1895,12 +1901,12 @@ pub fn keyCallback( // if we didn't have a previous event and this is a release // event then we just want to set it to null. const prev = self.pressed_key orelse break :event null; - if (prev.key == copy.key) copy.key = .invalid; + if (prev.key == copy.key) copy.key = .unidentified; } // If our key is invalid and we have no mods, then we're done! // This helps catch the state that we naturally released all keys. - if (copy.key == .invalid and copy.mods.empty()) break :event null; + if (copy.key == .unidentified and copy.mods.empty()) break :event null; break :event copy; }; @@ -1928,7 +1934,13 @@ pub fn keyCallback( if (!event.key.modifier()) { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - try self.setSelection(null); + + if (self.config.selection_clear_on_typing or + event.key == .escape) + { + try self.setSelection(null); + } + try self.io.terminal.scrollViewport(.{ .bottom = {} }); try self.queueRender(); } @@ -2057,12 +2069,18 @@ fn maybeHandleBinding( break :performed try self.performBindingAction(action); }; - // If we performed an action and it was a closing action, - // our "self" pointer is not safe to use anymore so we need to - // just exit immediately. - if (performed and closingAction(action)) { - log.debug("key binding is a closing binding, halting key event processing", .{}); - return .closed; + if (performed) { + // If we performed an action and it was a closing action, + // our "self" pointer is not safe to use anymore so we need to + // just exit immediately. + if (closingAction(action)) { + log.debug("key binding is a closing binding, halting key event processing", .{}); + return .closed; + } + + // If our action was "ignore" then we return the special input + // effect of "ignored". + if (action == .ignore) return .ignored; } // If we have the performable flag and the action was not performed, @@ -2295,7 +2313,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { pressed_key.action = .release; // Release the full key first - if (pressed_key.key != .invalid) { + if (pressed_key.key != .unidentified) { assert(self.keyCallback(pressed_key) catch |err| err: { log.warn("error releasing key on focus loss err={}", .{err}); break :err .ignored; @@ -2315,8 +2333,15 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { if (@field(pressed_key.mods, key)) { @field(pressed_key.mods, key) = false; inline for (&.{ "right", "left" }) |side| { - const keyname = if (comptime std.mem.eql(u8, key, "ctrl")) "control" else key; - pressed_key.key = @field(input.Key, side ++ "_" ++ keyname); + const keyname = comptime keyname: { + break :keyname if (std.mem.eql(u8, key, "ctrl")) + "control" + else if (std.mem.eql(u8, key, "super")) + "meta" + else + key; + }; + pressed_key.key = @field(input.Key, keyname ++ "_" ++ side); if (pressed_key.key != original_key) { assert(self.keyCallback(pressed_key) catch |err| err: { log.warn("error releasing key on focus loss err={}", .{err}); @@ -3657,165 +3682,162 @@ fn dragLeftClickTriple( fn dragLeftClickSingle( self: *Surface, drag_pin: terminal.Pin, - xpos: f64, + drag_x: f64, ) !void { - // NOTE(mitchellh): This logic super sucks. There has to be an easier way - // to calculate this, but this is good for a v1. Selection isn't THAT - // common so its not like this performance heavy code is running that - // often. - // TODO: unit test this, this logic sucks - - // If we were selecting, and we switched directions, then we restart - // calculations because it forces us to reconsider if the first cell is - // selected. - self.checkResetSelSwitch(drag_pin); - - // Our logic for determining if the starting cell is selected: - // - // - The "xboundary" is 60% the width of a cell from the left. We choose - // 60% somewhat arbitrarily based on feeling. - // - If we started our click left of xboundary, backwards selections - // can NEVER select the current char. - // - If we started our click right of xboundary, backwards selections - // ALWAYS selected the current char, but we must move the cursor - // left of the xboundary. - // - Inverted logic for forwards selections. - // - - // Our clicking point - const click_pin = self.mouse.left_click_pin.?.*; - - // the boundary point at which we consider selection or non-selection - const cell_width_f64: f64 = @floatFromInt(self.size.cell.width); - const cell_xboundary = cell_width_f64 * 0.6; - - // first xpos of the clicked cell adjusted for padding - const left_padding_f64: f64 = @as(f64, @floatFromInt(self.size.padding.left)); - const cell_xstart = @as(f64, @floatFromInt(click_pin.x)) * cell_width_f64; - const cell_start_xpos = self.mouse.left_click_xpos - cell_xstart - left_padding_f64; - - // If this is the same cell, then we only start the selection if weve - // moved past the boundary point the opposite direction from where we - // started. - if (click_pin.eql(drag_pin)) { - // Ensuring to adjusting the cursor position for padding - const cell_xpos = xpos - cell_xstart - left_padding_f64; - const selected: bool = if (cell_start_xpos < cell_xboundary) - cell_xpos >= cell_xboundary - else - cell_xpos < cell_xboundary; - - try self.setSelection(if (selected) terminal.Selection.init( - drag_pin, - drag_pin, - SurfaceMouse.isRectangleSelectState(self.mouse.mods), - ) else null); - - return; - } - - // If this is a different cell and we haven't started selection, - // we determine the starting cell first. - if (self.io.terminal.screen.selection == null) { - // - If we're moving to a point before the start, then we select - // the starting cell if we started after the boundary, else - // we start selection of the prior cell. - // - Inverse logic for a point after the start. - const start: terminal.Pin = if (dragLeftClickBefore( - drag_pin, - click_pin, - self.mouse.mods, - )) start: { - if (cell_start_xpos >= cell_xboundary) break :start click_pin; - if (click_pin.x > 0) break :start click_pin.left(1); - var start = click_pin.up(1) orelse click_pin; - start.x = self.io.terminal.screen.pages.cols - 1; - break :start start; - } else start: { - if (cell_start_xpos < cell_xboundary) break :start click_pin; - if (click_pin.x < self.io.terminal.screen.pages.cols - 1) - break :start click_pin.right(1); - var start = click_pin.down(1) orelse click_pin; - start.x = 0; - break :start start; - }; - - try self.setSelection(terminal.Selection.init( - start, - drag_pin, - SurfaceMouse.isRectangleSelectState(self.mouse.mods), - )); - return; - } - - // TODO: detect if selection point is passed the point where we've - // actually written data before and disallow it. - - // We moved! Set the selection end point. The start point should be - // set earlier. - assert(self.io.terminal.screen.selection != null); - const sel = self.io.terminal.screen.selection.?; - try self.setSelection(terminal.Selection.init( - sel.start(), + // This logic is in a separate function so that it can be unit tested. + try self.setSelection(mouseSelection( + self.mouse.left_click_pin.?.*, drag_pin, - sel.rectangle, + @intFromFloat(@max(0.0, self.mouse.left_click_xpos)), + @intFromFloat(@max(0.0, drag_x)), + self.mouse.mods, + self.size, )); } -// Resets the selection if we switched directions, depending on the select -// mode. See dragLeftClickSingle for more details. -fn checkResetSelSwitch( - self: *Surface, +/// Calculates the appropriate selection given pins and pixel x positions for +/// the click point and the drag point, as well as mouse mods and screen size. +fn mouseSelection( + click_pin: terminal.Pin, drag_pin: terminal.Pin, -) void { - const screen = &self.io.terminal.screen; - const sel = screen.selection orelse return; - const sel_start = sel.start(); - const sel_end = sel.end(); + click_x: u32, + drag_x: u32, + mods: input.Mods, + size: rendererpkg.Size, +) ?terminal.Selection { + // Explanation: + // + // # Normal selections + // + // ## Left-to-right selections + // - The clicked cell is included if it was clicked to the left of its + // threshold point and the drag location is right of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is right of its threshold point. + // + // ## Right-to-left selections + // - The clicked cell is included if it was clicked to the right of its + // threshold point and the drag location is left of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is left of its threshold point. + // + // # Rectangular selections + // + // Rectangular selections are handled similarly, except that + // entire columns are considered rather than individual cells. - var reset: bool = false; - if (sel.rectangle) { - // When we're in rectangle mode, we reset the selection relative to - // the click point depending on the selection mode we're in, with - // the exception of single-column selections, which we always reset - // on if we drift. - if (sel_start.x == sel_end.x) { - reset = drag_pin.x != sel_start.x; - } else { - reset = switch (sel.order(screen)) { - .forward => drag_pin.x < sel_start.x or drag_pin.before(sel_start), - .reverse => drag_pin.x > sel_start.x or sel_start.before(drag_pin), - .mirrored_forward => drag_pin.x > sel_start.x or drag_pin.before(sel_start), - .mirrored_reverse => drag_pin.x < sel_start.x or sel_start.before(drag_pin), + // We only include cells in the selection if the threshold point lies + // between the start and end points of the selection. A threshold of + // 60% of the cell width was chosen empirically because it felt good. + const threshold_point: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(size.cell.width)) * 0.6, + )); + + // We use this to clamp the pixel positions below. + const max_x = size.grid().columns * size.cell.width - 1; + + // We need to know how far across in the cell the drag pos is, so + // we subtract the padding and then take it modulo the cell width. + const drag_x_frac = @min(max_x, drag_x -| size.padding.left) % size.cell.width; + + // We figure out the fractional part of the click x position similarly. + const click_x_frac = @min(max_x, click_x -| size.padding.left) % size.cell.width; + + // Whether or not this is a rectangular selection. + const rectangle_selection = SurfaceMouse.isRectangleSelectState(mods); + + // Whether the click pin and drag pin are equal. + const same_pin = drag_pin.eql(click_pin); + + // Whether or not the end point of our selection is before the start point. + const end_before_start = ebs: { + if (same_pin) { + break :ebs drag_x_frac < click_x_frac; + } + + // Special handling for rectangular selections, we only use x position. + if (rectangle_selection) { + break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) { + .eq => drag_x_frac < click_x_frac, + .lt => true, + .gt => false, }; } - } else { - // Normal select uses simpler logic that is just based on the - // selection start/end. - reset = if (sel_end.before(sel_start)) - sel_start.before(drag_pin) + + break :ebs drag_pin.before(click_pin); + }; + + // Whether or not the the click pin cell + // should be included in the selection. + const include_click_cell = if (end_before_start) + click_x_frac >= threshold_point + else + click_x_frac < threshold_point; + + // Whether or not the the drag pin cell + // should be included in the selection. + const include_drag_cell = if (end_before_start) + drag_x_frac < threshold_point + else + drag_x_frac >= threshold_point; + + // If the click cell should be included in the selection then it's the + // start, otherwise we get the previous or next cell to it depending on + // the type and direction of the selection. + const start_pin = + if (include_click_cell) + click_pin + else if (end_before_start) + if (rectangle_selection) + click_pin.leftClamp(1) + else + click_pin.leftWrap(1) orelse click_pin + else if (rectangle_selection) + click_pin.rightClamp(1) else - drag_pin.before(sel_start); + click_pin.rightWrap(1) orelse click_pin; + + // Likewise for the end pin with the drag cell. + const end_pin = + if (include_drag_cell) + drag_pin + else if (end_before_start) + if (rectangle_selection) + drag_pin.rightClamp(1) + else + drag_pin.rightWrap(1) orelse drag_pin + else if (rectangle_selection) + drag_pin.leftClamp(1) + else + drag_pin.leftWrap(1) orelse drag_pin; + + // If the click cell is the same as the drag cell and the click cell + // shouldn't be included, or if the cells are adjacent such that the + // start or end pin becomes the other cell, and that cell should not + // be included, then we have no selection, so we set it to null. + // + // If in rectangular selection mode, we compare columns as well. + // + // TODO(qwerasd): this can/should probably be refactored, it's a bit + // repetitive and does excess work in rectangle mode. + if ((!include_click_cell and same_pin) or + (!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or + (!include_click_cell and end_pin.eql(click_pin)) or + (!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or + (!include_drag_cell and start_pin.eql(drag_pin)) or + (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x)) + { + return null; } - // Nullifying a selection can't fail. - if (reset) self.setSelection(null) catch unreachable; -} + // TODO: Clamp selection to the screen area, don't + // let it extend past the last written row. -// Handles how whether or not the drag screen point is before the click point. -// When we are in rectangle select, we only interpret the x axis to determine -// where to start the selection (before or after the click point). See -// dragLeftClickSingle for more details. -fn dragLeftClickBefore( - drag_pin: terminal.Pin, - click_pin: terminal.Pin, - mods: input.Mods, -) bool { - if (mods.ctrlOrSuper() and mods.alt) { - return drag_pin.x < click_pin.x; - } - - return drag_pin.before(click_pin); + return .init( + start_pin, + end_pin, + rectangle_selection, + ); } /// Call to notify Ghostty that the color scheme for the terminal has @@ -3901,6 +3923,21 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .parent = self }, ), + // Undo and redo both support both surface and app targeting. + // If we are triggering on a surface then we perform the + // action with the surface target. + .undo => return try self.rt_app.performAction( + .{ .surface = self }, + .undo, + {}, + ), + + .redo => return try self.rt_app.performAction( + .{ .surface = self }, + .redo, + {}, + ), + else => try self.app.performAction( self.rt_app, action.scoped(.app).?, @@ -4800,3 +4837,430 @@ fn presentSurface(self: *Surface) !void { {}, ); } + +/// Utility function for the unit tests for mouse selection logic. +/// +/// Tests a click and drag on a 10x5 cell grid, x positions are given in +/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3. +/// +/// NOTE: The size tested with has 10px wide cells, meaning only one digit +/// after the decimal place has any meaning, e.g. 3.14 is equal to 3.1. +/// +/// The provided start_x/y and end_x/y are the expected start and end points +/// of the resulting selection. +fn testMouseSelection( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + start_x: terminal.size.CellCountInt, + start_y: u32, + end_x: terminal.size.CellCountInt, + end_y: u32, + rect: bool, +) !void { + assert(builtin.is_test); + + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const size: rendererpkg.Size = .{ + .cell = .{ .width = 10, .height = 20 }, + .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, + .screen = .{ .width = 110, .height = 110 }, + }; + var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0); + defer screen.deinit(); + + // We hold both ctrl and alt for rectangular + // select so that this test is platform agnostic. + const mods: input.Mods = .{ + .ctrl = rect, + .alt = rect, + }; + + try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); + + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; + + const cell_width_f64: f64 = @floatFromInt(size.cell.width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + size.padding.left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + size.padding.left; + + const start_pin = screen.pages.pin(.{ + .viewport = .{ .x = start_x, .y = start_y }, + }) orelse unreachable; + const end_pin = screen.pages.pin(.{ + .viewport = .{ .x = end_x, .y = end_y }, + }) orelse unreachable; + + try std.testing.expectEqualDeep(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = rect, + }, mouseSelection( + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + mods, + size, + )); +} + +/// Like `testMouseSelection` but checks that the resulting selection is null. +/// +/// See `testMouseSelection` for more details. +fn testMouseSelectionIsNull( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + rect: bool, +) !void { + assert(builtin.is_test); + + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const size: rendererpkg.Size = .{ + .cell = .{ .width = 10, .height = 20 }, + .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, + .screen = .{ .width = 110, .height = 110 }, + }; + var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0); + defer screen.deinit(); + + // We hold both ctrl and alt for rectangular + // select so that this test is platform agnostic. + const mods: input.Mods = .{ + .ctrl = rect, + .alt = rect, + }; + + try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); + + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; + + const cell_width_f64: f64 = @floatFromInt(size.cell.width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + size.padding.left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + size.padding.left; + + try std.testing.expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + mods, + size, + ), + ); +} + +test "Surface: selection logic" { + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off + + // -- LTR + // single cell selection + try testMouseSelection( + 3.0, 3, // click + 3.9, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testMouseSelection( + 3.0, 3, // click + 5.9, 3, // drag + 3, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testMouseSelection( + 3.0, 3, // click + 5.0, 3, // drag + 3, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testMouseSelection( + 3.9, 3, // click + 5.9, 3, // drag + 4, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testMouseSelection( + 3.9, 3, // click + 5.0, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testMouseSelectionIsNull( + 3.0, 3, // click + 3.1, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testMouseSelectionIsNull( + 3.8, 3, // click + 3.9, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testMouseSelectionIsNull( + 3.9, 3, // click + 4.0, 3, // drag + false, // regular selection + ); + + // -- RTL + // single cell selection + try testMouseSelection( + 3.9, 3, // click + 3.0, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testMouseSelection( + 5.9, 3, // click + 3.0, 3, // drag + 5, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testMouseSelection( + 5.9, 3, // click + 3.9, 3, // drag + 5, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testMouseSelection( + 5.0, 3, // click + 3.0, 3, // drag + 4, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testMouseSelection( + 5.0, 3, // click + 3.9, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testMouseSelectionIsNull( + 3.1, 3, // click + 3.0, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testMouseSelectionIsNull( + 3.9, 3, // click + 3.8, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testMouseSelectionIsNull( + 4.0, 3, // click + 3.9, 3, // drag + false, // regular selection + ); + + // -- Wrapping + // LTR, wrap excluded cells + try testMouseSelection( + 9.9, 2, // click + 0.0, 4, // drag + 0, 3, // expected start + 9, 3, // expected end + false, // regular selection + ); + // RTL, wrap excluded cells + try testMouseSelection( + 0.0, 4, // click + 9.9, 2, // drag + 9, 3, // expected start + 0, 3, // expected end + false, // regular selection + ); +} + +test "Surface: rectangle selection logic" { + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off + + // -- LTR + // single column selection + try testMouseSelection( + 3.0, 2, // click + 3.9, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testMouseSelection( + 3.0, 2, // click + 5.9, 4, // drag + 3, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testMouseSelection( + 3.0, 2, // click + 5.0, 4, // drag + 3, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testMouseSelection( + 3.9, 2, // click + 5.9, 4, // drag + 4, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testMouseSelection( + 3.9, 2, // click + 5.0, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testMouseSelectionIsNull( + 3.0, 2, // click + 3.1, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testMouseSelectionIsNull( + 3.8, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testMouseSelectionIsNull( + 3.9, 2, // click + 4.0, 4, // drag + true, //rectangle selection + ); + + // -- RTL + // single column selection + try testMouseSelection( + 3.9, 2, // click + 3.0, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testMouseSelection( + 5.9, 2, // click + 3.0, 4, // drag + 5, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testMouseSelection( + 5.9, 2, // click + 3.9, 4, // drag + 5, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testMouseSelection( + 5.0, 2, // click + 3.0, 4, // drag + 4, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testMouseSelection( + 5.0, 2, // click + 3.9, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testMouseSelectionIsNull( + 3.1, 2, // click + 3.0, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testMouseSelectionIsNull( + 3.9, 2, // click + 3.8, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testMouseSelectionIsNull( + 4.0, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); + + // -- Wrapping + // LTR, do not wrap + try testMouseSelection( + 9.9, 2, // click + 0.0, 4, // drag + 9, 2, // expected start + 0, 4, // expected end + true, //rectangle selection + ); + // RTL, do not wrap + try testMouseSelection( + 0.0, 4, // click + 9.9, 2, // drag + 0, 4, // expected start + 9, 2, // expected end + true, //rectangle selection + ); +} diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 4be296f09..b4c5164c2 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -165,6 +165,9 @@ pub const Action = union(Key) { /// Control whether the inspector is shown or hidden. inspector: Inspector, + /// Show the GTK inspector. + show_gtk_inspector, + /// The inspector for the given target has changes and should be /// rendered at the next opportunity. render_inspector, @@ -255,6 +258,15 @@ pub const Action = union(Key) { /// it needs to ring the bell. This is usually a sound or visual effect. ring_bell, + /// Undo the last action. See the "undo" keybinding for more + /// details on what can and cannot be undone. + undo, + + /// Redo the last undone action. + redo, + + check_for_updates, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -282,6 +294,7 @@ pub const Action = union(Key) { initial_size, cell_size, inspector, + show_gtk_inspector, render_inspector, desktop_notification, set_title, @@ -301,6 +314,9 @@ pub const Action = union(Key) { config_change, close_window, ring_bell, + undo, + redo, + check_for_updates, }; /// Sync with: ghostty_action_u diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index c953300cd..5334c8ecd 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -92,42 +92,12 @@ pub const App = struct { // We want to get the physical unmapped key to process keybinds. const physical_key = keycode: for (input.keycodes.entries) |entry| { if (entry.native == self.keycode) break :keycode entry.key; - } else .invalid; - - // If the resulting text has length 1 then we can take its key - // and attempt to translate it to a key enum and call the key callback. - // If the length is greater than 1 then we're going to call the - // charCallback. - // - // We also only do key translation if this is not a dead key. - const key = if (!self.composing) key: { - // If our physical key is a keypad key, we use that. - if (physical_key.keypad()) break :key physical_key; - - // A completed key. If the length of the key is one then we can - // attempt to translate it to a key enum and call the key - // callback. First try plain ASCII. - if (text.len > 0) { - if (input.Key.fromASCII(text[0])) |key| { - break :key key; - } - } - - // If the above doesn't work, we use the unmodified value. - if (std.math.cast(u8, unshifted_codepoint)) |ascii| { - if (input.Key.fromASCII(ascii)) |key| { - break :key key; - } - } - - break :key physical_key; - } else .invalid; + } else .unidentified; // Build our final key event return .{ .action = self.action, - .key = key, - .physical_key = physical_key, + .key = physical_key, .mods = self.mods, .consumed_mods = self.consumed_mods, .composing = self.composing, @@ -453,7 +423,7 @@ pub const Surface = struct { pub fn init(self: *Surface, app: *App, opts: Options) !void { self.* = .{ .app = app, - .platform = try Platform.init(opts.platform_tag, opts.platform), + .platform = try .init(opts.platform_tag, opts.platform), .userdata = opts.userdata, .core_surface = undefined, .content_scale = .{ @@ -552,7 +522,7 @@ pub const Surface = struct { const alloc = self.app.core_app.alloc; const inspector = try alloc.create(Inspector); errdefer alloc.destroy(inspector); - inspector.* = try Inspector.init(self); + inspector.* = try .init(self); self.inspector = inspector; return inspector; } @@ -872,7 +842,10 @@ pub const Surface = struct { // our translation settings for Ghostty. If we aren't from // the desktop then we didn't set our LANGUAGE var so we // don't need to remove it. - if (internal_os.launchedFromDesktop()) env.remove("LANGUAGE"); + switch (self.app.config.@"launched-from".?) { + .desktop => env.remove("LANGUAGE"), + .dbus, .systemd, .cli => {}, + } } return env; @@ -1210,7 +1183,7 @@ pub const CAPI = struct { // Create our runtime app var app = try global.alloc.create(App); errdefer global.alloc.destroy(app); - app.* = try App.init(core_app, config, opts.*); + app.* = try .init(core_app, config, opts.*); errdefer app.terminate(); return app; @@ -1386,6 +1359,11 @@ pub const CAPI = struct { return surface.core_surface.needsConfirmQuit(); } + /// Returns true if the surface process has exited. + export fn ghostty_surface_process_exited(surface: *Surface) bool { + return surface.core_surface.child_exited; + } + /// Returns true if the surface has a selection. export fn ghostty_surface_has_selection(surface: *Surface) bool { return surface.core_surface.hasSelection(); @@ -1979,7 +1957,7 @@ pub const CAPI = struct { } export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool { - return ptr.initMetal(objc.Object.fromId(device)); + return ptr.initMetal(.fromId(device)); } export fn ghostty_inspector_metal_render( @@ -1988,8 +1966,8 @@ pub const CAPI = struct { descriptor: objc.c.id, ) void { return ptr.renderMetal( - objc.Object.fromId(command_buffer), - objc.Object.fromId(descriptor), + .fromId(command_buffer), + .fromId(descriptor), ) catch |err| { log.err("error rendering inspector err={}", .{err}); return; diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 9d1c8a6b5..924737074 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -249,6 +249,10 @@ pub const App = struct { .prompt_title, .reset_window_size, .ring_bell, + .check_for_updates, + .undo, + .redo, + .show_gtk_inspector, => { log.info("unimplemented action={}", .{action}); return false; @@ -966,46 +970,46 @@ pub const Surface = struct { .repeat => .repeat, }; const key: input.Key = switch (glfw_key) { - .a => .a, - .b => .b, - .c => .c, - .d => .d, - .e => .e, - .f => .f, - .g => .g, - .h => .h, - .i => .i, - .j => .j, - .k => .k, - .l => .l, - .m => .m, - .n => .n, - .o => .o, - .p => .p, - .q => .q, - .r => .r, - .s => .s, - .t => .t, - .u => .u, - .v => .v, - .w => .w, - .x => .x, - .y => .y, - .z => .z, - .zero => .zero, - .one => .one, - .two => .two, - .three => .three, - .four => .four, - .five => .five, - .six => .six, - .seven => .seven, - .eight => .eight, - .nine => .nine, - .up => .up, - .down => .down, - .right => .right, - .left => .left, + .a => .key_a, + .b => .key_b, + .c => .key_c, + .d => .key_d, + .e => .key_e, + .f => .key_f, + .g => .key_g, + .h => .key_h, + .i => .key_i, + .j => .key_j, + .k => .key_k, + .l => .key_l, + .m => .key_m, + .n => .key_n, + .o => .key_o, + .p => .key_p, + .q => .key_q, + .r => .key_r, + .s => .key_s, + .t => .key_t, + .u => .key_u, + .v => .key_v, + .w => .key_w, + .x => .key_x, + .y => .key_y, + .z => .key_z, + .zero => .digit_0, + .one => .digit_1, + .two => .digit_2, + .three => .digit_3, + .four => .digit_4, + .five => .digit_5, + .six => .digit_6, + .seven => .digit_7, + .eight => .digit_8, + .nine => .digit_9, + .up => .arrow_up, + .down => .arrow_down, + .right => .arrow_right, + .left => .arrow_left, .home => .home, .end => .end, .page_up => .page_up, @@ -1036,34 +1040,34 @@ pub const Surface = struct { .F23 => .f23, .F24 => .f24, .F25 => .f25, - .kp_0 => .kp_0, - .kp_1 => .kp_1, - .kp_2 => .kp_2, - .kp_3 => .kp_3, - .kp_4 => .kp_4, - .kp_5 => .kp_5, - .kp_6 => .kp_6, - .kp_7 => .kp_7, - .kp_8 => .kp_8, - .kp_9 => .kp_9, - .kp_decimal => .kp_decimal, - .kp_divide => .kp_divide, - .kp_multiply => .kp_multiply, - .kp_subtract => .kp_subtract, - .kp_add => .kp_add, - .kp_enter => .kp_enter, - .kp_equal => .kp_equal, - .grave_accent => .grave_accent, + .kp_0 => .numpad_0, + .kp_1 => .numpad_1, + .kp_2 => .numpad_2, + .kp_3 => .numpad_3, + .kp_4 => .numpad_4, + .kp_5 => .numpad_5, + .kp_6 => .numpad_6, + .kp_7 => .numpad_7, + .kp_8 => .numpad_8, + .kp_9 => .numpad_9, + .kp_decimal => .numpad_decimal, + .kp_divide => .numpad_divide, + .kp_multiply => .numpad_multiply, + .kp_subtract => .numpad_subtract, + .kp_add => .numpad_add, + .kp_enter => .numpad_enter, + .kp_equal => .numpad_equal, + .grave_accent => .backquote, .minus => .minus, .equal => .equal, .space => .space, .semicolon => .semicolon, - .apostrophe => .apostrophe, + .apostrophe => .quote, .comma => .comma, .period => .period, .slash => .slash, - .left_bracket => .left_bracket, - .right_bracket => .right_bracket, + .left_bracket => .bracket_left, + .right_bracket => .bracket_right, .backslash => .backslash, .enter => .enter, .tab => .tab, @@ -1075,20 +1079,20 @@ pub const Surface = struct { .num_lock => .num_lock, .print_screen => .print_screen, .pause => .pause, - .left_shift => .left_shift, - .left_control => .left_control, - .left_alt => .left_alt, - .left_super => .left_super, - .right_shift => .right_shift, - .right_control => .right_control, - .right_alt => .right_alt, - .right_super => .right_super, + .left_shift => .shift_left, + .left_control => .control_left, + .left_alt => .alt_left, + .left_super => .meta_left, + .right_shift => .shift_right, + .right_control => .control_right, + .right_alt => .alt_right, + .right_super => .meta_right, + .menu => .context_menu, - .menu, .world_1, .world_2, .unknown, - => .invalid, + => .unidentified, }; // This is a hack for GLFW. We require our apprts to send both @@ -1108,7 +1112,6 @@ pub const Surface = struct { const key_event: input.KeyEvent = .{ .action = action, .key = key, - .physical_key = key, .mods = mods, .consumed_mods = .{}, .composing = false, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index c9a973611..099a051a4 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -40,6 +40,7 @@ const Window = @import("Window.zig"); const ConfigErrorsDialog = @import("ConfigErrorsDialog.zig"); const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig"); const CloseDialog = @import("CloseDialog.zig"); +const GlobalShortcuts = @import("GlobalShortcuts.zig"); const Split = @import("Split.zig"); const inspector = @import("inspector.zig"); const key = @import("key.zig"); @@ -74,6 +75,9 @@ cursor_none: ?*gdk.Cursor, /// The clipboard confirmation window, if it is currently open. clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null, +/// The config errors dialog, if it is currently open. +config_errors_dialog: ?ConfigErrorsDialog = null, + /// The window containing the quick terminal. /// Null when never initialized. quick_terminal: ?*Window = null, @@ -92,6 +96,8 @@ css_provider: *gtk.CssProvider, /// Providers for loading custom stylesheets defined by user custom_css_providers: std.ArrayListUnmanaged(*gtk.CssProvider) = .{}, +global_shortcuts: ?GlobalShortcuts, + /// The timer used to quit the application after the last window is closed. quit_timer: union(enum) { off: void, @@ -267,7 +273,10 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { const single_instance = switch (config.@"gtk-single-instance") { .true => true, .false => false, - .desktop => internal_os.launchedFromDesktop(), + .desktop => switch (config.@"launched-from".?) { + .desktop, .systemd, .dbus => true, + .cli => false, + }, }; // Setup the flags for our application. @@ -282,7 +291,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // can develop Ghostty in Ghostty. const app_id: [:0]const u8 = app_id: { if (config.class) |class| { - if (isValidAppId(class)) { + if (gio.Application.idIsValid(class) != 0) { break :app_id class; } else { log.warn("invalid 'class' in config, ignoring", .{}); @@ -419,6 +428,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // our "activate" call above will open a window. .running = gio_app.getIsRemote() == 0, .css_provider = css_provider, + .global_shortcuts = .init(core_app.alloc, gio_app), }; } @@ -440,6 +450,8 @@ pub fn terminate(self: *App) void { self.winproto.deinit(self.core_app.alloc); + if (self.global_shortcuts) |*shortcuts| shortcuts.deinit(); + self.config.deinit(); } @@ -472,6 +484,7 @@ pub fn performAction( .config_change => self.configChange(target, value.config), .reload_config => try self.reloadConfig(target, value), .inspector => self.controlInspector(target, value), + .show_gtk_inspector => self.showGTKInspector(), .desktop_notification => self.showDesktopNotification(target, value), .set_title => try self.setTitle(target, value), .pwd => try self.setPwd(target, value), @@ -489,11 +502,11 @@ pub fn performAction( .toggle_quick_terminal => return try self.toggleQuickTerminal(), .secure_input => self.setSecureInput(target, value), .ring_bell => try self.ringBell(target), + .toggle_command_palette => try self.toggleCommandPalette(target), // Unimplemented .close_all_windows, .float_window, - .toggle_command_palette, .toggle_visibility, .cell_size, .key_sequence, @@ -501,6 +514,9 @@ pub fn performAction( .renderer_health, .color_change, .reset_window_size, + .check_for_updates, + .undo, + .redo, => { log.warn("unimplemented action={}", .{action}); return false; @@ -677,6 +693,12 @@ fn controlInspector( surface.controlInspector(mode); } +fn showGTKInspector( + _: *const App, +) void { + gtk.Window.setInteractiveDebugging(@intFromBool(true)); +} + fn toggleMaximize(_: *App, target: apprt.Target) void { switch (target) { .app => {}, @@ -747,7 +769,7 @@ fn toggleWindowDecorations( .surface => |v| { const window = v.rt_surface.container.window() orelse { log.info( - "toggleFullscreen invalid for container={s}", + "toggleWindowDecorations invalid for container={s}", .{@tagName(v.rt_surface.container)}, ); return; @@ -789,6 +811,23 @@ fn ringBell(_: *App, target: apprt.Target) !void { } } +fn toggleCommandPalette(_: *App, target: apprt.Target) !void { + switch (target) { + .app => {}, + .surface => |surface| { + const window = surface.rt_surface.container.window() orelse { + log.info( + "toggleCommandPalette invalid for container={s}", + .{@tagName(surface.rt_surface.container)}, + ); + return; + }; + + window.toggleCommandPalette(); + }, + } +} + fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void { switch (mode) { .start => self.startQuitTimer(), @@ -1009,6 +1048,12 @@ fn syncConfigChanges(self: *App, window: ?*Window) !void { ConfigErrorsDialog.maybePresent(self, window); try self.syncActionAccelerators(); + if (self.global_shortcuts) |*shortcuts| { + shortcuts.refreshSession(self) catch |err| { + log.warn("failed to refresh global shortcuts={}", .{err}); + }; + } + // Load our runtime and custom CSS. If this fails then our window is just stuck // with the old CSS but we don't want to fail the entire sync operation. self.loadRuntimeCss() catch |err| switch (err) { @@ -1027,6 +1072,8 @@ fn syncActionAccelerators(self: *App) !void { try self.syncActionAccelerator("app.open-config", .{ .open_config = {} }); try self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} }); try self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle }); + try self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector); + try self.syncActionAccelerator("win.toggle-command-palette", .toggle_command_palette); try self.syncActionAccelerator("win.close", .{ .close_window = {} }); try self.syncActionAccelerator("win.new-window", .{ .new_window = {} }); try self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} }); @@ -1621,6 +1668,16 @@ fn gtkActionPresentSurface( ); } +fn gtkActionShowGTKInspector( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *App, +) callconv(.c) void { + self.core_app.performAction(self, .show_gtk_inspector) catch |err| { + log.err("error showing GTK inspector err={}", .{err}); + }; +} + /// This is called to setup the action map that this application supports. /// This should be called only once on startup. fn initActions(self: *App) void { @@ -1639,6 +1696,7 @@ fn initActions(self: *App) void { .{ "open-config", gtkActionOpenConfig, null }, .{ "reload-config", gtkActionReloadConfig, null }, .{ "present-surface", gtkActionPresentSurface, t }, + .{ "show-gtk-inspector", gtkActionShowGTKInspector, null }, }; inline for (actions) |entry| { const action = gio.SimpleAction.new(entry[0], entry[2]); @@ -1654,32 +1712,3 @@ fn initActions(self: *App) void { action_map.addAction(action.as(gio.Action)); } } - -fn isValidAppId(app_id: [:0]const u8) bool { - if (app_id.len > 255 or app_id.len == 0) return false; - if (app_id[0] == '.') return false; - if (app_id[app_id.len - 1] == '.') return false; - - var hasDot = false; - for (app_id) |char| { - switch (char) { - 'a'...'z', 'A'...'Z', '0'...'9', '_', '-' => {}, - '.' => hasDot = true, - else => return false, - } - } - if (!hasDot) return false; - - return true; -} - -test "isValidAppId" { - try testing.expect(isValidAppId("foo.bar")); - try testing.expect(isValidAppId("foo.bar.baz")); - try testing.expect(!isValidAppId("foo")); - try testing.expect(!isValidAppId("foo.bar?")); - try testing.expect(!isValidAppId("foo.")); - try testing.expect(!isValidAppId(".foo")); - try testing.expect(!isValidAppId("")); - try testing.expect(!isValidAppId("foo" ** 86)); -} diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index f10fc79ac..fab1aa893 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -69,16 +69,16 @@ fn init( request: apprt.ClipboardRequest, is_secure_input: bool, ) !void { - var builder = switch (DialogType) { + var builder: Builder = switch (DialogType) { adw.AlertDialog => switch (request) { - .osc_52_read => Builder.init("ccw-osc-52-read", 1, 5), - .osc_52_write => Builder.init("ccw-osc-52-write", 1, 5), - .paste => Builder.init("ccw-paste", 1, 5), + .osc_52_read => .init("ccw-osc-52-read", 1, 5), + .osc_52_write => .init("ccw-osc-52-write", 1, 5), + .paste => .init("ccw-paste", 1, 5), }, adw.MessageDialog => switch (request) { - .osc_52_read => Builder.init("ccw-osc-52-read", 1, 2), - .osc_52_write => Builder.init("ccw-osc-52-write", 1, 2), - .paste => Builder.init("ccw-paste", 1, 2), + .osc_52_read => .init("ccw-osc-52-read", 1, 2), + .osc_52_write => .init("ccw-osc-52-write", 1, 2), + .paste => .init("ccw-paste", 1, 2), }, else => unreachable, }; diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig new file mode 100644 index 000000000..a99db78d7 --- /dev/null +++ b/src/apprt/gtk/CommandPalette.zig @@ -0,0 +1,249 @@ +const CommandPalette = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const adw = @import("adw"); +const gio = @import("gio"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const configpkg = @import("../../config.zig"); +const inputpkg = @import("../../input.zig"); +const key = @import("key.zig"); +const Builder = @import("Builder.zig"); +const Window = @import("Window.zig"); + +const log = std.log.scoped(.command_palette); + +window: *Window, + +arena: std.heap.ArenaAllocator, + +/// The dialog object containing the palette UI. +dialog: *adw.Dialog, + +/// The search input text field. +search: *gtk.SearchEntry, + +/// The view containing each result row. +view: *gtk.ListView, + +/// The model that provides filtered data for the view to display. +model: *gtk.SingleSelection, + +/// The list that serves as the data source of the model. +/// This is where all command data is ultimately stored. +source: *gio.ListStore, + +pub fn init(self: *CommandPalette, window: *Window) !void { + // Register the custom command type *before* initializing the builder + // If we don't do this now, the builder will complain that it doesn't know + // about this type and fail to initialize + _ = Command.getGObjectType(); + + var builder = Builder.init("command-palette", 1, 5); + defer builder.deinit(); + + self.* = .{ + .window = window, + .arena = .init(window.app.core_app.alloc), + .dialog = builder.getObject(adw.Dialog, "command-palette").?, + .search = builder.getObject(gtk.SearchEntry, "search").?, + .view = builder.getObject(gtk.ListView, "view").?, + .model = builder.getObject(gtk.SingleSelection, "model").?, + .source = builder.getObject(gio.ListStore, "source").?, + }; + + // Manually take a reference here so that the dialog + // remains in memory after closing + self.dialog.ref(); + errdefer self.dialog.unref(); + + _ = gtk.SearchEntry.signals.stop_search.connect( + self.search, + *CommandPalette, + searchStopped, + self, + .{}, + ); + + _ = gtk.SearchEntry.signals.activate.connect( + self.search, + *CommandPalette, + searchActivated, + self, + .{}, + ); + + _ = gtk.ListView.signals.activate.connect( + self.view, + *CommandPalette, + rowActivated, + self, + .{}, + ); + + try self.updateConfig(&self.window.app.config); +} + +pub fn deinit(self: *CommandPalette) void { + self.arena.deinit(); + self.dialog.unref(); +} + +pub fn toggle(self: *CommandPalette) void { + self.dialog.present(self.window.window.as(gtk.Widget)); + + // Focus on the search bar when opening the dialog + self.dialog.setFocus(self.search.as(gtk.Widget)); +} + +pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !void { + // Clear existing binds and clear allocated data + self.source.removeAll(); + _ = self.arena.reset(.retain_capacity); + + // TODO: Allow user-configured palette entries + for (inputpkg.command.defaults) |command| { + // Filter out actions that are not implemented + // or don't make sense for GTK + switch (command.action) { + .close_all_windows, + .toggle_secure_input, + => continue, + + else => {}, + } + + const cmd = try Command.new( + self.arena.allocator(), + command, + config.keybind.set, + ); + const cmd_ref = cmd.as(gobject.Object); + self.source.append(cmd_ref); + cmd_ref.unref(); + } +} + +fn activated(self: *CommandPalette, pos: c_uint) void { + // Use self.model and not self.source here to use the list of *visible* results + const object = self.model.as(gio.ListModel).getObject(pos) orelse return; + const cmd = gobject.ext.cast(Command, object) orelse return; + + // Close before running the action in order to avoid being replaced by another + // dialog (such as the change title dialog). If that occurs then the command + // palette dialog won't be counted as having closed properly and cannot + // receive focus when reopened. + _ = self.dialog.close(); + + const action = inputpkg.Binding.Action.parse( + std.mem.span(cmd.cmd_c.action_key), + ) catch |err| { + log.err("got invalid action={s} ({})", .{ cmd.cmd_c.action_key, err }); + return; + }; + + self.window.performBindingAction(action); +} + +fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { + // ESC was pressed - close the palette + _ = self.dialog.close(); +} + +fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { + // If Enter is pressed, activate the selected entry + self.activated(self.model.getSelected()); +} + +fn rowActivated(_: *gtk.ListView, pos: c_uint, self: *CommandPalette) callconv(.c) void { + self.activated(pos); +} + +/// Object that wraps around a command. +/// +/// As GTK list models only accept objects that are within the GObject hierarchy, +/// we have to construct a wrapper to be easily consumed by the list model. +const Command = extern struct { + parent: Parent, + cmd_c: inputpkg.Command.C, + + pub const getGObjectType = gobject.ext.defineClass(Command, .{ + .name = "GhosttyCommand", + .classInit = Class.init, + }); + + pub fn new(alloc: Allocator, cmd: inputpkg.Command, keybinds: inputpkg.Binding.Set) !*Command { + const self = gobject.ext.newInstance(Command, .{}); + var buf: [64]u8 = undefined; + + const action = action: { + const trigger = keybinds.getTrigger(cmd.action) orelse break :action null; + const accel = try key.accelFromTrigger(&buf, trigger) orelse break :action null; + break :action try alloc.dupeZ(u8, accel); + }; + + self.cmd_c = .{ + .title = cmd.title.ptr, + .description = cmd.description.ptr, + .action = if (action) |v| v.ptr else "", + .action_key = try std.fmt.allocPrintZ(alloc, "{}", .{cmd.action}), + }; + + return self; + } + + fn as(self: *Command, comptime T: type) *T { + return gobject.ext.as(T, self); + } + + pub const Parent = gobject.Object; + + pub const Class = extern struct { + parent: Parent.Class, + + pub const Instance = Command; + + pub fn init(class: *Class) callconv(.c) void { + const info = @typeInfo(inputpkg.Command.C).@"struct"; + + // Expose all fields on the Command.C struct as properties + // that can be accessed by the GObject type system + // (and by extension, blueprints) + const properties = comptime props: { + var props: [info.fields.len]type = undefined; + + for (info.fields, 0..) |field, i| { + const accessor = struct { + fn getter(cmd: *Command) ?[:0]const u8 { + return std.mem.span(@field(cmd.cmd_c, field.name)); + } + }; + + // "Canonicalize" field names into the format GObject expects + const prop_name = prop_name: { + var buf: [field.name.len:0]u8 = undefined; + _ = std.mem.replace(u8, field.name, "_", "-", &buf); + break :prop_name buf; + }; + + props[i] = gobject.ext.defineProperty( + &prop_name, + Command, + ?[:0]const u8, + .{ + .default = null, + .accessor = .{ .getter = &accessor.getter }, + }, + ); + } + + break :props props; + }; + + gobject.ext.registerProperties(class, &properties); + } + }; +}; diff --git a/src/apprt/gtk/ConfigErrorsDialog.zig b/src/apprt/gtk/ConfigErrorsDialog.zig index a1a2a61af..da70ccce1 100644 --- a/src/apprt/gtk/ConfigErrorsDialog.zig +++ b/src/apprt/gtk/ConfigErrorsDialog.zig @@ -29,15 +29,38 @@ error_message: *gtk.TextBuffer, pub fn maybePresent(app: *App, window: ?*Window) void { if (app.config._diagnostics.empty()) return; - var builder = switch (DialogType) { - adw.AlertDialog => Builder.init("config-errors-dialog", 1, 5), - adw.MessageDialog => Builder.init("config-errors-dialog", 1, 2), - else => unreachable, - }; - defer builder.deinit(); + const config_errors_dialog = config_errors_dialog: { + if (app.config_errors_dialog) |config_errors_dialog| break :config_errors_dialog config_errors_dialog; - const dialog = builder.getObject(DialogType, "config_errors_dialog").?; - const error_message = builder.getObject(gtk.TextBuffer, "error_message").?; + var builder: Builder = switch (DialogType) { + adw.AlertDialog => .init("config-errors-dialog", 1, 5), + adw.MessageDialog => .init("config-errors-dialog", 1, 2), + else => unreachable, + }; + + const dialog = builder.getObject(DialogType, "config_errors_dialog").?; + const error_message = builder.getObject(gtk.TextBuffer, "error_message").?; + + _ = DialogType.signals.response.connect(dialog, *App, onResponse, app, .{}); + + app.config_errors_dialog = .{ + .builder = builder, + .dialog = dialog, + .error_message = error_message, + }; + + break :config_errors_dialog app.config_errors_dialog.?; + }; + + { + var start = std.mem.zeroes(gtk.TextIter); + config_errors_dialog.error_message.getStartIter(&start); + + var end = std.mem.zeroes(gtk.TextIter); + config_errors_dialog.error_message.getEndIter(&end); + + config_errors_dialog.error_message.delete(&start, &end); + } var msg_buf: [4095:0]u8 = undefined; var fbs = std.io.fixedBufferStream(&msg_buf); @@ -52,22 +75,24 @@ pub fn maybePresent(app: *App, window: ?*Window) void { continue; }; - error_message.insertAtCursor(&msg_buf, @intCast(fbs.pos)); - error_message.insertAtCursor("\n", 1); + config_errors_dialog.error_message.insertAtCursor(&msg_buf, @intCast(fbs.pos)); + config_errors_dialog.error_message.insertAtCursor("\n", 1); } - _ = DialogType.signals.response.connect(dialog, *App, onResponse, app, .{}); - - const parent = if (window) |w| w.window.as(gtk.Widget) else null; - switch (DialogType) { - adw.AlertDialog => dialog.as(adw.Dialog).present(parent), - adw.MessageDialog => dialog.as(gtk.Window).present(), + adw.AlertDialog => { + const parent = if (window) |w| w.window.as(gtk.Widget) else null; + config_errors_dialog.dialog.as(adw.Dialog).present(parent); + }, + adw.MessageDialog => config_errors_dialog.dialog.as(gtk.Window).present(), else => unreachable, } } fn onResponse(_: *DialogType, response: [*:0]const u8, app: *App) callconv(.c) void { + if (app.config_errors_dialog) |config_errors_dialog| config_errors_dialog.builder.deinit(); + app.config_errors_dialog = null; + if (std.mem.orderZ(u8, response, "reload") == .eq) { app.reloadConfig(.app, .{}) catch |err| { log.warn("error reloading config error={}", .{err}); diff --git a/src/apprt/gtk/GlobalShortcuts.zig b/src/apprt/gtk/GlobalShortcuts.zig new file mode 100644 index 000000000..ac9dbaa8a --- /dev/null +++ b/src/apprt/gtk/GlobalShortcuts.zig @@ -0,0 +1,421 @@ +const GlobalShortcuts = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); + +const App = @import("App.zig"); +const configpkg = @import("../../config.zig"); +const Binding = @import("../../input.zig").Binding; +const key = @import("key.zig"); + +const log = std.log.scoped(.global_shortcuts); +const Token = [16]u8; + +app: *App, +arena: std.heap.ArenaAllocator, +dbus: *gio.DBusConnection, + +/// A mapping from a unique ID to an action. +/// Currently the unique ID is simply the serialized representation of the +/// trigger that was used for the action as triggers are unique in the keymap, +/// but this may change in the future. +map: std.StringArrayHashMapUnmanaged(Binding.Action) = .{}, + +/// The handle of the current global shortcuts portal session, +/// as a D-Bus object path. +handle: ?[:0]const u8 = null, + +/// The D-Bus signal subscription for the response signal on requests. +/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. +response_subscription: c_uint = 0, + +/// The D-Bus signal subscription for the keybind activate signal. +/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. +activate_subscription: c_uint = 0, + +pub fn init(alloc: Allocator, gio_app: *gio.Application) ?GlobalShortcuts { + const dbus = gio_app.getDbusConnection() orelse return null; + + return .{ + // To be initialized later + .app = undefined, + .arena = .init(alloc), + .dbus = dbus, + }; +} + +pub fn deinit(self: *GlobalShortcuts) void { + self.close(); + self.arena.deinit(); +} + +fn close(self: *GlobalShortcuts) void { + if (self.response_subscription != 0) { + self.dbus.signalUnsubscribe(self.response_subscription); + self.response_subscription = 0; + } + + if (self.activate_subscription != 0) { + self.dbus.signalUnsubscribe(self.activate_subscription); + self.activate_subscription = 0; + } + + if (self.handle) |handle| { + // Close existing session + self.dbus.call( + "org.freedesktop.portal.Desktop", + handle, + "org.freedesktop.portal.Session", + "Close", + null, + null, + .{}, + -1, + null, + null, + null, + ); + self.handle = null; + } +} + +pub fn refreshSession(self: *GlobalShortcuts, app: *App) !void { + // Ensure we have a valid reference to the app + // (it was left uninitialized in `init`) + self.app = app; + + // Close any existing sessions + self.close(); + + // Update map + var trigger_buf: [256]u8 = undefined; + + self.map.clearRetainingCapacity(); + var it = self.app.config.keybind.set.bindings.iterator(); + + while (it.next()) |entry| { + const leaf = switch (entry.value_ptr.*) { + // Global shortcuts can't have leaders + .leader => continue, + .leaf => |leaf| leaf, + }; + if (!leaf.flags.global) continue; + + const trigger = try key.xdgShortcutFromTrigger( + &trigger_buf, + entry.key_ptr.*, + ) orelse continue; + + try self.map.put( + self.arena.allocator(), + try self.arena.allocator().dupeZ(u8, trigger), + leaf.action, + ); + } + + if (self.map.count() > 0) { + try self.request(.create_session); + } +} + +fn shortcutActivated( + _: *gio.DBusConnection, + _: ?[*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params: *glib.Variant, + ud: ?*anyopaque, +) callconv(.c) void { + const self: *GlobalShortcuts = @ptrCast(@alignCast(ud)); + + // 2nd value in the tuple is the activated shortcut ID + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-activated + var shortcut_id: [*:0]const u8 = undefined; + params.getChild(1, "&s", &shortcut_id); + log.debug("activated={s}", .{shortcut_id}); + + const action = self.map.get(std.mem.span(shortcut_id)) orelse return; + + self.app.core_app.performAllAction(self.app, action) catch |err| { + log.err("failed to perform action={}", .{err}); + }; +} + +const Method = enum { + create_session, + bind_shortcuts, + + fn name(self: Method) [:0]const u8 { + return switch (self) { + .create_session => "CreateSession", + .bind_shortcuts => "BindShortcuts", + }; + } + + /// Construct the payload expected by the XDG portal call. + fn makePayload( + self: Method, + shortcuts: *GlobalShortcuts, + request_token: [:0]const u8, + ) ?*glib.Variant { + switch (self) { + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession + .create_session => { + var session_token: Token = undefined; + return glib.Variant.newParsed( + "({'handle_token': <%s>, 'session_handle_token': <%s>},)", + request_token.ptr, + generateToken(&session_token).ptr, + ); + }, + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts + .bind_shortcuts => { + const handle = shortcuts.handle orelse return null; + + const bind_type = glib.VariantType.new("a(sa{sv})"); + defer glib.free(bind_type); + + var binds: glib.VariantBuilder = undefined; + glib.VariantBuilder.init(&binds, bind_type); + + var action_buf: [256]u8 = undefined; + + var it = shortcuts.map.iterator(); + while (it.next()) |entry| { + const trigger = entry.key_ptr.*.ptr; + const action = std.fmt.bufPrintZ( + &action_buf, + "{}", + .{entry.value_ptr.*}, + ) catch continue; + + binds.addParsed( + "(%s, {'description': <%s>, 'preferred_trigger': <%s>})", + trigger, + action.ptr, + trigger, + ); + } + + return glib.Variant.newParsed( + "(%o, %*, '', {'handle_token': <%s>})", + handle.ptr, + binds.end(), + request_token.ptr, + ); + }, + } + } + + fn onResponse(self: Method, shortcuts: *GlobalShortcuts, vardict: *glib.Variant) void { + switch (self) { + .create_session => { + var handle: ?[*:0]u8 = null; + if (vardict.lookup("session_handle", "&s", &handle) == 0) { + log.err( + "session handle not found in response={s}", + .{vardict.print(@intFromBool(true))}, + ); + return; + } + + shortcuts.handle = shortcuts.arena.allocator().dupeZ(u8, std.mem.span(handle.?)) catch { + log.err("out of memory: failed to clone session handle", .{}); + return; + }; + + log.debug("session_handle={?s}", .{handle}); + + // Subscribe to keybind activations + shortcuts.activate_subscription = shortcuts.dbus.signalSubscribe( + null, + "org.freedesktop.portal.GlobalShortcuts", + "Activated", + "/org/freedesktop/portal/desktop", + handle, + .{ .match_arg0_path = true }, + shortcutActivated, + shortcuts, + null, + ); + + shortcuts.request(.bind_shortcuts) catch |err| { + log.err("failed to bind shortcuts={}", .{err}); + return; + }; + }, + .bind_shortcuts => {}, + } + } +}; + +/// Submit a request to the global shortcuts portal. +fn request( + self: *GlobalShortcuts, + comptime method: Method, +) !void { + // NOTE(pluiedev): + // XDG Portals are really, really poorly-designed pieces of hot garbage. + // How the protocol is _initially_ designed to work is as follows: + // + // 1. The client calls a method which returns the path of a Request object; + // 2. The client waits for the Response signal under said object path; + // 3. When the signal arrives, the actual return value and status code + // become available for the client for further processing. + // + // THIS DOES NOT WORK. Once the first two steps are complete, the client + // needs to immediately start listening for the third step, but an overeager + // server implementation could easily send the Response signal before the + // client is even ready, causing communications to break down over a simple + // race condition/two generals' problem that even _TCP_ had figured out + // decades ago. Worse yet, you get exactly _one_ chance to listen for the + // signal, or else your communication attempt so far has all been in vain. + // + // And they know this. Instead of fixing their freaking protocol, they just + // ask clients to manually construct the expected object path and subscribe + // to the request signal beforehand, making the whole response value of + // the original call COMPLETELY MEANINGLESS. + // + // Furthermore, this is _entirely undocumented_ aside from one tiny + // paragraph under the documentation for the Request interface, and + // anyone would be forgiven for missing it without reading the libportal + // source code. + // + // When in Rome, do as the Romans do, I guess...? + + const callbacks = struct { + fn gotResponseHandle( + source: ?*gobject.Object, + res: *gio.AsyncResult, + _: ?*anyopaque, + ) callconv(.c) void { + const dbus_ = gobject.ext.cast(gio.DBusConnection, source.?).?; + + var err: ?*glib.Error = null; + defer if (err) |err_| err_.free(); + + const params_ = dbus_.callFinish(res, &err) orelse { + if (err) |err_| log.err("request failed={s} ({})", .{ + err_.f_message orelse "(unknown)", + err_.f_code, + }); + return; + }; + defer params_.unref(); + + // TODO: XDG recommends updating the signal subscription if the actual + // returned request path is not the same as the expected request + // path, to retain compatibility with older versions of XDG portals. + // Although it suffers from the race condition outlined above, + // we should still implement this at some point. + } + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html#org-freedesktop-portal-request-response + fn responded( + dbus: *gio.DBusConnection, + _: ?[*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params_: *glib.Variant, + ud: ?*anyopaque, + ) callconv(.c) void { + const self_: *GlobalShortcuts = @ptrCast(@alignCast(ud)); + + // Unsubscribe from the response signal + if (self_.response_subscription != 0) { + dbus.signalUnsubscribe(self_.response_subscription); + self_.response_subscription = 0; + } + + var response: u32 = 0; + var vardict: ?*glib.Variant = null; + params_.get("(u@a{sv})", &response, &vardict); + + switch (response) { + 0 => { + log.debug("request successful", .{}); + method.onResponse(self_, vardict.?); + }, + 1 => log.debug("request was cancelled by user", .{}), + 2 => log.warn("request ended unexpectedly", .{}), + else => log.err("unrecognized response code={}", .{response}), + } + } + }; + + var request_token_buf: Token = undefined; + const request_token = generateToken(&request_token_buf); + + const payload = method.makePayload(self, request_token) orelse return; + const request_path = try self.getRequestPath(request_token); + + self.response_subscription = self.dbus.signalSubscribe( + null, + "org.freedesktop.portal.Request", + "Response", + request_path, + null, + .{}, + callbacks.responded, + self, + null, + ); + + self.dbus.call( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.GlobalShortcuts", + method.name(), + payload, + null, + .{}, + -1, + null, + callbacks.gotResponseHandle, + null, + ); +} + +/// Generate a random token suitable for use in requests. +fn generateToken(buf: *Token) [:0]const u8 { + // u28 takes up 7 bytes in hex, 8 bytes for "ghostty_" and 1 byte for NUL + // 7 + 8 + 1 = 16 + return std.fmt.bufPrintZ( + buf, + "ghostty_{x:0<7}", + .{std.crypto.random.int(u28)}, + ) catch unreachable; +} + +/// Get the XDG portal request path for the current Ghostty instance. +/// +/// If this sounds like nonsense, see `request` for an explanation as to +/// why we need to do this. +fn getRequestPath(self: *GlobalShortcuts, token: [:0]const u8) ![:0]const u8 { + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html + // for the syntax XDG portals expect. + + // `getUniqueName` should never return null here as we're using an ordinary + // message bus connection. If it doesn't, something is very wrong + const unique_name = std.mem.span(self.dbus.getUniqueName().?); + + const object_path = try std.mem.joinZ(self.arena.allocator(), "/", &.{ + "/org/freedesktop/portal/desktop/request", + unique_name[1..], // Remove leading `:` + token, + }); + + // Sanitize the unique name by replacing every `.` with `_`. + // In effect, this will turn a unique name like `:1.192` into `1_192`. + // Valid D-Bus object path components never contain `.`s anyway, so we're + // free to replace all instances of `.` here and avoid extra allocation. + std.mem.replaceScalar(u8, object_path, '.', '_'); + + return object_path; +} diff --git a/src/apprt/gtk/ResizeOverlay.zig b/src/apprt/gtk/ResizeOverlay.zig index 767cf097d..2ab59624a 100644 --- a/src/apprt/gtk/ResizeOverlay.zig +++ b/src/apprt/gtk/ResizeOverlay.zig @@ -50,12 +50,12 @@ first: bool = true, pub fn init(self: *ResizeOverlay, surface: *Surface, config: *const configpkg.Config) void { self.* = .{ .surface = surface, - .config = DerivedConfig.init(config), + .config = .init(config), }; } pub fn updateConfig(self: *ResizeOverlay, config: *const configpkg.Config) void { - self.config = DerivedConfig.init(config); + self.config = .init(config); } /// De-initialize the ResizeOverlay. This removes any pending idlers/timers that diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig index 9caa9ab56..fb719c3c9 100644 --- a/src/apprt/gtk/Split.zig +++ b/src/apprt/gtk/Split.zig @@ -138,7 +138,7 @@ pub fn init( .container = container, .top_left = .{ .surface = tl }, .bottom_right = .{ .surface = br }, - .orientation = Orientation.fromDirection(direction), + .orientation = .fromDirection(direction), }; // Replace the previous containers element with our split. This allows a diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 7ff96480e..1e5b1bfe8 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -746,7 +746,21 @@ pub fn deinit(self: *Surface) void { self.core_surface.deinit(); self.core_surface = undefined; - if (self.cgroup_path) |path| self.app.core_app.alloc.free(path); + // Remove the cgroup if we have one. We do this after deiniting the core + // surface to ensure all processes have exited. + if (self.cgroup_path) |path| { + internal_os.cgroup.remove(path) catch |err| { + // We don't want this to be fatal in any way so we just log + // and continue. A dangling empty cgroup is not a big deal + // and this should be rare. + log.warn( + "failed to remove cgroup for surface path={s} err={}", + .{ path, err }, + ); + }; + + self.app.core_app.alloc.free(path); + } // Free all our GTK stuff // @@ -1191,7 +1205,7 @@ pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { return; } - self.url_widget = URLWidget.init(self.overlay, uriZ); + self.url_widget = .init(self.overlay, uriZ); } pub fn supportsClipboard( @@ -1563,7 +1577,7 @@ fn gtkMouseMotion( const scaled = self.scaledCoordinates(x, y); const pos: apprt.CursorPos = .{ - .x = @floatCast(@max(0, scaled.x)), + .x = @floatCast(scaled.x), .y = @floatCast(scaled.y), }; @@ -1840,7 +1854,7 @@ pub fn keyEvent( // (These are keybinds explicitly marked as requesting physical mapping). const physical_key = keycode: for (input.keycodes.entries) |entry| { if (entry.native == keycode) break :keycode entry.key; - } else .invalid; + } else .unidentified; // Get our modifier for the event const mods: input.Mods = gtk_key.eventMods( @@ -1861,52 +1875,6 @@ pub fn keyEvent( break :consumed gtk_key.translateMods(@bitCast(masked)); }; - // If we're not in a dead key state, we want to translate our text - // to some input.Key. - const key = if (!self.im_composing) key: { - // First, try to convert the keyval directly to a key. This allows the - // use of key remapping and identification of keypad numerics (as - // opposed to their ASCII counterparts) - if (gtk_key.keyFromKeyval(keyval)) |key| { - break :key key; - } - - // A completed key. If the length of the key is one then we can - // attempt to translate it to a key enum and call the key - // callback. First try plain ASCII. - if (self.im_len > 0) { - if (input.Key.fromASCII(self.im_buf[0])) |key| { - break :key key; - } - } - - // If that doesn't work then we try to translate the kevval.. - if (keyval_unicode != 0) { - if (std.math.cast(u8, keyval_unicode)) |byte| { - if (input.Key.fromASCII(byte)) |key| { - break :key key; - } - } - } - - // If that doesn't work we use the unshifted value... - if (std.math.cast(u8, keyval_unicode_unshifted)) |ascii| { - if (input.Key.fromASCII(ascii)) |key| { - break :key key; - } - } - - // If we have im text then this is invalid. This means that - // the keypress generated some character that we don't know about - // in our key enum. We don't want to use the physical key because - // it can be simply wrong. For example on "Turkish Q" the "i" key - // on a US layout results in "ı" which is not the same as "i" so - // we shouldn't use the physical key. - if (self.im_len > 0 or keyval_unicode_unshifted != 0) break :key .invalid; - - break :key physical_key; - } else .invalid; - // log.debug("key pressed key={} keyval={x} physical_key={} composing={} text_len={} mods={}", .{ // key, // keyval, @@ -1936,8 +1904,7 @@ pub fn keyEvent( // Invoke the core Ghostty logic to handle this input. const effect = self.core_surface.keyCallback(.{ .action = action, - .key = key, - .physical_key = physical_key, + .key = physical_key, .mods = mods, .consumed_mods = consumed_mods, .composing = self.im_composing, @@ -2088,8 +2055,7 @@ fn gtkInputCommit( // invalid key, which should produce no PTY encoding). _ = self.core_surface.keyCallback(.{ .action = .press, - .key = .invalid, - .physical_key = .invalid, + .key = .unidentified, .mods = .{}, .consumed_mods = .{}, .composing = false, @@ -2453,6 +2419,48 @@ pub fn ringBell(self: *Surface) !void { surface.beep(); } + if (features.audio) audio: { + // Play a user-specified audio file. + + const pathname, const required = switch (self.app.config.@"bell-audio-path" orelse break :audio) { + .optional => |path| .{ path, false }, + .required => |path| .{ path, true }, + }; + + const volume = std.math.clamp(self.app.config.@"bell-audio-volume", 0.0, 1.0); + + std.debug.assert(std.fs.path.isAbsolute(pathname)); + const media_file = gtk.MediaFile.newForFilename(pathname); + + if (required) { + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + gtkStreamError, + null, + .{ .detail = "error" }, + ); + } + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + gtkStreamEnded, + null, + .{ .detail = "ended" }, + ); + + const media_stream = media_file.as(gtk.MediaStream); + media_stream.setVolume(volume); + media_stream.play(); + } + + if (features.attention) { + // Request user attention + window.winproto.setUrgent(true) catch |err| { + log.err("failed to request user attention={}", .{err}); + }; + } + // Mark tab as needing attention if (self.container.tab()) |tab| tab: { const page = window.notebook.getTabPage(tab) orelse break :tab; @@ -2461,3 +2469,27 @@ pub fn ringBell(self: *Surface) !void { if (page.getSelected() == 0) page.setNeedsAttention(@intFromBool(true)); } } + +/// Handle a stream that is in an error state. +fn gtkStreamError(media_file: *gtk.MediaFile, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void { + const path = path: { + const file = media_file.getFile() orelse break :path null; + break :path file.getPath(); + }; + defer if (path) |p| glib.free(p); + + const media_stream = media_file.as(gtk.MediaStream); + const err = media_stream.getError() orelse return; + + log.warn("error playing bell from {s}: {s} {d} {s}", .{ + path orelse "<>", + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "", + }); +} + +/// Stream is finished, release the memory. +fn gtkStreamEnded(media_file: *gtk.MediaFile, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void { + media_file.unref(); +} diff --git a/src/apprt/gtk/TabView.zig b/src/apprt/gtk/TabView.zig index 29a069a6d..8a4145b5f 100644 --- a/src/apprt/gtk/TabView.zig +++ b/src/apprt/gtk/TabView.zig @@ -7,6 +7,7 @@ const std = @import("std"); const gtk = @import("gtk"); const adw = @import("adw"); const gobject = @import("gobject"); +const glib = @import("glib"); const Window = @import("Window.zig"); const Tab = @import("Tab.zig"); @@ -243,7 +244,14 @@ fn adwClosePage( const child = page.getChild().as(gobject.Object); const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return 0)); self.tab_view.closePageFinish(page, @intFromBool(self.forcing_close)); - if (!self.forcing_close) tab.closeWithConfirmation(); + if (!self.forcing_close) { + // We cannot trigger a close directly in here as the page will stay + // alive until this handler returns, breaking the assumption where + // no pages means they are all destroyed. + // + // Schedule the close request to happen in the next event cycle. + _ = glib.idleAddOnce(glibIdleOnceCloseTab, tab); + } return 1; } @@ -269,3 +277,8 @@ fn adwSelectPage(_: *adw.TabView, _: *gobject.ParamSpec, self: *TabView) callcon const title = page.getTitle(); self.window.setTitle(std.mem.span(title)); } + +fn glibIdleOnceCloseTab(data: ?*anyopaque) callconv(.c) void { + const tab: *Tab = @ptrCast(@alignCast(data orelse return)); + tab.closeWithConfirmation(); +} diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index d82087ff0..f911ccbc1 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -34,6 +34,7 @@ const gtk_key = @import("key.zig"); const TabView = @import("TabView.zig"); const HeaderBar = @import("headerbar.zig"); const CloseDialog = @import("CloseDialog.zig"); +const CommandPalette = @import("CommandPalette.zig"); const winprotopkg = @import("winproto.zig"); const gtk_version = @import("gtk_version.zig"); const adw_version = @import("adw_version.zig"); @@ -67,6 +68,9 @@ titlebar_menu: Menu(Window, "titlebar_menu", true), /// The libadwaita widget for receiving toast send requests. toast_overlay: *adw.ToastOverlay, +/// The command palette. +command_palette: CommandPalette, + /// See adwTabOverviewOpen for why we have this. adw_tab_overview_focus_timer: ?c_uint = null, @@ -86,6 +90,7 @@ pub const DerivedConfig = struct { quick_terminal_position: configpkg.Config.QuickTerminalPosition, quick_terminal_size: configpkg.Config.QuickTerminalSize, quick_terminal_autohide: bool, + quick_terminal_keyboard_interactivity: configpkg.Config.QuickTerminalKeyboardInteractivity, maximize: bool, fullscreen: bool, @@ -105,6 +110,7 @@ pub const DerivedConfig = struct { .quick_terminal_position = config.@"quick-terminal-position", .quick_terminal_size = config.@"quick-terminal-size", .quick_terminal_autohide = config.@"quick-terminal-autohide", + .quick_terminal_keyboard_interactivity = config.@"quick-terminal-keyboard-interactivity", .maximize = config.maximize, .fullscreen = config.fullscreen, @@ -132,18 +138,19 @@ pub fn init(self: *Window, app: *App) !void { self.* = .{ .app = app, .last_config = @intFromPtr(&app.config), - .config = DerivedConfig.init(&app.config), + .config = .init(&app.config), .window = undefined, .headerbar = undefined, .tab_overview = null, .notebook = undefined, .titlebar_menu = undefined, .toast_overlay = undefined, + .command_palette = undefined, .winproto = .none, }; // Create the window - self.window = adw.ApplicationWindow.new(app.app.as(gtk.Application)); + self.window = .new(app.app.as(gtk.Application)); const gtk_window = self.window.as(gtk.Window); const gtk_widget = self.window.as(gtk.Widget); errdefer gtk_window.destroy(); @@ -167,6 +174,8 @@ pub fn init(self: *Window, app: *App) !void { // Setup our notebook self.notebook.init(self); + if (adw_version.supportsDialogs()) try self.command_palette.init(self); + // If we are using Adwaita, then we can support the tab overview. self.tab_overview = if (adw_version.supportsTabOverview()) overview: { const tab_overview = adw.TabOverview.new(); @@ -326,7 +335,7 @@ pub fn init(self: *Window, app: *App) !void { } // Setup our toast overlay if we have one - self.toast_overlay = adw.ToastOverlay.new(); + self.toast_overlay = .new(); self.toast_overlay.setChild(self.notebook.asWidget()); box.append(self.toast_overlay.as(gtk.Widget)); @@ -456,10 +465,13 @@ pub fn updateConfig( if (self.last_config == this_config) return; self.last_config = this_config; - self.config = DerivedConfig.init(config); + self.config = .init(config); // We always resync our appearance whenever the config changes. try self.syncAppearance(); + + // Update binds inside the command palette + try self.command_palette.updateConfig(config); } /// Updates appearance based on config settings. Will be called once upon window @@ -577,6 +589,7 @@ fn initActions(self: *Window) void { .{ "split-left", gtkActionSplitLeft }, .{ "split-up", gtkActionSplitUp }, .{ "toggle-inspector", gtkActionToggleInspector }, + .{ "toggle-command-palette", gtkActionToggleCommandPalette }, .{ "copy", gtkActionCopy }, .{ "paste", gtkActionPaste }, .{ "reset", gtkActionReset }, @@ -600,6 +613,7 @@ fn initActions(self: *Window) void { pub fn deinit(self: *Window) void { self.winproto.deinit(self.app.core_app.alloc); + if (adw_version.supportsDialogs()) self.command_palette.deinit(); if (self.adw_tab_overview_focus_timer) |timer| { _ = glib.Source.remove(timer); @@ -729,6 +743,15 @@ pub fn toggleWindowDecorations(self: *Window) void { }; } +/// Toggle the window decorations for this window. +pub fn toggleCommandPalette(self: *Window) void { + if (adw_version.supportsDialogs()) { + self.command_palette.toggle(); + } else { + log.warn("libadwaita 1.5+ is required for the command palette", .{}); + } +} + /// Grabs focus on the currently selected tab. pub fn focusCurrentTab(self: *Window) void { const tab = self.notebook.currentTab() orelse return; @@ -793,11 +816,15 @@ fn gtkWindowNotifyIsActive( _: *gobject.ParamSpec, self: *Window, ) callconv(.c) void { - if (!self.isQuickTerminal()) return; + self.winproto.setUrgent(false) catch |err| { + log.err("failed to unrequest user attention={}", .{err}); + }; - // Hide when we're unfocused - if (self.config.quick_terminal_autohide and self.window.as(gtk.Window).isActive() == 0) { - self.toggleVisibility(); + if (self.isQuickTerminal()) { + // Hide when we're unfocused + if (self.config.quick_terminal_autohide and self.window.as(gtk.Window).isActive() == 0) { + self.toggleVisibility(); + } } } @@ -820,7 +847,7 @@ fn gtkWindowUpdateScaleFactor( } /// Perform a binding action on the window's action surface. -fn performBindingAction(self: *Window, action: input.Binding.Action) void { +pub fn performBindingAction(self: *Window, action: input.Binding.Action) void { const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(action) catch |err| { log.warn("error performing binding action error={}", .{err}); @@ -1082,6 +1109,14 @@ fn gtkActionToggleInspector( self.performBindingAction(.{ .inspector = .toggle }); } +fn gtkActionToggleCommandPalette( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, +) callconv(.C) void { + self.performBindingAction(.toggle_command_palette); +} + fn gtkActionCopy( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig index a1db8ac62..45623ab2a 100644 --- a/src/apprt/gtk/gresource.zig +++ b/src/apprt/gtk/gresource.zig @@ -63,6 +63,7 @@ pub const blueprint_files = [_]VersionedBlueprint{ .{ .major = 1, .minor = 5, .name = "prompt-title-dialog" }, .{ .major = 1, .minor = 5, .name = "config-errors-dialog" }, .{ .major = 1, .minor = 0, .name = "menu-headerbar-split_menu" }, + .{ .major = 1, .minor = 5, .name = "command-palette" }, .{ .major = 1, .minor = 0, .name = "menu-surface-context_menu" }, .{ .major = 1, .minor = 0, .name = "menu-window-titlebar_menu" }, .{ .major = 1, .minor = 5, .name = "ccw-osc-52-read" }, diff --git a/src/apprt/gtk/inspector.zig b/src/apprt/gtk/inspector.zig index e3e61e258..3adeb9711 100644 --- a/src/apprt/gtk/inspector.zig +++ b/src/apprt/gtk/inspector.zig @@ -138,7 +138,7 @@ const Window = struct { }; // Create the window - self.window = gtk.ApplicationWindow.new(inspector.surface.app.app.as(gtk.Application)); + self.window = .new(inspector.surface.app.app.as(gtk.Application)); errdefer self.window.as(gtk.Window).destroy(); self.window.as(gtk.Window).setTitle(i18n._("Ghostty: Terminal Inspector")); diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 2e00552a6..fc3296366 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -20,10 +20,45 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u if (trigger.mods.super) try writer.writeAll(""); // Write our key + if (!try writeTriggerKey(writer, trigger)) return null; + + // We need to make the string null terminated. + try writer.writeByte(0); + const slice = buf_stream.getWritten(); + return slice[0 .. slice.len - 1 :0]; +} + +/// Returns a XDG-compliant shortcuts string from a trigger. +/// Spec: https://specifications.freedesktop.org/shortcuts-spec/latest/ +pub fn xdgShortcutFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { + var buf_stream = std.io.fixedBufferStream(buf); + const writer = buf_stream.writer(); + + // Modifiers + if (trigger.mods.shift) try writer.writeAll("SHIFT+"); + if (trigger.mods.ctrl) try writer.writeAll("CTRL+"); + if (trigger.mods.alt) try writer.writeAll("ALT+"); + if (trigger.mods.super) try writer.writeAll("LOGO+"); + + // Write our key + // NOTE: While the spec specifies that only libxkbcommon keysyms are + // expected, using GTK's keysyms should still work as they are identical + // to *X11's* keysyms (which I assume is a subset of libxkbcommon's). + // I haven't been able to any evidence to back up that assumption but + // this works for now + if (!try writeTriggerKey(writer, trigger)) return null; + + // We need to make the string null terminated. + try writer.writeByte(0); + const slice = buf_stream.getWritten(); + return slice[0 .. slice.len - 1 :0]; +} + +fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) !bool { switch (trigger.key) { - .physical, .translated => |k| { - const keyval = keyvalFromKey(k) orelse return null; - try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return null)); + .physical => |k| { + const keyval = keyvalFromKey(k) orelse return false; + try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return false)); }, .unicode => |cp| { @@ -35,10 +70,7 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u }, } - // We need to make the string null terminated. - try writer.writeByte(0); - const slice = buf_stream.getWritten(); - return slice[0 .. slice.len - 1 :0]; + return true; } pub fn translateMods(state: gdk.ModifierType) input.Mods { @@ -122,42 +154,42 @@ pub fn eventMods( // if only the modifier key is pressed, but our core logic // relies on it. switch (physical_key) { - .left_shift => { + .shift_left => { mods.shift = action != .release; mods.sides.shift = .left; }, - .right_shift => { + .shift_right => { mods.shift = action != .release; mods.sides.shift = .right; }, - .left_control => { + .control_left => { mods.ctrl = action != .release; mods.sides.ctrl = .left; }, - .right_control => { + .control_right => { mods.ctrl = action != .release; mods.sides.ctrl = .right; }, - .left_alt => { + .alt_left => { mods.alt = action != .release; mods.sides.alt = .left; }, - .right_alt => { + .alt_right => { mods.alt = action != .release; mods.sides.alt = .right; }, - .left_super => { + .meta_left => { mods.super = action != .release; mods.sides.super = .left; }, - .right_super => { + .meta_right => { mods.super = action != .release; mods.sides.super = .right; }, @@ -182,7 +214,7 @@ pub fn keyvalFromKey(key: input.Key) ?c_uint { switch (key) { inline else => |key_comptime| { return comptime value: { - @setEvalBranchQuota(10_000); + @setEvalBranchQuota(50_000); for (keymap) |entry| { if (entry[1] == key_comptime) break :value entry[0]; } @@ -199,7 +231,7 @@ test "accelFromTrigger" { try testing.expectEqualStrings("q", (try accelFromTrigger(&buf, .{ .mods = .{ .super = true }, - .key = .{ .translated = .q }, + .key = .{ .unicode = 'q' }, })).?); try testing.expectEqualStrings("backslash", (try accelFromTrigger(&buf, .{ @@ -208,66 +240,81 @@ test "accelFromTrigger" { })).?); } +test "xdgShortcutFromTrigger" { + const testing = std.testing; + var buf: [256]u8 = undefined; + + try testing.expectEqualStrings("LOGO+q", (try xdgShortcutFromTrigger(&buf, .{ + .mods = .{ .super = true }, + .key = .{ .unicode = 'q' }, + })).?); + + try testing.expectEqualStrings("SHIFT+CTRL+ALT+LOGO+backslash", (try xdgShortcutFromTrigger(&buf, .{ + .mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true }, + .key = .{ .unicode = 92 }, + })).?); +} + /// A raw entry in the keymap. Our keymap contains mappings between /// GDK keys and our own key enum. const RawEntry = struct { c_uint, input.Key }; const keymap: []const RawEntry = &.{ - .{ gdk.KEY_a, .a }, - .{ gdk.KEY_b, .b }, - .{ gdk.KEY_c, .c }, - .{ gdk.KEY_d, .d }, - .{ gdk.KEY_e, .e }, - .{ gdk.KEY_f, .f }, - .{ gdk.KEY_g, .g }, - .{ gdk.KEY_h, .h }, - .{ gdk.KEY_i, .i }, - .{ gdk.KEY_j, .j }, - .{ gdk.KEY_k, .k }, - .{ gdk.KEY_l, .l }, - .{ gdk.KEY_m, .m }, - .{ gdk.KEY_n, .n }, - .{ gdk.KEY_o, .o }, - .{ gdk.KEY_p, .p }, - .{ gdk.KEY_q, .q }, - .{ gdk.KEY_r, .r }, - .{ gdk.KEY_s, .s }, - .{ gdk.KEY_t, .t }, - .{ gdk.KEY_u, .u }, - .{ gdk.KEY_v, .v }, - .{ gdk.KEY_w, .w }, - .{ gdk.KEY_x, .x }, - .{ gdk.KEY_y, .y }, - .{ gdk.KEY_z, .z }, + .{ gdk.KEY_a, .key_a }, + .{ gdk.KEY_b, .key_b }, + .{ gdk.KEY_c, .key_c }, + .{ gdk.KEY_d, .key_d }, + .{ gdk.KEY_e, .key_e }, + .{ gdk.KEY_f, .key_f }, + .{ gdk.KEY_g, .key_g }, + .{ gdk.KEY_h, .key_h }, + .{ gdk.KEY_i, .key_i }, + .{ gdk.KEY_j, .key_j }, + .{ gdk.KEY_k, .key_k }, + .{ gdk.KEY_l, .key_l }, + .{ gdk.KEY_m, .key_m }, + .{ gdk.KEY_n, .key_n }, + .{ gdk.KEY_o, .key_o }, + .{ gdk.KEY_p, .key_p }, + .{ gdk.KEY_q, .key_q }, + .{ gdk.KEY_r, .key_r }, + .{ gdk.KEY_s, .key_s }, + .{ gdk.KEY_t, .key_t }, + .{ gdk.KEY_u, .key_u }, + .{ gdk.KEY_v, .key_v }, + .{ gdk.KEY_w, .key_w }, + .{ gdk.KEY_x, .key_x }, + .{ gdk.KEY_y, .key_y }, + .{ gdk.KEY_z, .key_z }, - .{ gdk.KEY_0, .zero }, - .{ gdk.KEY_1, .one }, - .{ gdk.KEY_2, .two }, - .{ gdk.KEY_3, .three }, - .{ gdk.KEY_4, .four }, - .{ gdk.KEY_5, .five }, - .{ gdk.KEY_6, .six }, - .{ gdk.KEY_7, .seven }, - .{ gdk.KEY_8, .eight }, - .{ gdk.KEY_9, .nine }, + .{ gdk.KEY_0, .digit_0 }, + .{ gdk.KEY_1, .digit_1 }, + .{ gdk.KEY_2, .digit_2 }, + .{ gdk.KEY_3, .digit_3 }, + .{ gdk.KEY_4, .digit_4 }, + .{ gdk.KEY_5, .digit_5 }, + .{ gdk.KEY_6, .digit_6 }, + .{ gdk.KEY_7, .digit_7 }, + .{ gdk.KEY_8, .digit_8 }, + .{ gdk.KEY_9, .digit_9 }, .{ gdk.KEY_semicolon, .semicolon }, .{ gdk.KEY_space, .space }, - .{ gdk.KEY_apostrophe, .apostrophe }, + .{ gdk.KEY_apostrophe, .quote }, .{ gdk.KEY_comma, .comma }, - .{ gdk.KEY_grave, .grave_accent }, + .{ gdk.KEY_grave, .backquote }, .{ gdk.KEY_period, .period }, .{ gdk.KEY_slash, .slash }, .{ gdk.KEY_minus, .minus }, .{ gdk.KEY_equal, .equal }, - .{ gdk.KEY_bracketleft, .left_bracket }, - .{ gdk.KEY_bracketright, .right_bracket }, + .{ gdk.KEY_bracketleft, .bracket_left }, + .{ gdk.KEY_bracketright, .bracket_right }, .{ gdk.KEY_backslash, .backslash }, - .{ gdk.KEY_Up, .up }, - .{ gdk.KEY_Down, .down }, - .{ gdk.KEY_Right, .right }, - .{ gdk.KEY_Left, .left }, + .{ gdk.KEY_Up, .arrow_up }, + .{ gdk.KEY_Down, .arrow_down }, + .{ gdk.KEY_Right, .arrow_right }, + .{ gdk.KEY_Left, .arrow_left }, .{ gdk.KEY_Home, .home }, .{ gdk.KEY_End, .end }, .{ gdk.KEY_Insert, .insert }, @@ -310,45 +357,49 @@ const keymap: []const RawEntry = &.{ .{ gdk.KEY_F24, .f24 }, .{ gdk.KEY_F25, .f25 }, - .{ gdk.KEY_KP_0, .kp_0 }, - .{ gdk.KEY_KP_1, .kp_1 }, - .{ gdk.KEY_KP_2, .kp_2 }, - .{ gdk.KEY_KP_3, .kp_3 }, - .{ gdk.KEY_KP_4, .kp_4 }, - .{ gdk.KEY_KP_5, .kp_5 }, - .{ gdk.KEY_KP_6, .kp_6 }, - .{ gdk.KEY_KP_7, .kp_7 }, - .{ gdk.KEY_KP_8, .kp_8 }, - .{ gdk.KEY_KP_9, .kp_9 }, - .{ gdk.KEY_KP_Decimal, .kp_decimal }, - .{ gdk.KEY_KP_Divide, .kp_divide }, - .{ gdk.KEY_KP_Multiply, .kp_multiply }, - .{ gdk.KEY_KP_Subtract, .kp_subtract }, - .{ gdk.KEY_KP_Add, .kp_add }, - .{ gdk.KEY_KP_Enter, .kp_enter }, - .{ gdk.KEY_KP_Equal, .kp_equal }, + .{ gdk.KEY_KP_0, .numpad_0 }, + .{ gdk.KEY_KP_1, .numpad_1 }, + .{ gdk.KEY_KP_2, .numpad_2 }, + .{ gdk.KEY_KP_3, .numpad_3 }, + .{ gdk.KEY_KP_4, .numpad_4 }, + .{ gdk.KEY_KP_5, .numpad_5 }, + .{ gdk.KEY_KP_6, .numpad_6 }, + .{ gdk.KEY_KP_7, .numpad_7 }, + .{ gdk.KEY_KP_8, .numpad_8 }, + .{ gdk.KEY_KP_9, .numpad_9 }, + .{ gdk.KEY_KP_Decimal, .numpad_decimal }, + .{ gdk.KEY_KP_Divide, .numpad_divide }, + .{ gdk.KEY_KP_Multiply, .numpad_multiply }, + .{ gdk.KEY_KP_Subtract, .numpad_subtract }, + .{ gdk.KEY_KP_Add, .numpad_add }, + .{ gdk.KEY_KP_Enter, .numpad_enter }, + .{ gdk.KEY_KP_Equal, .numpad_equal }, - .{ gdk.KEY_KP_Separator, .kp_separator }, - .{ gdk.KEY_KP_Left, .kp_left }, - .{ gdk.KEY_KP_Right, .kp_right }, - .{ gdk.KEY_KP_Up, .kp_up }, - .{ gdk.KEY_KP_Down, .kp_down }, - .{ gdk.KEY_KP_Page_Up, .kp_page_up }, - .{ gdk.KEY_KP_Page_Down, .kp_page_down }, - .{ gdk.KEY_KP_Home, .kp_home }, - .{ gdk.KEY_KP_End, .kp_end }, - .{ gdk.KEY_KP_Insert, .kp_insert }, - .{ gdk.KEY_KP_Delete, .kp_delete }, - .{ gdk.KEY_KP_Begin, .kp_begin }, + .{ gdk.KEY_KP_Separator, .numpad_separator }, + .{ gdk.KEY_KP_Left, .numpad_left }, + .{ gdk.KEY_KP_Right, .numpad_right }, + .{ gdk.KEY_KP_Up, .numpad_up }, + .{ gdk.KEY_KP_Down, .numpad_down }, + .{ gdk.KEY_KP_Page_Up, .numpad_page_up }, + .{ gdk.KEY_KP_Page_Down, .numpad_page_down }, + .{ gdk.KEY_KP_Home, .numpad_home }, + .{ gdk.KEY_KP_End, .numpad_end }, + .{ gdk.KEY_KP_Insert, .numpad_insert }, + .{ gdk.KEY_KP_Delete, .numpad_delete }, + .{ gdk.KEY_KP_Begin, .numpad_begin }, - .{ gdk.KEY_Shift_L, .left_shift }, - .{ gdk.KEY_Control_L, .left_control }, - .{ gdk.KEY_Alt_L, .left_alt }, - .{ gdk.KEY_Super_L, .left_super }, - .{ gdk.KEY_Shift_R, .right_shift }, - .{ gdk.KEY_Control_R, .right_control }, - .{ gdk.KEY_Alt_R, .right_alt }, - .{ gdk.KEY_Super_R, .right_super }, + .{ gdk.KEY_Copy, .copy }, + .{ gdk.KEY_Cut, .cut }, + .{ gdk.KEY_Paste, .paste }, + + .{ gdk.KEY_Shift_L, .shift_left }, + .{ gdk.KEY_Control_L, .control_left }, + .{ gdk.KEY_Alt_L, .alt_left }, + .{ gdk.KEY_Super_L, .meta_left }, + .{ gdk.KEY_Shift_R, .shift_right }, + .{ gdk.KEY_Control_R, .control_right }, + .{ gdk.KEY_Alt_R, .alt_right }, + .{ gdk.KEY_Super_R, .meta_right }, // TODO: media keys }; diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index ecaef6b33..7c4b53d03 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -73,3 +73,19 @@ window.ssd.no-border-radius { filter: blur(5px); transition: filter 0.3s ease; } + +.command-palette-search { + font-size: 1.25rem; + padding: 4px; + -gtk-icon-size: 20px; +} + +.command-palette-search > image:first-child { + margin-left: 8px; + margin-right: 4px; +} + +.command-palette-search > image:last-child { + margin-left: 4px; + margin-right: 8px; +} diff --git a/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp b/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp index 71e7d060c..3273aa81c 100644 --- a/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp +++ b/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp @@ -81,6 +81,11 @@ menu menu { } section { + item { + label: _("Command Palette"); + action: "win.toggle-command-palette"; + } + item { label: _("Terminal Inspector"); action: "win.toggle-inspector"; diff --git a/src/apprt/gtk/ui/1.5/command-palette.blp b/src/apprt/gtk/ui/1.5/command-palette.blp new file mode 100644 index 000000000..a84482091 --- /dev/null +++ b/src/apprt/gtk/ui/1.5/command-palette.blp @@ -0,0 +1,106 @@ +using Gtk 4.0; +using Gio 2.0; +using Adw 1; + +Adw.Dialog command-palette { + content-width: 700; + + Adw.ToolbarView { + top-bar-style: flat; + + [top] + Adw.HeaderBar { + [title] + SearchEntry search { + hexpand: true; + placeholder-text: _("Execute a command…"); + + styles [ + "command-palette-search", + ] + } + } + + ScrolledWindow { + min-content-height: 300; + + ListView view { + show-separators: true; + single-click-activate: true; + + model: SingleSelection model { + model: FilterListModel { + incremental: true; + + filter: AnyFilter { + StringFilter { + expression: expr item as <$GhosttyCommand>.title; + search: bind search.text; + } + + StringFilter { + expression: expr item as <$GhosttyCommand>.action-key; + search: bind search.text; + } + }; + + model: Gio.ListStore source { + item-type: typeof<$GhosttyCommand>; + }; + }; + }; + + styles [ + "rich-list", + ] + + factory: BuilderListItemFactory { + template ListItem { + child: Box { + orientation: horizontal; + spacing: 10; + tooltip-text: bind template.item as <$GhosttyCommand>.description; + + Box { + orientation: vertical; + hexpand: true; + + Label { + ellipsize: end; + halign: start; + wrap: false; + single-line-mode: true; + + styles [ + "title", + ] + + label: bind template.item as <$GhosttyCommand>.title; + } + + Label { + ellipsize: end; + halign: start; + wrap: false; + single-line-mode: true; + + styles [ + "subtitle", + "monospace", + ] + + label: bind template.item as <$GhosttyCommand>.action-key; + } + } + + ShortcutLabel { + accelerator: bind template.item as <$GhosttyCommand>.action; + valign: center; + } + }; + } + }; + } + } + } +} diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig index ff83e6851..2dbe5a7a0 100644 --- a/src/apprt/gtk/winproto.zig +++ b/src/apprt/gtk/winproto.zig @@ -146,4 +146,10 @@ pub const Window = union(Protocol) { inline else => |*v| try v.addSubprocessEnv(env), } } + + pub fn setUrgent(self: *Window, urgent: bool) !void { + switch (self.*) { + inline else => |*v| try v.setUrgent(urgent), + } + } }; diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig index 5cb5887c9..fb732b756 100644 --- a/src/apprt/gtk/winproto/noop.zig +++ b/src/apprt/gtk/winproto/noop.zig @@ -70,4 +70,6 @@ pub const Window = struct { } pub fn addSubprocessEnv(_: *Window, _: *std.process.EnvMap) !void {} + + pub fn setUrgent(_: *Window, _: bool) !void {} }; diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 5f5feca6e..cbe8c01a4 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -6,8 +6,8 @@ const build_options = @import("build_options"); const gdk = @import("gdk"); const gdk_wayland = @import("gdk_wayland"); const gobject = @import("gobject"); -const gtk4_layer_shell = @import("gtk4-layer-shell"); const gtk = @import("gtk"); +const layer_shell = @import("gtk4-layer-shell"); const wayland = @import("wayland"); const Config = @import("../../../config.zig").Config; @@ -16,6 +16,7 @@ const ApprtWindow = @import("../Window.zig"); const wl = wayland.client.wl; const org = wayland.client.org; +const xdg = wayland.client.xdg; const log = std.log.scoped(.winproto_wayland); @@ -34,6 +35,8 @@ pub const App = struct { kde_slide_manager: ?*org.KdeKwinSlideManager = null, default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, + + xdg_activation: ?*xdg.ActivationV1 = null, }; pub fn init( @@ -45,16 +48,11 @@ pub const App = struct { _ = config; _ = app_id; - // Check if we're actually on Wayland - if (gobject.typeCheckInstanceIsA( - gdk_display.as(gobject.TypeInstance), - gdk_wayland.WaylandDisplay.getGObjectType(), - ) == 0) return null; - const gdk_wayland_display = gobject.ext.cast( gdk_wayland.WaylandDisplay, gdk_display, - ) orelse return error.NoWaylandDisplay; + ) orelse return null; + const display: *wl.Display = @ptrCast(@alignCast( gdk_wayland_display.getWlDisplay() orelse return error.NoWaylandDisplay, )); @@ -73,9 +71,9 @@ pub const App = struct { registry.setListener(*Context, registryListener, context); if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - if (context.kde_decoration_manager != null) { - // FIXME: Roundtrip again because we have to wait for the decoration - // manager to respond with the preferred default mode. Ew. + // Do another round-trip to get the default decoration mode + if (context.kde_decoration_manager) |deco_manager| { + deco_manager.setListener(*Context, decoManagerListener, context); if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; } @@ -98,7 +96,7 @@ pub const App = struct { } pub fn supportsQuickTerminal(_: App) bool { - if (!gtk4_layer_shell.isSupported()) { + if (!layer_shell.isSupported()) { log.warn("your compositor does not support the wlr-layer-shell protocol; disabling quick terminal", .{}); return false; } @@ -108,9 +106,9 @@ pub const App = struct { pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void { const window = apprt_window.window.as(gtk.Window); - gtk4_layer_shell.initForWindow(window); - gtk4_layer_shell.setLayer(window, .top); - gtk4_layer_shell.setKeyboardMode(window, .on_demand); + layer_shell.initForWindow(window); + layer_shell.setLayer(window, .top); + layer_shell.setNamespace(window, "ghostty-quick-terminal"); } fn registryListener( @@ -118,71 +116,54 @@ pub const App = struct { event: wl.Registry.Event, context: *Context, ) void { - switch (event) { - // https://wayland.app/protocols/wayland#wl_registry:event:global - .global => |global| { - log.debug("wl_registry.global: interface={s}", .{global.interface}); + inline for (@typeInfo(Context).@"struct".fields) |field| { + // Globals should be optional pointers + const T = switch (@typeInfo(field.type)) { + .optional => |o| switch (@typeInfo(o.child)) { + .pointer => |v| v.child, + else => continue, + }, + else => continue, + }; - if (registryBind( - org.KdeKwinBlurManager, - registry, - global, - )) |blur_manager| { - context.kde_blur_manager = blur_manager; - return; - } + // Only process Wayland interfaces + if (!@hasDecl(T, "interface")) continue; - if (registryBind( - org.KdeKwinServerDecorationManager, - registry, - global, - )) |deco_manager| { - context.kde_decoration_manager = deco_manager; - deco_manager.setListener(*Context, decoManagerListener, context); - return; - } + switch (event) { + .global => |v| global: { + if (std.mem.orderZ( + u8, + v.interface, + T.interface.name, + ) != .eq) break :global; - if (registryBind( - org.KdeKwinSlideManager, - registry, - global, - )) |slide_manager| { - context.kde_slide_manager = slide_manager; - return; - } - }, + @field(context, field.name) = registry.bind( + v.name, + T, + T.generated_version, + ) catch |err| { + log.warn( + "error binding interface {s} error={}", + .{ v.interface, err }, + ); + return; + }; + }, - // We don't handle removal events - .global_remove => {}, + // This should be a rare occurrence, but in case a global + // is suddenly no longer available, we destroy and unset it + // as the protocol mandates. + .global_remove => |v| remove: { + const global = @field(context, field.name) orelse break :remove; + if (global.getId() == v.name) { + global.destroy(); + @field(context, field.name) = null; + } + }, + } } } - /// Bind a Wayland interface to a global object. Returns non-null - /// if the binding was successful, otherwise null. - /// - /// The type T is the Wayland interface type that we're requesting. - /// This function will verify that the global object is the correct - /// interface and version before binding. - fn registryBind( - comptime T: type, - registry: *wl.Registry, - global: anytype, - ) ?*T { - if (std.mem.orderZ( - u8, - global.interface, - T.interface.name, - ) != .eq) return null; - - return registry.bind(global.name, T, T.generated_version) catch |err| { - log.warn("error binding interface {s} error={}", .{ - global.interface, - err, - }); - return null; - }; - } - fn decoManagerListener( _: *org.KdeKwinServerDecorationManager, event: org.KdeKwinServerDecorationManager.Event, @@ -207,15 +188,19 @@ pub const Window = struct { app_context: *App.Context, /// A token that, when present, indicates that the window is blurred. - blur_token: ?*org.KdeKwinBlur, + blur_token: ?*org.KdeKwinBlur = null, /// Object that controls the decoration mode (client/server/auto) /// of the window. - decoration: ?*org.KdeKwinServerDecoration, + decoration: ?*org.KdeKwinServerDecoration = null, /// Object that controls the slide-in/slide-out animations of the /// quick terminal. Always null for windows other than the quick terminal. - slide: ?*org.KdeKwinSlide, + slide: ?*org.KdeKwinSlide = null, + + /// Object that, when present, denotes that the window is currently + /// requesting attention from the user. + activation_token: ?*xdg.ActivationTokenV1 = null, pub fn init( alloc: Allocator, @@ -268,9 +253,7 @@ pub const Window = struct { .apprt_window = apprt_window, .surface = wl_surface, .app_context = app.context, - .blur_token = null, .decoration = deco, - .slide = null, }; } @@ -315,6 +298,21 @@ pub const Window = struct { _ = env; } + pub fn setUrgent(self: *Window, urgent: bool) !void { + const activation = self.app_context.xdg_activation orelse return; + + // If there already is a token, destroy and unset it + if (self.activation_token) |token| token.destroy(); + + self.activation_token = if (urgent) token: { + const token = try activation.getActivationToken(); + token.setSurface(self.surface); + token.setListener(*Window, onActivationTokenEvent, self); + token.commit(); + break :token token; + } else null; + } + /// Update the blur state of the window. fn syncBlur(self: *Window) !void { const manager = self.app_context.kde_blur_manager orelse return; @@ -356,9 +354,24 @@ pub const Window = struct { fn syncQuickTerminal(self: *Window) !void { const window = self.apprt_window.window.as(gtk.Window); - const position = self.apprt_window.config.quick_terminal_position; + const config = &self.apprt_window.config; - const anchored_edge: ?gtk4_layer_shell.ShellEdge = switch (position) { + layer_shell.setKeyboardMode( + window, + switch (config.quick_terminal_keyboard_interactivity) { + .none => .none, + .@"on-demand" => on_demand: { + if (layer_shell.getProtocolVersion() < 4) { + log.warn("your compositor does not support on-demand keyboard access; falling back to exclusive access", .{}); + break :on_demand .exclusive; + } + break :on_demand .on_demand; + }, + .exclusive => .exclusive, + }, + ); + + const anchored_edge: ?layer_shell.ShellEdge = switch (config.quick_terminal_position) { .left => .left, .right => .right, .top => .top, @@ -366,43 +379,41 @@ pub const Window = struct { .center => null, }; - for (std.meta.tags(gtk4_layer_shell.ShellEdge)) |edge| { + for (std.meta.tags(layer_shell.ShellEdge)) |edge| { if (anchored_edge) |anchored| { if (edge == anchored) { - gtk4_layer_shell.setMargin(window, edge, 0); - gtk4_layer_shell.setAnchor(window, edge, true); + layer_shell.setMargin(window, edge, 0); + layer_shell.setAnchor(window, edge, true); continue; } } // Arbitrary margin - could be made customizable? - gtk4_layer_shell.setMargin(window, edge, 20); - gtk4_layer_shell.setAnchor(window, edge, false); + layer_shell.setMargin(window, edge, 20); + layer_shell.setAnchor(window, edge, false); } - if (self.apprt_window.isQuickTerminal()) { - if (self.slide) |slide| slide.release(); + if (self.slide) |slide| slide.release(); - self.slide = if (anchored_edge) |anchored| slide: { - const mgr = self.app_context.kde_slide_manager orelse break :slide null; + self.slide = if (anchored_edge) |anchored| slide: { + const mgr = self.app_context.kde_slide_manager orelse break :slide null; - const slide = mgr.create(self.surface) catch |err| { - log.warn("could not create slide object={}", .{err}); - break :slide null; - }; + const slide = mgr.create(self.surface) catch |err| { + log.warn("could not create slide object={}", .{err}); + break :slide null; + }; - const slide_location: org.KdeKwinSlide.Location = switch (anchored) { - .top => .top, - .bottom => .bottom, - .left => .left, - .right => .right, - }; + const slide_location: org.KdeKwinSlide.Location = switch (anchored) { + .top => .top, + .bottom => .bottom, + .left => .left, + .right => .right, + }; - slide.setLocation(@intCast(@intFromEnum(slide_location))); - slide.commit(); - break :slide slide; - } else null; - } + slide.setLocation(@intCast(@intFromEnum(slide_location))); + slide.commit(); + break :slide slide; + } else null; } /// Update the size of the quick terminal based on monitor dimensions. @@ -412,17 +423,41 @@ pub const Window = struct { apprt_window: *ApprtWindow, ) callconv(.c) void { const window = apprt_window.window.as(gtk.Window); - const size = apprt_window.config.quick_terminal_size; - const position = apprt_window.config.quick_terminal_position; + const config = &apprt_window.config; var monitor_size: gdk.Rectangle = undefined; monitor.getGeometry(&monitor_size); - const dims = size.calculate(position, .{ - .width = @intCast(monitor_size.f_width), - .height = @intCast(monitor_size.f_height), - }); + const dims = config.quick_terminal_size.calculate( + config.quick_terminal_position, + .{ + .width = @intCast(monitor_size.f_width), + .height = @intCast(monitor_size.f_height), + }, + ); window.setDefaultSize(@intCast(dims.width), @intCast(dims.height)); } + + fn onActivationTokenEvent( + token: *xdg.ActivationTokenV1, + event: xdg.ActivationTokenV1.Event, + self: *Window, + ) void { + const activation = self.app_context.xdg_activation orelse return; + const current_token = self.activation_token orelse return; + + if (token.getId() != current_token.getId()) { + log.warn("received event for unknown activation token; ignoring", .{}); + return; + } + + switch (event) { + .done => |done| { + activation.activate(done.token, self.surface); + token.destroy(); + self.activation_token = null; + }, + } + } }; diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index c2b6bf416..624de03f8 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -36,16 +36,11 @@ pub const App = struct { config: *const Config, ) !?App { // If the display isn't X11, then we don't need to do anything. - if (gobject.typeCheckInstanceIsA( - gdk_display.as(gobject.TypeInstance), - gdk_x11.X11Display.getGObjectType(), - ) == 0) return null; - - // Get our X11 display const gdk_x11_display = gobject.ext.cast( gdk_x11.X11Display, gdk_display, ) orelse return null; + const xlib_display = gdk_x11_display.getXdisplay(); const x11_program_name: [:0]const u8 = if (config.@"x11-instance-name") |pn| @@ -109,7 +104,7 @@ pub const App = struct { return .{ .display = xlib_display, .base_event_code = base_event_code, - .atoms = Atoms.init(gdk_x11_display), + .atoms = .init(gdk_x11_display), }; } @@ -176,8 +171,8 @@ pub const App = struct { pub const Window = struct { app: *App, config: *const ApprtWindow.DerivedConfig, - window: xlib.Window, gtk_window: *adw.ApplicationWindow, + x11_surface: *gdk_x11.X11Surface, blur_region: Region = .{}, @@ -192,13 +187,6 @@ pub const Window = struct { gtk.Native, ).getSurface() orelse return error.NotX11Surface; - // Check if we're actually on X11 - if (gobject.typeCheckInstanceIsA( - surface.as(gobject.TypeInstance), - gdk_x11.X11Surface.getGObjectType(), - ) == 0) - return error.NotX11Surface; - const x11_surface = gobject.ext.cast( gdk_x11.X11Surface, surface, @@ -207,8 +195,8 @@ pub const Window = struct { return .{ .app = app, .config = &apprt_window.config, - .window = x11_surface.getXid(), .gtk_window = apprt_window.window, + .x11_surface = x11_surface, }; } @@ -279,7 +267,7 @@ pub const Window = struct { const blur = self.config.background_blur; log.debug("set blur={}, window xid={}, region={}", .{ blur, - self.window, + self.x11_surface.getXid(), self.blur_region, }); @@ -335,11 +323,19 @@ pub const Window = struct { pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { var buf: [64]u8 = undefined; - const window_id = try std.fmt.bufPrint(&buf, "{}", .{self.window}); + const window_id = try std.fmt.bufPrint( + &buf, + "{}", + .{self.x11_surface.getXid()}, + ); try env.put("WINDOWID", window_id); } + pub fn setUrgent(self: *Window, urgent: bool) !void { + self.x11_surface.setUrgencyHint(@intFromBool(urgent)); + } + fn getWindowProperty( self: *Window, comptime T: type, @@ -363,7 +359,7 @@ pub const Window = struct { const code = c.XGetWindowProperty( @ptrCast(@alignCast(self.app.display)), - self.window, + self.x11_surface.getXid(), name, options.offset, options.length, @@ -401,7 +397,7 @@ pub const Window = struct { const status = c.XChangeProperty( @ptrCast(@alignCast(self.app.display)), - self.window, + self.x11_surface.getXid(), name, typ, @intFromEnum(format), @@ -419,7 +415,7 @@ pub const Window = struct { fn deleteProperty(self: *Window, name: c.Atom) X11Error!void { const status = c.XDeleteProperty( @ptrCast(@alignCast(self.app.display)), - self.window, + self.x11_surface.getXid(), name, ); if (status == 0) return error.RequestFailed; diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 6de41c544..dce6a3a56 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -74,7 +74,7 @@ pub const Message = union(enum) { /// A terminal color was changed using OSC sequences. color_change: struct { - kind: terminal.osc.Command.ColorKind, + kind: terminal.osc.Command.ColorOperation.Kind, color: terminal.color.RGB, }, diff --git a/src/bench/codepoint-width.zig b/src/bench/codepoint-width.zig index ce44bccb0..07c865e55 100644 --- a/src/bench/codepoint-width.zig +++ b/src/bench/codepoint-width.zig @@ -68,7 +68,7 @@ pub fn main() !void { var args: Args = .{}; defer args.deinit(); { - var iter = try std.process.argsWithAllocator(alloc); + var iter = try cli.args.argsIterator(alloc); defer iter.deinit(); try cli.args.parse(Args, alloc, &args, &iter); } diff --git a/src/bench/grapheme-break.zig b/src/bench/grapheme-break.zig index bbe2171d5..049af4a91 100644 --- a/src/bench/grapheme-break.zig +++ b/src/bench/grapheme-break.zig @@ -60,7 +60,7 @@ pub fn main() !void { var args: Args = .{}; defer args.deinit(); { - var iter = try std.process.argsWithAllocator(alloc); + var iter = try cli.args.argsIterator(alloc); defer iter.deinit(); try cli.args.parse(Args, alloc, &args, &iter); } diff --git a/src/bench/page-init.zig b/src/bench/page-init.zig index e45d64fbb..9b0d1ac1d 100644 --- a/src/bench/page-init.zig +++ b/src/bench/page-init.zig @@ -45,7 +45,7 @@ pub fn main() !void { var args: Args = .{}; defer args.deinit(); { - var iter = try std.process.argsWithAllocator(alloc); + var iter = try cli.args.argsIterator(alloc); defer iter.deinit(); try cli.args.parse(Args, alloc, &args, &iter); } diff --git a/src/bench/parser.zig b/src/bench/parser.zig index ee6c3ee94..9245c06cb 100644 --- a/src/bench/parser.zig +++ b/src/bench/parser.zig @@ -27,7 +27,7 @@ pub fn main() !void { var args: Args = args: { var args: Args = .{}; errdefer args.deinit(); - var iter = try std.process.argsWithAllocator(alloc); + var iter = try cli.args.argsIterator(alloc); defer iter.deinit(); try cli.args.parse(Args, alloc, &args, &iter); break :args args; diff --git a/src/bench/stream.zig b/src/bench/stream.zig index a7abb37cc..6309c9e7f 100644 --- a/src/bench/stream.zig +++ b/src/bench/stream.zig @@ -12,9 +12,9 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const ziglyph = @import("ziglyph"); const cli = @import("../cli.zig"); const terminal = @import("../terminal/main.zig"); +const synthetic = @import("../synthetic/main.zig"); const Args = struct { mode: Mode = .noop, @@ -70,6 +70,14 @@ const Mode = enum { // Generate an infinite stream of arbitrary random bytes. @"gen-rand", + + // Generate an infinite stream of OSC requests. These will be mixed + // with valid and invalid OSC requests by default, but the + // `-valid` and `-invalid`-suffixed variants can be used to get only + // a specific type of OSC request. + @"gen-osc", + @"gen-osc-valid", + @"gen-osc-invalid", }; pub const std_options: std.Options = .{ @@ -84,7 +92,7 @@ pub fn main() !void { var args: Args = .{}; defer args.deinit(); { - var iter = try std.process.argsWithAllocator(alloc); + var iter = try cli.args.argsIterator(alloc); defer iter.deinit(); try cli.args.parse(Args, alloc, &args, &iter); } @@ -93,13 +101,57 @@ pub fn main() !void { const writer = std.io.getStdOut().writer(); const buf = try alloc.alloc(u8, args.@"buffer-size"); + // Build our RNG const seed: u64 = if (args.seed >= 0) @bitCast(args.seed) else @truncate(@as(u128, @bitCast(std.time.nanoTimestamp()))); + var prng = std.Random.DefaultPrng.init(seed); + const rand = prng.random(); // Handle the modes that do not depend on terminal state first. switch (args.mode) { - .@"gen-ascii" => try genAscii(writer, seed), - .@"gen-utf8" => try genUtf8(writer, seed), - .@"gen-rand" => try genRand(writer, seed), + .@"gen-ascii" => { + var gen: synthetic.Bytes = .{ + .rand = rand, + .alphabet = synthetic.Bytes.Alphabet.ascii, + }; + try generate(writer, gen.generator()); + }, + + .@"gen-utf8" => { + var gen: synthetic.Utf8 = .{ + .rand = rand, + }; + try generate(writer, gen.generator()); + }, + + .@"gen-rand" => { + var gen: synthetic.Bytes = .{ .rand = rand }; + try generate(writer, gen.generator()); + }, + + .@"gen-osc" => { + var gen: synthetic.Osc = .{ + .rand = rand, + .p_valid = 0.5, + }; + try generate(writer, gen.generator()); + }, + + .@"gen-osc-valid" => { + var gen: synthetic.Osc = .{ + .rand = rand, + .p_valid = 1.0, + }; + try generate(writer, gen.generator()); + }, + + .@"gen-osc-invalid" => { + var gen: synthetic.Osc = .{ + .rand = rand, + .p_valid = 0.0, + }; + try generate(writer, gen.generator()); + }, + .noop => try benchNoop(reader, buf), // Handle the ones that depend on terminal state next @@ -133,61 +185,14 @@ pub fn main() !void { } } -/// Generates an infinite stream of random printable ASCII characters. -/// This has no control characters in it at all. -fn genAscii(writer: anytype, seed: u64) !void { - const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;':\\\",./<>?`~"; - try genData(writer, alphabet, seed); -} - -/// Generates an infinite stream of bytes from the given alphabet. -fn genData(writer: anytype, alphabet: []const u8, seed: u64) !void { - var prng = std.rand.DefaultPrng.init(seed); - const rnd = prng.random(); +fn generate( + writer: anytype, + gen: synthetic.Generator, +) !void { var buf: [1024]u8 = undefined; while (true) { - for (&buf) |*c| { - const idx = rnd.uintLessThanBiased(usize, alphabet.len); - c.* = alphabet[idx]; - } - - writer.writeAll(&buf) catch |err| switch (err) { - error.BrokenPipe => return, // stdout closed - else => return err, - }; - } -} - -fn genUtf8(writer: anytype, seed: u64) !void { - var prng = std.rand.DefaultPrng.init(seed); - const rnd = prng.random(); - var buf: [1024]u8 = undefined; - while (true) { - var i: usize = 0; - while (i <= buf.len - 4) { - const cp: u18 = while (true) { - const cp = rnd.int(u18); - if (ziglyph.isPrint(cp)) break cp; - }; - - i += try std.unicode.utf8Encode(cp, buf[i..]); - } - - writer.writeAll(buf[0..i]) catch |err| switch (err) { - error.BrokenPipe => return, // stdout closed - else => return err, - }; - } -} - -fn genRand(writer: anytype, seed: u64) !void { - var prng = std.rand.DefaultPrng.init(seed); - const rnd = prng.random(); - var buf: [1024]u8 = undefined; - while (true) { - rnd.bytes(&buf); - - writer.writeAll(&buf) catch |err| switch (err) { + const data = try gen.next(&buf); + writer.writeAll(data) catch |err| switch (err) { error.BrokenPipe => return, // stdout closed else => return err, }; diff --git a/src/build/GhosttyBench.zig b/src/build/GhosttyBench.zig index 27f40abff..9e93a3b85 100644 --- a/src/build/GhosttyBench.zig +++ b/src/build/GhosttyBench.zig @@ -36,11 +36,13 @@ pub fn init( const bin_name = try std.fmt.allocPrint(b.allocator, "bench-{s}", .{name}); const c_exe = b.addExecutable(.{ .name = bin_name, - .root_source_file = b.path("src/main.zig"), - .target = deps.config.target, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = deps.config.target, - // We always want our benchmarks to be in release mode. - .optimize = .ReleaseFast, + // We always want our benchmarks to be in release mode. + .optimize = .ReleaseFast, + }), }); c_exe.linkLibC(); diff --git a/src/build/GhosttyDocs.zig b/src/build/GhosttyDocs.zig index d6ebe30eb..4b5dbfd92 100644 --- a/src/build/GhosttyDocs.zig +++ b/src/build/GhosttyDocs.zig @@ -26,8 +26,13 @@ pub fn init( inline for (manpages) |manpage| { const generate_markdown = b.addExecutable(.{ .name = "mdgen_" ++ manpage.name ++ "_" ++ manpage.section, - .root_source_file = b.path("src/main.zig"), - .target = b.graph.host, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), }); deps.help_strings.addImport(generate_markdown); diff --git a/src/build/GhosttyExe.zig b/src/build/GhosttyExe.zig index e251e7b45..083aecdb5 100644 --- a/src/build/GhosttyExe.zig +++ b/src/build/GhosttyExe.zig @@ -13,10 +13,14 @@ install_step: *std.Build.Step.InstallArtifact, pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty { const exe: *std.Build.Step.Compile = b.addExecutable(.{ .name = "ghostty", - .root_source_file = b.path("src/main.zig"), - .target = cfg.target, - .optimize = cfg.optimize, - .strip = cfg.strip, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = cfg.target, + .optimize = cfg.optimize, + .strip = cfg.strip, + .omit_frame_pointer = cfg.strip, + .unwind_tables = if (cfg.strip) .none else .sync, + }), }); const install_step = b.addInstallArtifact(exe, .{}); diff --git a/src/build/GhosttyFrameData.zig b/src/build/GhosttyFrameData.zig index b07e7333f..3dc638a05 100644 --- a/src/build/GhosttyFrameData.zig +++ b/src/build/GhosttyFrameData.zig @@ -15,8 +15,13 @@ output: std.Build.LazyPath, pub fn init(b: *std.Build) !GhosttyFrameData { const exe = b.addExecutable(.{ .name = "framegen", - .root_source_file = b.path("src/build/framegen/main.zig"), - .target = b.graph.host, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/build/framegen/main.zig"), + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), }); const run = b.addRunArtifact(exe); diff --git a/src/build/GhosttyI18n.zig b/src/build/GhosttyI18n.zig index daf523938..a1852bb96 100644 --- a/src/build/GhosttyI18n.zig +++ b/src/build/GhosttyI18n.zig @@ -54,7 +54,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { "--keyword=C_:1c,2", "--package-name=" ++ domain, "--msgid-bugs-address=m@mitchellh.com", - "--copyright-holder=Mitchell Hashimoto", + "--copyright-holder=\"Mitchell Hashimoto, Ghostty contributors\"", "-o", "-", }); diff --git a/src/build/GhosttyWebdata.zig b/src/build/GhosttyWebdata.zig index fef08434f..b0201c3ff 100644 --- a/src/build/GhosttyWebdata.zig +++ b/src/build/GhosttyWebdata.zig @@ -18,8 +18,13 @@ pub fn init( { const webgen_config = b.addExecutable(.{ .name = "webgen_config", - .root_source_file = b.path("src/main.zig"), - .target = b.graph.host, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), }); deps.help_strings.addImport(webgen_config); diff --git a/src/build/HelpStrings.zig b/src/build/HelpStrings.zig index d088e6c3e..04ae629b7 100644 --- a/src/build/HelpStrings.zig +++ b/src/build/HelpStrings.zig @@ -12,8 +12,13 @@ output: std.Build.LazyPath, pub fn init(b: *std.Build, cfg: *const Config) !HelpStrings { const exe = b.addExecutable(.{ .name = "helpgen", - .root_source_file = b.path("src/helpgen.zig"), - .target = b.graph.host, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/helpgen.zig"), + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), }); const help_config = config: { diff --git a/src/build/MetallibStep.zig b/src/build/MetallibStep.zig index 12adf3edb..bac3a72c5 100644 --- a/src/build/MetallibStep.zig +++ b/src/build/MetallibStep.zig @@ -22,13 +22,12 @@ step: *Step, output: LazyPath, pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { - const self = b.allocator.create(MetallibStep) catch @panic("OOM"); + switch (opts.target.result.os.tag) { + .macos, .ios => {}, + else => return null, // Only macOS and iOS are supported. + } - const sdk = switch (opts.target.result.os.tag) { - .macos => "macosx", - .ios => "iphoneos", - else => return null, - }; + const self = b.allocator.create(MetallibStep) catch @panic("OOM"); const min_version = if (opts.target.query.os_version_min) |v| b.fmt("{}", .{v.semver}) @@ -38,11 +37,31 @@ pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { else => unreachable, }; + // Find the metal and metallib executables. The Apple docs + // at the time of writing (June 2025) say to use + // `xcrun --sdk metal` but this doesn't work with Xcode 26. + // + // I don't know if this is a bug but the xcodebuild approach also + // works with Xcode 15 so it seems safe to use this instead. + // + // Reported bug: FB17874042. + var code: u8 = undefined; + const metal_exe = std.mem.trim(u8, b.runAllowFail( + &.{ "xcodebuild", "-find-executable", "metal" }, + &code, + .Ignore, + ) catch return null, "\r\n "); + const metallib_exe = std.mem.trim(u8, b.runAllowFail( + &.{ "xcodebuild", "-find-executable", "metallib" }, + &code, + .Ignore, + ) catch return null, "\r\n "); + const run_ir = RunStep.create( b, b.fmt("metal {s}", .{opts.name}), ); - run_ir.addArgs(&.{ "/usr/bin/xcrun", "-sdk", sdk, "metal", "-o" }); + run_ir.addArgs(&.{ metal_exe, "-o" }); const output_ir = run_ir.addOutputFileArg(b.fmt("{s}.ir", .{opts.name})); run_ir.addArgs(&.{"-c"}); for (opts.sources) |source| run_ir.addFileArg(source); @@ -62,7 +81,7 @@ pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { b, b.fmt("metallib {s}", .{opts.name}), ); - run_lib.addArgs(&.{ "/usr/bin/xcrun", "-sdk", sdk, "metallib", "-o" }); + run_lib.addArgs(&.{ metallib_exe, "-o" }); const output_lib = run_lib.addOutputFileArg(b.fmt("{s}.metallib", .{opts.name})); run_lib.addFileArg(output_ir); run_lib.step.dependOn(&run_ir.step); diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 4b97298f7..5d737cb6f 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -24,9 +24,9 @@ pub const LazyPathList = std.ArrayList(std.Build.LazyPath); pub fn init(b: *std.Build, cfg: *const Config) !SharedDeps { var result: SharedDeps = .{ .config = cfg, - .help_strings = try HelpStrings.init(b, cfg), - .unicode_tables = try UnicodeTables.init(b), - .framedata = try GhosttyFrameData.init(b), + .help_strings = try .init(b, cfg), + .unicode_tables = try .init(b), + .framedata = try .init(b), // Setup by retarget .options = undefined, @@ -60,6 +60,9 @@ pub fn changeEntrypoint( var result = self.*; result.config = config; + result.options = b.addOptions(); + try config.addOptions(result.options); + return result; } @@ -69,7 +72,7 @@ fn initTarget( target: std.Build.ResolvedTarget, ) !void { // Update our metallib - self.metallib = MetallibStep.create(b, .{ + self.metallib = .create(b, .{ .name = "Ghostty", .target = target, .sources = &.{b.path("src/renderer/shaders/cell.metal")}, @@ -374,7 +377,7 @@ pub fn add( // We always require the system SDK so that our system headers are available. // This makes things like `os/log.h` available for cross-compiling. if (step.rootModuleTarget().os.tag.isDarwin()) { - try @import("apple_sdk").addPaths(b, step.root_module); + try @import("apple_sdk").addPaths(b, step); const metallib = self.metallib.?; metallib.output.addStepDependencies(&step.step); @@ -606,21 +609,23 @@ fn addGTK( .wayland_protocols = wayland_protocols_dep.path(""), }); - // FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 scanner.addCustomProtocol( plasma_wayland_protocols_dep.path("src/protocols/blur.xml"), ); + // FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 scanner.addCustomProtocol( plasma_wayland_protocols_dep.path("src/protocols/server-decoration.xml"), ); scanner.addCustomProtocol( plasma_wayland_protocols_dep.path("src/protocols/slide.xml"), ); + scanner.addSystemProtocol("staging/xdg-activation/xdg-activation-v1.xml"); scanner.generate("wl_compositor", 1); scanner.generate("org_kde_kwin_blur_manager", 1); scanner.generate("org_kde_kwin_server_decoration_manager", 1); scanner.generate("org_kde_kwin_slide_manager", 1); + scanner.generate("xdg_activation_v1", 1); step.root_module.addImport("wayland", b.createModule(.{ .root_source_file = scanner.result, diff --git a/src/build/UnicodeTables.zig b/src/build/UnicodeTables.zig index 58af17a6e..5bba2341b 100644 --- a/src/build/UnicodeTables.zig +++ b/src/build/UnicodeTables.zig @@ -12,8 +12,13 @@ output: std.Build.LazyPath, pub fn init(b: *std.Build) !UnicodeTables { const exe = b.addExecutable(.{ .name = "unigen", - .root_source_file = b.path("src/unicode/props.zig"), - .target = b.graph.host, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/unicode/props.zig"), + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), }); if (b.lazyDependency("ziglyph", .{ diff --git a/src/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md index 7ace64cd8..f8e502b45 100644 --- a/src/build/mdgen/ghostty_1_footer.md +++ b/src/build/mdgen/ghostty_1_footer.md @@ -44,6 +44,7 @@ See GitHub issues: # AUTHOR Mitchell Hashimoto +Ghostty contributors # SEE ALSO diff --git a/src/build/mdgen/ghostty_5_footer.md b/src/build/mdgen/ghostty_5_footer.md index c5077ab97..380d83a53 100644 --- a/src/build/mdgen/ghostty_5_footer.md +++ b/src/build/mdgen/ghostty_5_footer.md @@ -36,6 +36,7 @@ See GitHub issues: # AUTHOR Mitchell Hashimoto +Ghostty contributors # SEE ALSO diff --git a/src/build/mdgen/mdgen.zig b/src/build/mdgen/mdgen.zig index aca230aa5..e7d966323 100644 --- a/src/build/mdgen/mdgen.zig +++ b/src/build/mdgen/mdgen.zig @@ -26,7 +26,7 @@ pub fn genConfig(writer: anytype, cli: bool) !void { \\ ); - @setEvalBranchQuota(3000); + @setEvalBranchQuota(5000); inline for (@typeInfo(Config).@"struct".fields) |field| { if (field.name[0] == '_') continue; @@ -94,6 +94,7 @@ pub fn genKeybindActions(writer: anytype) !void { const info = @typeInfo(KeybindAction); std.debug.assert(info == .@"union"); + @setEvalBranchQuota(5000); inline for (info.@"union".fields) |field| { if (field.name[0] == '_') continue; diff --git a/src/cli/args.zig b/src/cli/args.zig index 4860cdd74..68972a622 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -84,7 +84,7 @@ pub fn parse( // If the arena is unset, we create it. We mark that we own it // only so that we can clean it up on error. if (dst._arena == null) { - dst._arena = ArenaAllocator.init(alloc); + dst._arena = .init(alloc); arena_owned = true; } @@ -481,7 +481,7 @@ pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T { // Keep track of which fields were set so we can error if a required // field was not set. const FieldSet = std.StaticBitSet(info.fields.len); - var fields_set: FieldSet = FieldSet.initEmpty(); + var fields_set: FieldSet = .initEmpty(); // We split each value by "," var iter = std.mem.splitSequence(u8, v, ","); diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index 6cd989201..f84d540c3 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -155,14 +155,12 @@ const ChordBinding = struct { while (l_trigger != null and r_trigger != null) { const lhs_key: c_int = blk: { switch (l_trigger.?.data.key) { - .translated => |key| break :blk @intFromEnum(key), .physical => |key| break :blk @intFromEnum(key), .unicode => |key| break :blk @intCast(key), } }; const rhs_key: c_int = blk: { switch (r_trigger.?.data.key) { - .translated => |key| break :blk @intFromEnum(key), .physical => |key| break :blk @intFromEnum(key), .unicode => |key| break :blk @intCast(key), } @@ -254,8 +252,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col }); } const key = switch (trigger.data.key) { - .translated => |k| try std.fmt.allocPrint(alloc, "{s}", .{@tagName(k)}), - .physical => |k| try std.fmt.allocPrint(alloc, "physical:{s}", .{@tagName(k)}), + .physical => |k| try std.fmt.allocPrint(alloc, "{s}", .{@tagName(k)}), .unicode => |c| try std.fmt.allocPrint(alloc, "{u}", .{c}), }; result = win.printSegment(.{ .text = key }, .{ .col_offset = result.col }); @@ -297,8 +294,7 @@ fn iterateBindings(alloc: Allocator, iter: anytype, win: *const vaxis.Window) !s if (t.mods.shift) try std.fmt.format(buf.writer(), "shift + ", .{}); switch (t.key) { - .translated => |k| try std.fmt.format(buf.writer(), "{s}", .{@tagName(k)}), - .physical => |k| try std.fmt.format(buf.writer(), "physical:{s}", .{@tagName(k)}), + .physical => |k| try std.fmt.format(buf.writer(), "{s}", .{@tagName(k)}), .unicode => |c| try std.fmt.format(buf.writer(), "{u}", .{c}), } diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 54f4c0969..4bb8a74eb 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -77,7 +77,7 @@ const ThemeListElement = struct { /// Two different directories will be searched for themes. /// /// The first directory is the `themes` subdirectory of your Ghostty -/// configuration directory. This is `$XDG_CONFIG_DIR/ghostty/themes` or +/// configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or /// `~/.config/ghostty/themes`. /// /// The second directory is the `themes` subdirectory of the Ghostty resources diff --git a/src/config/Config.zig b/src/config/Config.zig index 95dcf3420..2df66ba45 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -410,7 +410,7 @@ pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{ /// include path separators unless it is an absolute pathname. /// /// The first directory is the `themes` subdirectory of your Ghostty -/// configuration directory. This is `$XDG_CONFIG_DIR/ghostty/themes` or +/// configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or /// `~/.config/ghostty/themes`. /// /// The second directory is the `themes` subdirectory of the Ghostty resources @@ -474,6 +474,21 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// selection color will vary across the selection. @"selection-invert-fg-bg": bool = false, +/// Whether to clear selected text when typing. This defaults to `true`. +/// This is typical behavior for most terminal emulators as well as +/// text input fields. If you set this to `false`, then the selected text +/// will not be cleared when typing. +/// +/// "Typing" is specifically defined as any non-modifier (shift, control, +/// alt, etc.) keypress that produces data to be sent to the application +/// running within the terminal (e.g. the shell). Additionally, selection +/// is cleared when any preedit or composition state is started (e.g. +/// when typing languages such as Japanese). +/// +/// If this is `false`, then the selection can still be manually +/// cleared by clicking once or by pressing `escape`. +@"selection-clear-on-typing": bool = true, + /// The minimum contrast ratio between the foreground and background colors. /// The contrast ratio is a value between 1 and 21. A value of 1 allows for no /// contrast (e.g. black on black). This value is the contrast ratio as defined @@ -929,12 +944,46 @@ class: ?[:0]const u8 = null, /// Trigger: `+`-separated list of keys and modifiers. Example: `ctrl+a`, /// `ctrl+shift+b`, `up`. /// -/// Valid keys are currently only listed in the -/// [Ghostty source code](https://github.com/ghostty-org/ghostty/blob/d6e76858164d52cff460fedc61ddf2e560912d71/src/input/key.zig#L255). -/// This is a documentation limitation and we will improve this in the future. -/// A common gotcha is that numeric keys are written as words: e.g. `one`, -/// `two`, `three`, etc. and not `1`, `2`, `3`. This will also be improved in -/// the future. +/// If the key is a single Unicode codepoint, the trigger will match +/// any presses that produce that codepoint. These are impacted by +/// keyboard layouts. For example, `a` will match the `a` key on a +/// QWERTY keyboard, but will match the `q` key on a AZERTY keyboard +/// (assuming US physical layout). +/// +/// For Unicode codepoints, matching is done by comparing the set of +/// modifiers with the unmodified codepoint. The unmodified codepoint is +/// sometimes called an "unshifted character" in other software, but all +/// modifiers are considered, not only shift. For example, `ctrl+a` will match +/// `a` but not `ctrl+shift+a` (which is `A` on a US keyboard). +/// +/// Further, codepoint matching is case-insensitive and the unmodified +/// codepoint is always case folded for comparison. As a result, +/// `ctrl+A` configured will match when `ctrl+a` is pressed. Note that +/// this means some key combinations are impossible depending on keyboard +/// layout. For example, `ctrl+_` is impossible on a US keyboard because +/// `_` is `shift+-` and `ctrl+shift+-` is not equal to `ctrl+_` (because +/// the modifiers don't match!). More details on impossible key combinations +/// can be found at this excellent source written by Qt developers: +/// https://doc.qt.io/qt-6/qkeysequence.html#keyboard-layout-issues +/// +/// Physical key codes can be specified by using any of the key codes +/// as specified by the [W3C specification](https://www.w3.org/TR/uievents-code/). +/// For example, `KeyA` will match the physical `a` key on a US standard +/// keyboard regardless of the keyboard layout. These are case-sensitive. +/// +/// For aesthetic reasons, the w3c codes also support snake case. For +/// example, `key_a` is equivalent to `KeyA`. The only exceptions are +/// function keys, e.g. `F1` is `f1` (no underscore). This is a consequence +/// of our internal code using snake case but is purposely supported +/// and tested so it is safe to use. It allows an all-lowercase binding +/// which I find more aesthetically pleasing. +/// +/// Function keys such as `insert`, `up`, `f5`, etc. are also specified +/// using the keys as specified by the previously linked W3C specification. +/// +/// Physical keys always match with a higher priority than Unicode codepoints, +/// so if you specify both `a` and `KeyA`, the physical key will always be used +/// regardless of what order they are configured. /// /// Valid modifiers are `shift`, `ctrl` (alias: `control`), `alt` (alias: `opt`, /// `option`), and `super` (alias: `cmd`, `command`). You may use the modifier @@ -954,11 +1003,6 @@ class: ?[:0]const u8 = null, /// /// * only a single key input is allowed, `ctrl+a+b` is invalid. /// -/// * the key input can be prefixed with `physical:` to specify a -/// physical key mapping rather than a logical one. A physical key -/// mapping responds to the hardware keycode and not the keycode -/// translated by any system keyboard layouts. Example: "ctrl+physical:a" -/// /// You may also specify multiple triggers separated by `>` to require a /// sequence of triggers to activate the action. For example, /// `ctrl+a>n=new_window` will only trigger the `new_window` action if the @@ -1081,12 +1125,33 @@ class: ?[:0]const u8 = null, /// `global:unconsumed:ctrl+a=reload_config` will make the keybind global /// and not consume the input to reload the config. /// -/// Note: `global:` is only supported on macOS. On macOS, -/// this feature requires accessibility permissions to be granted to Ghostty. -/// When a `global:` keybind is specified and Ghostty is launched or reloaded, -/// Ghostty will attempt to request these permissions. If the permissions are -/// not granted, the keybind will not work. On macOS, you can find these -/// permissions in System Preferences -> Privacy & Security -> Accessibility. +/// Note: `global:` is only supported on macOS and certain Linux platforms. +/// +/// On macOS, this feature requires accessibility permissions to be granted +/// to Ghostty. When a `global:` keybind is specified and Ghostty is launched +/// or reloaded, Ghostty will attempt to request these permissions. +/// If the permissions are not granted, the keybind will not work. On macOS, +/// you can find these permissions in System Preferences -> Privacy & Security +/// -> Accessibility. +/// +/// On Linux, you need a desktop environment that implements the +/// [Global Shortcuts](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html) +/// protocol as a part of its XDG desktop protocol implementation. +/// Desktop environments that are known to support (or not support) +/// global shortcuts include: +/// +/// - Users using KDE Plasma (since [5.27](https://kde.org/announcements/plasma/5/5.27.0/#wayland)) +/// and GNOME (since [48](https://release.gnome.org/48/#and-thats-not-all)) should be able +/// to use global shortcuts with little to no configuration. +/// +/// - Some manual configuration is required on Hyprland. Consult the steps +/// outlined on the [Hyprland Wiki](https://wiki.hyprland.org/Configuring/Binds/#dbus-global-shortcuts) +/// to set up global shortcuts correctly. +/// (Important: [`xdg-desktop-portal-hyprland`](https://wiki.hyprland.org/Hypr-Ecosystem/xdg-desktop-portal-hyprland/) +/// must also be installed!) +/// +/// - Notably, global shortcuts have not been implemented on wlroots-based +/// compositors like Sway (see [upstream issue](https://github.com/emersion/xdg-desktop-portal-wlr/issues/240)). keybind: Keybinds = .{}, /// Horizontal window padding. This applies padding between the terminal cells @@ -1640,6 +1705,52 @@ keybind: Keybinds = .{}, /// window is ever created. Only implemented on Linux and macOS. @"initial-window": bool = true, +/// The duration that undo operations remain available. After this +/// time, the operation will be removed from the undo stack and +/// cannot be undone. +/// +/// The default value is 5 seconds. +/// +/// This timeout applies per operation, meaning that if you perform +/// multiple operations, each operation will have its own timeout. +/// New operations do not reset the timeout of previous operations. +/// +/// A timeout of zero will effectively disable undo operations. It is +/// not possible to set an infinite timeout, but you can set a very +/// large timeout to effectively disable the timeout (on the order of years). +/// This is highly discouraged, as it will cause the undo stack to grow +/// indefinitely, memory usage to grow unbounded, and terminal sessions +/// to never actually quit. +/// +/// The duration is specified as a series of numbers followed by time units. +/// Whitespace is allowed between numbers and units. Each number and unit will +/// be added together to form the total duration. +/// +/// The allowed time units are as follows: +/// +/// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments +/// are made for leap years or leap seconds. +/// * `d` - one SI day, or 86400 seconds. +/// * `h` - one hour, or 3600 seconds. +/// * `m` - one minute, or 60 seconds. +/// * `s` - one second. +/// * `ms` - one millisecond, or 0.001 second. +/// * `us` or `µs` - one microsecond, or 0.000001 second. +/// * `ns` - one nanosecond, or 0.000000001 second. +/// +/// Examples: +/// * `1h30m` +/// * `45s` +/// +/// Units can be repeated and will be added together. This means that +/// `1h1h` is equivalent to `2h`. This is confusing and should be avoided. +/// A future update may disallow this. +/// +/// This configuration is only supported on macOS. Linux doesn't +/// support undo operations at all so this configuration has no +/// effect. +@"undo-timeout": Duration = .{ .duration = 5 * std.time.ns_per_s }, + /// The position of the "quick" terminal window. To learn more about the /// quick terminal, see the documentation for the `toggle_quick_terminal` /// binding action. @@ -1743,6 +1854,34 @@ keybind: Keybinds = .{}, /// On Linux the behavior is always equivalent to `move`. @"quick-terminal-space-behavior": QuickTerminalSpaceBehavior = .move, +/// Determines under which circumstances that the quick terminal should receive +/// keyboard input. See the corresponding [Wayland documentation](https://wayland.app/protocols/wlr-layer-shell-unstable-v1#zwlr_layer_surface_v1:enum:keyboard_interactivity) +/// for a more detailed explanation of the behavior of each option. +/// +/// > [!NOTE] +/// > The exact behavior of each option may differ significantly across +/// > compositors -- experiment with them on your system to find one that +/// > suits your liking! +/// +/// Valid values are: +/// +/// * `none` +/// +/// The quick terminal will not receive any keyboard input. +/// +/// * `on-demand` (default) +/// +/// The quick terminal would only receive keyboard input when it is focused. +/// +/// * `exclusive` +/// +/// The quick terminal will always receive keyboard input, even when another +/// window is currently focused. +/// +/// Only has an effect on Linux Wayland. +/// On macOS the behavior is always equivalent to `on-demand`. +@"quick-terminal-keyboard-interactivity": QuickTerminalKeyboardInteractivity = .@"on-demand", + /// Whether to enable shell integration auto-injection or not. Shell integration /// greatly enhances the terminal experience by enabling a number of features: /// @@ -1861,28 +2000,65 @@ keybind: Keybinds = .{}, /// open terminals. @"custom-shader-animation": CustomShaderAnimation = .true, -/// The list of enabled features that are activated after encountering -/// a bell character. +/// Bell features to enable if bell support is available in your runtime. Not +/// all features are available on all runtimes. The format of this is a list of +/// features to enable separated by commas. If you prefix a feature with `no-` +/// then it is disabled. If you omit a feature, its default value is used. /// /// Valid values are: /// /// * `system` /// -/// Instructs the system to notify the user using built-in system functions. +/// Instruct the system to notify the user using built-in system functions. /// This could result in an audiovisual effect, a notification, or something /// else entirely. Changing these effects require altering system settings: /// for instance under the "Sound > Alert Sound" setting in GNOME, -/// or the "Accessibility > System Bell" settings in KDE Plasma. +/// or the "Accessibility > System Bell" settings in KDE Plasma. (GTK only) /// -/// On macOS this has no affect. +/// * `audio` /// -/// On macOS, if the app is unfocused, it will bounce the app icon in the dock -/// once. Additionally, the title of the window with the alerted terminal -/// surface will contain a bell emoji (🔔) until the terminal is focused -/// or a key is pressed. These are not currently configurable since they're -/// considered unobtrusive. +/// Play a custom sound. (GTK only) +/// +/// * `attention` *(enabled by default)* +/// +/// Request the user's attention when Ghostty is unfocused, until it has +/// received focus again. On macOS, this will bounce the app icon in the +/// dock once. On Linux, the behavior depends on the desktop environment +/// and/or the window manager/compositor: +/// +/// - On KDE, the background of the desktop icon in the task bar would be +/// highlighted; +/// +/// - On GNOME, you may receive a notification that, when clicked, would +/// bring the Ghostty window into focus; +/// +/// - On Sway, the window may be decorated with a distinctly colored border; +/// +/// - On other systems this may have no effect at all. +/// +/// * `title` *(enabled by default)* +/// +/// Prepend a bell emoji (🔔) to the title of the alerted surface until the +/// terminal is re-focused or interacted with (such as on keyboard input). +/// +/// Only implemented on macOS. +/// +/// Example: `audio`, `no-audio`, `system`, `no-system` @"bell-features": BellFeatures = .{}, +/// If `audio` is an enabled bell feature, this is a path to an audio file. If +/// the path is not absolute, it is considered relative to the directory of the +/// configuration file that it is referenced from, or from the current working +/// directory if this is used as a CLI flag. The path may be prefixed with `~/` +/// to reference the user's home directory. (GTK only) +@"bell-audio-path": ?Path = null, + +/// If `audio` is an enabled bell feature, this is the volume to play the audio +/// file at (relative to the system volume). This is a floating point number +/// ranging from 0.0 (silence) to 1.0 (as loud as possible). The default is 0.5. +/// (GTK only) +@"bell-audio-volume": f64 = 0.5, + /// Control the in-app notifications that Ghostty shows. /// /// On Linux (GTK), in-app notifications show up as toasts. Toasts appear @@ -1939,6 +2115,25 @@ keybind: Keybinds = .{}, /// it will retain the previous setting until fullscreen is exited. @"macos-non-native-fullscreen": NonNativeFullscreen = .false, +/// Whether the window buttons in the macOS titlebar are visible. The window +/// buttons are the colored buttons in the upper left corner of most macOS apps, +/// also known as the traffic lights, that allow you to close, miniaturize, and +/// zoom the window. +/// +/// This setting has no effect when `window-decoration = false` or +/// `macos-titlebar-style = hidden`, as the window buttons are always hidden in +/// these modes. +/// +/// Valid values are: +/// +/// * `visible` - Show the window buttons. +/// * `hidden` - Hide the window buttons. +/// +/// The default value is `visible`. +/// +/// Changing this option at runtime only applies to new windows. +@"macos-window-buttons": MacWindowButtons = .visible, + /// The style of the macOS titlebar. Available values are: "native", /// "transparent", "tabs", and "hidden". /// @@ -2004,7 +2199,7 @@ keybind: Keybinds = .{}, /// macOS doesn't have a distinct "alt" key and instead has the "option" /// key which behaves slightly differently. On macOS by default, the -/// option key plus a character will sometimes produces a Unicode character. +/// option key plus a character will sometimes produce a Unicode character. /// For example, on US standard layouts option-b produces "∫". This may be /// undesirable if you want to use "option" as an "alt" key for keybindings /// in terminal programs or shells. @@ -2314,6 +2509,23 @@ term: []const u8 = "xterm-ghostty", /// running. Defaults to an empty string if not set. @"enquiry-response": []const u8 = "", +/// The mechanism used to launch Ghostty. This should generally not be +/// set by users, see the warning below. +/// +/// WARNING: This is a low-level configuration that is not intended to be +/// modified by users. All the values will be automatically detected as they +/// are needed by Ghostty. This is only here in case our detection logic is +/// incorrect for your environment or for developers who want to test +/// Ghostty's behavior in different, forced environments. +/// +/// This is set using the standard `no-[value]`, `[value]` syntax separated +/// by commas. Example: "no-desktop,systemd". Specific details about the +/// available values are documented on LaunchProperties in the code. Since +/// this isn't intended to be modified by users, the documentation is +/// lighter than the other configurations and users are expected to +/// refer to the code for details. +@"launched-from": ?LaunchSource = null, + /// Configures the low-level API to use for async IO, eventing, etc. /// /// Most users should leave this set to `auto`. This will automatically detect @@ -2445,7 +2657,7 @@ pub fn load(alloc_gpa: Allocator) !Config { pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { // Build up our basic config var result: Config = .{ - ._arena = ArenaAllocator.init(alloc_gpa), + ._arena = .init(alloc_gpa), }; errdefer result.deinit(); const alloc = result._arena.?.allocator(); @@ -2636,19 +2848,18 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { // can replay if we are discarding the default files. const replay_len_start = self._replay_steps.items.len; - // Keep track of font families because if they are set from the CLI - // then we clear the previously set values. This avoids a UX oddity - // where on the CLI you have to specify `font-family=""` to clear the - // font families before setting a new one. + // font-family settings set via the CLI overwrite any prior values + // rather than append. This avoids a UX oddity where you have to + // specify `font-family=""` to clear the font families. const fields = &[_][]const u8{ "font-family", "font-family-bold", "font-family-italic", "font-family-bold-italic", }; - var counter: [fields.len]usize = undefined; - inline for (fields, 0..) |field, i| { - counter[i] = @field(self, field).list.items.len; + inline for (fields) |field| @field(self, field).overwrite_next = true; + defer { + inline for (fields) |field| @field(self, field).overwrite_next = false; } // Initialize our CLI iterator. @@ -2673,28 +2884,6 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { try new_config.loadIter(alloc_gpa, &it); self.deinit(); self.* = new_config; - } else { - // If any of our font family settings were changed, then we - // replace the entire list with the new list. - inline for (fields, 0..) |field, i| { - const v = &@field(self, field); - - // The list can be empty if it was reset, i.e. --font-family="" - if (v.list.items.len > 0) { - const len = v.list.items.len - counter[i]; - if (len > 0) { - // Note: we don't have to worry about freeing the memory - // that we overwrite or cut off here because its all in - // an arena. - v.list.replaceRangeAssumeCapacity( - 0, - len, - v.list.items[counter[i]..], - ); - v.list.items.len = len; - } - } - } } // Any paths referenced from the CLI are relative to the current working @@ -3020,6 +3209,11 @@ pub fn finalize(self: *Config) !void { const alloc = self._arena.?.allocator(); + // Ensure our launch source is properly set. + if (self.@"launched-from" == null) { + self.@"launched-from" = .detect(); + } + // If we have a font-family set and don't set the others, default // the others to the font family. This way, if someone does // --font-family=foo, then we try to get the stylized versions of @@ -3044,14 +3238,11 @@ pub fn finalize(self: *Config) !void { } // The default for the working directory depends on the system. - const wd = self.@"working-directory" orelse wd: { + const wd = self.@"working-directory" orelse switch (self.@"launched-from".?) { // If we have no working directory set, our default depends on - // whether we were launched from the desktop or CLI. - if (internal_os.launchedFromDesktop()) { - break :wd "home"; - } - - break :wd "inherit"; + // whether we were launched from the desktop or elsewhere. + .desktop => "home", + .cli, .dbus, .systemd => "inherit", }; // If we are missing either a command or home directory, we need @@ -3074,7 +3265,10 @@ pub fn finalize(self: *Config) !void { // If we were launched from the desktop, our SHELL env var // will represent our SHELL at login time. We want to use the // latest shell from /etc/passwd or directory services. - if (internal_os.launchedFromDesktop()) break :shell_env; + switch (self.@"launched-from".?) { + .desktop, .dbus, .systemd => break :shell_env, + .cli => {}, + } if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { log.info("default shell source=env value={s}", .{value}); @@ -3246,7 +3440,7 @@ pub fn parseManuallyHook( /// be deallocated while shallow clones exist. pub fn shallowClone(self: *const Config, alloc_gpa: Allocator) Config { var result = self.*; - result._arena = ArenaAllocator.init(alloc_gpa); + result._arena = .init(alloc_gpa); return result; } @@ -4086,6 +4280,11 @@ pub const RepeatableString = struct { // Allocator for the list is the arena for the parent config. list: std.ArrayListUnmanaged([:0]const u8) = .{}, + // If true, then the next value will clear the list and start over + // rather than append. This is a bit of a hack but is here to make + // the font-family set of configurations work with CLI parsing. + overwrite_next: bool = false, + pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { const value = input orelse return error.ValueRequired; @@ -4095,6 +4294,12 @@ pub const RepeatableString = struct { return; } + // If we're overwriting then we clear before appending + if (self.overwrite_next) { + self.list.clearRetainingCapacity(); + self.overwrite_next = false; + } + const copy = try alloc.dupeZ(u8, value); try self.list.append(alloc, copy); } @@ -4161,6 +4366,24 @@ pub const RepeatableString = struct { try testing.expectEqual(@as(usize, 0), list.list.items.len); } + test "parseCLI overwrite" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "A"); + + // Set our overwrite flag + list.overwrite_next = true; + + try list.parseCLI(alloc, "B"); + try testing.expectEqual(@as(usize, 1), list.list.items.len); + try list.parseCLI(alloc, "C"); + try testing.expectEqual(@as(usize, 2), list.list.items.len); + } + test "formatConfig empty" { const testing = std.testing; var buf = std.ArrayList(u8).init(testing.allocator); @@ -4343,12 +4566,12 @@ pub const Keybinds = struct { // keybinds for opening and reloading config try self.set.put( alloc, - .{ .key = .{ .translated = .comma }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .{ .key = .{ .unicode = ',' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, .{ .reload_config = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .comma }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .unicode = ',' }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .open_config = {} }, ); @@ -4362,12 +4585,12 @@ pub const Keybinds = struct { if (!builtin.target.os.tag.isDarwin()) { try self.set.put( alloc, - .{ .key = .{ .translated = .insert }, .mods = .{ .ctrl = true } }, + .{ .key = .{ .physical = .insert }, .mods = .{ .ctrl = true } }, .{ .copy_to_clipboard = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .insert }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .insert }, .mods = .{ .shift = true } }, .{ .paste_from_clipboard = {} }, ); } @@ -4381,12 +4604,12 @@ pub const Keybinds = struct { try self.set.put( alloc, - .{ .key = .{ .translated = .c }, .mods = mods }, + .{ .key = .{ .unicode = 'c' }, .mods = mods }, .{ .copy_to_clipboard = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .v }, .mods = mods }, + .{ .key = .{ .unicode = 'v' }, .mods = mods }, .{ .paste_from_clipboard = {} }, ); } @@ -4397,84 +4620,84 @@ pub const Keybinds = struct { // set the expected keybind for the menu. try self.set.put( alloc, - .{ .key = .{ .translated = .plus }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .physical = .equal }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .increase_font_size = 1 }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .equal }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .unicode = '+' }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .increase_font_size = 1 }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .minus }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .unicode = '-' }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .decrease_font_size = 1 }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .zero }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .unicode = '0' }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .reset_font_size = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .j }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .{ .key = .{ .unicode = 'j' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, .{ .write_screen_file = .paste }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .j }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true, .alt = true }) }, + .{ .key = .{ .unicode = 'j' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true, .alt = true }) }, .{ .write_screen_file = .open }, ); // Expand Selection try self.set.putFlags( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .shift = true } }, .{ .adjust_selection = .left }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .shift = true } }, .{ .adjust_selection = .right }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .shift = true } }, .{ .adjust_selection = .up }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .shift = true } }, .{ .adjust_selection = .down }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .page_up }, .mods = .{ .shift = true } }, .{ .adjust_selection = .page_up }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .page_down }, .mods = .{ .shift = true } }, .{ .adjust_selection = .page_down }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .home }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .home }, .mods = .{ .shift = true } }, .{ .adjust_selection = .home }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .end }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .end }, .mods = .{ .shift = true } }, .{ .adjust_selection = .end }, .{ .performable = true }, ); @@ -4482,12 +4705,12 @@ pub const Keybinds = struct { // Tabs common to all platforms try self.set.put( alloc, - .{ .key = .{ .translated = .tab }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .tab }, .mods = .{ .ctrl = true, .shift = true } }, .{ .previous_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .tab }, .mods = .{ .ctrl = true } }, + .{ .key = .{ .physical = .tab }, .mods = .{ .ctrl = true } }, .{ .next_tab = {} }, ); @@ -4495,174 +4718,169 @@ pub const Keybinds = struct { if (comptime !builtin.target.os.tag.isDarwin()) { try self.set.put( alloc, - .{ .key = .{ .translated = .n }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 'n' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_window = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .w }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .close_surface = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .q }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 'q' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .quit = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .f4 }, .mods = .{ .alt = true } }, + .{ .key = .{ .physical = .f4 }, .mods = .{ .alt = true } }, .{ .close_window = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .t }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 't' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .w }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .close_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .shift = true } }, .{ .previous_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .shift = true } }, .{ .next_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_up }, .mods = .{ .ctrl = true } }, + .{ .key = .{ .physical = .page_up }, .mods = .{ .ctrl = true } }, .{ .previous_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_down }, .mods = .{ .ctrl = true } }, + .{ .key = .{ .physical = .page_down }, .mods = .{ .ctrl = true } }, .{ .next_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .o }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 'o' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_split = .right }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .e }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 'e' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_split = .down }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left_bracket }, .mods = .{ .ctrl = true, .super = true } }, + .{ .key = .{ .physical = .bracket_left }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .previous }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right_bracket }, .mods = .{ .ctrl = true, .super = true } }, + .{ .key = .{ .physical = .bracket_right }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .next }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .up }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .down }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .left }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .right }, ); // Resizing splits try self.set.put( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .up, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .down, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .left, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .right, 10 } }, ); - try self.set.put( - alloc, - .{ .key = .{ .translated = .plus }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, - .{ .equalize_splits = {} }, - ); // Viewport scrolling try self.set.put( alloc, - .{ .key = .{ .translated = .home }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .home }, .mods = .{ .shift = true } }, .{ .scroll_to_top = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .end }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .end }, .mods = .{ .shift = true } }, .{ .scroll_to_bottom = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .page_up }, .mods = .{ .shift = true } }, .{ .scroll_page_up = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .page_down }, .mods = .{ .shift = true } }, .{ .scroll_page_down = {} }, ); // Semantic prompts try self.set.put( alloc, - .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .physical = .page_up }, .mods = .{ .shift = true, .ctrl = true } }, .{ .jump_to_prompt = -1 }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .physical = .page_down }, .mods = .{ .shift = true, .ctrl = true } }, .{ .jump_to_prompt = 1 }, ); // Inspector, matching Chromium try self.set.put( alloc, - .{ .key = .{ .translated = .i }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .unicode = 'i' }, .mods = .{ .shift = true, .ctrl = true } }, .{ .inspector = .toggle }, ); // Terminal try self.set.put( alloc, - .{ .key = .{ .translated = .a }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .unicode = 'a' }, .mods = .{ .shift = true, .ctrl = true } }, .{ .select_all = {} }, ); // Selection clipboard paste try self.set.put( alloc, - .{ .key = .{ .translated = .insert }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .insert }, .mods = .{ .shift = true } }, .{ .paste_from_selection = {} }, ); } @@ -4675,23 +4893,14 @@ pub const Keybinds = struct { .{ .alt = true }; // Cmd+N for goto tab N - const start = @intFromEnum(inputpkg.Key.one); - const end = @intFromEnum(inputpkg.Key.eight); - var i: usize = start; + const start: u21 = '1'; + const end: u21 = '8'; + var i: u21 = start; while (i <= end) : (i += 1) { try self.set.put( alloc, .{ - // On macOS, we use the physical key for tab changing so - // that this works across all keyboard layouts. This may - // want to be true on other platforms as well but this - // is definitely true on macOS so we just do it here for - // now (#817) - .key = if (comptime builtin.target.os.tag.isDarwin()) - .{ .physical = @enumFromInt(i) } - else - .{ .translated = @enumFromInt(i) }, - + .key = .{ .unicode = i }, .mods = mods, }, .{ .goto_tab = (i - start) + 1 }, @@ -4700,10 +4909,7 @@ pub const Keybinds = struct { try self.set.put( alloc, .{ - .key = if (comptime builtin.target.os.tag.isDarwin()) - .{ .physical = .nine } - else - .{ .translated = .nine }, + .key = .{ .unicode = '9' }, .mods = mods, }, .{ .last_tab = {} }, @@ -4713,214 +4919,234 @@ pub const Keybinds = struct { // Toggle fullscreen try self.set.put( alloc, - .{ .key = .{ .translated = .enter }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .physical = .enter }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .toggle_fullscreen = {} }, ); // Toggle zoom a split try self.set.put( alloc, - .{ .key = .{ .translated = .enter }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .{ .key = .{ .physical = .enter }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, .{ .toggle_split_zoom = {} }, ); + // Toggle command palette, matches VSCode + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'p' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .toggle_command_palette, + ); + // Mac-specific keyboard bindings. if (comptime builtin.target.os.tag.isDarwin()) { try self.set.put( alloc, - .{ .key = .{ .translated = .q }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 'q' }, .mods = .{ .super = true } }, .{ .quit = {} }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .k }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 'k' }, .mods = .{ .super = true } }, .{ .clear_screen = {} }, .{ .performable = true }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .a }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 'a' }, .mods = .{ .super = true } }, .{ .select_all = {} }, ); + // Undo/redo + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 't' }, .mods = .{ .super = true, .shift = true } }, + .{ .undo = {} }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true } }, + .{ .undo = {} }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true, .shift = true } }, + .{ .redo = {} }, + .{ .performable = true }, + ); + // Viewport scrolling try self.set.put( alloc, - .{ .key = .{ .translated = .home }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .home }, .mods = .{ .super = true } }, .{ .scroll_to_top = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .end }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .end }, .mods = .{ .super = true } }, .{ .scroll_to_bottom = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_up }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .page_up }, .mods = .{ .super = true } }, .{ .scroll_page_up = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_down }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .page_down }, .mods = .{ .super = true } }, .{ .scroll_page_down = {} }, ); // Semantic prompts try self.set.put( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .shift = true } }, .{ .jump_to_prompt = -1 }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .shift = true } }, .{ .jump_to_prompt = 1 }, ); // Mac windowing try self.set.put( alloc, - .{ .key = .{ .translated = .n }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 'n' }, .mods = .{ .super = true } }, .{ .new_window = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .w }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .super = true } }, .{ .close_surface = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .w }, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .super = true, .alt = true } }, .{ .close_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .w }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .super = true, .shift = true } }, .{ .close_window = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .w }, .mods = .{ .super = true, .shift = true, .alt = true } }, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .super = true, .shift = true, .alt = true } }, .{ .close_all_windows = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .t }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 't' }, .mods = .{ .super = true } }, .{ .new_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left_bracket }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .physical = .bracket_left }, .mods = .{ .super = true, .shift = true } }, .{ .previous_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right_bracket }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .physical = .bracket_right }, .mods = .{ .super = true, .shift = true } }, .{ .next_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .d }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 'd' }, .mods = .{ .super = true } }, .{ .new_split = .right }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .d }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .unicode = 'd' }, .mods = .{ .super = true, .shift = true } }, .{ .new_split = .down }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left_bracket }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .bracket_left }, .mods = .{ .super = true } }, .{ .goto_split = .previous }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right_bracket }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .bracket_right }, .mods = .{ .super = true } }, .{ .goto_split = .next }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .up }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .down }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .left }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .right }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .ctrl = true } }, .{ .resize_split = .{ .up, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .ctrl = true } }, .{ .resize_split = .{ .down, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .ctrl = true } }, .{ .resize_split = .{ .left, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .ctrl = true } }, .{ .resize_split = .{ .right, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .equal }, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .physical = .equal }, .mods = .{ .super = true, .ctrl = true } }, .{ .equalize_splits = {} }, ); // Jump to prompt, matches Terminal.app try self.set.put( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true } }, .{ .jump_to_prompt = -1 }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true } }, .{ .jump_to_prompt = 1 }, ); - // Toggle command palette, matches VSCode - try self.set.put( - alloc, - .{ .key = .{ .translated = .p }, .mods = .{ .super = true, .shift = true } }, - .{ .toggle_command_palette = {} }, - ); - // Inspector, matching Chromium try self.set.put( alloc, - .{ .key = .{ .translated = .i }, .mods = .{ .alt = true, .super = true } }, + .{ .key = .{ .unicode = 'i' }, .mods = .{ .alt = true, .super = true } }, .{ .inspector = .toggle }, ); // Alternate keybind, common to Mac programs try self.set.put( alloc, - .{ .key = .{ .translated = .f }, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .ctrl = true } }, .{ .toggle_fullscreen = {} }, ); // Selection clipboard paste, matches Terminal.app try self.set.put( alloc, - .{ .key = .{ .translated = .v }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .unicode = 'v' }, .mods = .{ .super = true, .shift = true } }, .{ .paste_from_selection = {} }, ); @@ -4931,27 +5157,27 @@ pub const Keybinds = struct { // the keybinds to `unbind`. try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true } }, .{ .text = "\\x05" }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true } }, .{ .text = "\\x01" }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .backspace }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .backspace }, .mods = .{ .super = true } }, .{ .text = "\\x15" }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .alt = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .alt = true } }, .{ .esc = "b" }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .alt = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .alt = true } }, .{ .esc = "f" }, ); } @@ -5138,8 +5364,8 @@ pub const Keybinds = struct { // Note they turn into translated keys because they match // their ASCII mapping. const want = - \\keybind = ctrl+z>two=goto_tab:2 - \\keybind = ctrl+z>one=goto_tab:1 + \\keybind = ctrl+z>2=goto_tab:2 + \\keybind = ctrl+z>1=goto_tab:1 \\ ; try std.testing.expectEqualStrings(want, buf.items); @@ -5163,9 +5389,9 @@ pub const Keybinds = struct { // NB: This does not currently retain the order of the keybinds. const want = + \\a = ctrl+a>ctrl+c>t=new_tab \\a = ctrl+a>ctrl+b>w=close_window \\a = ctrl+a>ctrl+b>n=new_window - \\a = ctrl+a>ctrl+c>t=new_tab \\a = ctrl+b>ctrl+d>a=previous_tab \\ ; @@ -5678,6 +5904,12 @@ pub const WindowColorspace = enum { @"display-p3", }; +/// See macos-window-buttons +pub const MacWindowButtons = enum { + visible, + hidden, +}; + /// See macos-titlebar-style pub const MacTitlebarStyle = enum { native, @@ -5753,6 +5985,9 @@ pub const AppNotifications = packed struct { /// See bell-features pub const BellFeatures = packed struct { system: bool = false, + audio: bool = false, + attention: bool = true, + title: bool = true, }; /// See mouse-shift-capture @@ -5905,7 +6140,7 @@ pub const QuickTerminalSize = struct { it.next() orelse return error.ValueRequired, cli.args.whitespace, ); - self.primary = try Size.parse(primary); + self.primary = try .parse(primary); self.secondary = secondary: { const secondary = std.mem.trim( @@ -5913,7 +6148,7 @@ pub const QuickTerminalSize = struct { it.next() orelse break :secondary null, cli.args.whitespace, ); - break :secondary try Size.parse(secondary); + break :secondary try .parse(secondary); }; if (it.next()) |_| return error.TooManyArguments; @@ -6068,6 +6303,13 @@ pub const QuickTerminalSpaceBehavior = enum { move, }; +/// See quick-terminal-keyboard-interactivity +pub const QuickTerminalKeyboardInteractivity = enum { + none, + @"on-demand", + exclusive, +}; + /// See grapheme-width-method pub const GraphemeWidthMethod = enum { legacy, @@ -6395,7 +6637,7 @@ pub const Duration = struct { if (remaining.len == 0) break; // Find the longest number - const number = number: { + const number: u64 = number: { var prev_number: ?u64 = null; var prev_remaining: ?[]const u8 = null; for (1..remaining.len + 1) |index| { @@ -6409,8 +6651,17 @@ pub const Duration = struct { break :number prev_number; } orelse return error.InvalidValue; - // A number without a unit is invalid - if (remaining.len == 0) return error.InvalidValue; + // A number without a unit is invalid unless the number is + // exactly zero. In that case, the unit is unambiguous since + // its all the same. + if (remaining.len == 0) { + if (number == 0) { + value = 0; + break; + } + + return error.InvalidValue; + } // Find the longest matching unit. Needs to be the longest matching // to distinguish 'm' from 'ms'. @@ -6484,6 +6735,34 @@ pub const Duration = struct { } }; +pub const LaunchSource = enum { + /// Ghostty was launched via the CLI. This is the default if + /// no other source is detected. + cli, + + /// Ghostty was launched in a desktop environment (not via the CLI). + /// This is used to determine some behaviors such as how to read + /// settings, whether single instance defaults to true, etc. + desktop, + + /// Ghostty was started via dbus activation. + dbus, + + /// Ghostty was started via systemd activation. + systemd, + + pub fn detect() LaunchSource { + return if (internal_os.launchedFromDesktop()) + .desktop + else if (internal_os.launchedByDbusActivation()) + .dbus + else if (internal_os.launchedBySystemd()) + .systemd + else + .cli; + } +}; + pub const WindowPadding = struct { const Self = @This(); @@ -6592,6 +6871,11 @@ test "parse duration" { try std.testing.expectEqual(unit.factor, d.duration); } + { + const d = try Duration.parseCLI("0"); + try std.testing.expectEqual(@as(u64, 0), d.duration); + } + { const d = try Duration.parseCLI("100ns"); try std.testing.expectEqual(@as(u64, 100), d.duration); diff --git a/src/config/formatter.zig b/src/config/formatter.zig index ca3da1d91..cabf80953 100644 --- a/src/config/formatter.zig +++ b/src/config/formatter.zig @@ -153,7 +153,7 @@ pub const FileFormatter = struct { // If we're change-tracking then we need the default config to // compare against. var default: ?Config = if (self.changed) - try Config.default(self.alloc) + try .default(self.alloc) else null; defer if (default) |*v| v.deinit(); diff --git a/src/config/url.zig b/src/config/url.zig index 9f9f3fa4a..da3928aff 100644 --- a/src/config/url.zig +++ b/src/config/url.zig @@ -26,7 +26,7 @@ pub const regex = "(?:" ++ url_schemes ++ \\)(?: ++ ipv6_url_pattern ++ - \\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(? .{ .attachment = try Attachment.decode( + .attachment => .{ .attachment = try .decode( alloc, encoded, ) }, diff --git a/src/datastruct/cache_table.zig b/src/datastruct/cache_table.zig index 40d36cc24..fbfb30d71 100644 --- a/src/datastruct/cache_table.zig +++ b/src/datastruct/cache_table.zig @@ -70,7 +70,7 @@ pub fn CacheTable( /// become a pointless check, but hopefully branch prediction picks /// up on it at that point. The memory cost isn't too bad since it's /// just bytes, so should be a fraction the size of the main table. - lengths: [bucket_count]u8 = [_]u8{0} ** bucket_count, + lengths: [bucket_count]u8 = @splat(0), /// An instance of the context structure. /// Must be initialized before calling any operations. diff --git a/src/datastruct/circ_buf.zig b/src/datastruct/circ_buf.zig index 065bf6a1d..646a00940 100644 --- a/src/datastruct/circ_buf.zig +++ b/src/datastruct/circ_buf.zig @@ -152,7 +152,7 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { /// If larger, new values will be set to the default value. pub fn resize(self: *Self, alloc: Allocator, size: usize) Allocator.Error!void { // Rotate to zero so it is aligned. - try self.rotateToZero(alloc); + try self.rotateToZero(); // Reallocate, this adds to the end so we're ready to go. const prev_len = self.len(); @@ -173,29 +173,16 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { } /// Rotate the data so that it is zero-aligned. - fn rotateToZero(self: *Self, alloc: Allocator) Allocator.Error!void { - // TODO: this does this in the worst possible way by allocating. - // rewrite to not allocate, its possible, I'm just lazy right now. - + fn rotateToZero(self: *Self) Allocator.Error!void { // If we're already at zero then do nothing. if (self.tail == 0) return; - var buf = try alloc.alloc(T, self.storage.len); - defer { - self.head = if (self.full) 0 else self.len(); - self.tail = 0; - alloc.free(self.storage); - self.storage = buf; - } + // We use std.mem.rotate to rotate our storage in-place. + std.mem.rotate(T, self.storage, self.tail); - if (!self.full and self.head >= self.tail) { - fastmem.copy(T, buf, self.storage[self.tail..self.head]); - return; - } - - const middle = self.storage.len - self.tail; - fastmem.copy(T, buf, self.storage[self.tail..]); - fastmem.copy(T, buf[middle..], self.storage[0..self.head]); + // Then fix up our head and tail. + self.head = self.len() % self.storage.len; + self.tail = 0; } /// Returns if the buffer is currently empty. To check if its @@ -589,7 +576,7 @@ test "CircBuf rotateToZero" { defer buf.deinit(alloc); _ = buf.getPtrSlice(0, 11); - try buf.rotateToZero(alloc); + try buf.rotateToZero(); } test "CircBuf rotateToZero offset" { @@ -611,7 +598,7 @@ test "CircBuf rotateToZero offset" { try testing.expect(buf.tail > 0 and buf.head >= buf.tail); // Rotate to zero - try buf.rotateToZero(alloc); + try buf.rotateToZero(); try testing.expectEqual(@as(usize, 0), buf.tail); try testing.expectEqual(@as(usize, 1), buf.head); } @@ -645,7 +632,7 @@ test "CircBuf rotateToZero wraps" { } // Rotate to zero - try buf.rotateToZero(alloc); + try buf.rotateToZero(); try testing.expectEqual(@as(usize, 0), buf.tail); try testing.expectEqual(@as(usize, 3), buf.head); { @@ -681,7 +668,7 @@ test "CircBuf rotateToZero full no wrap" { } // Rotate to zero - try buf.rotateToZero(alloc); + try buf.rotateToZero(); try testing.expect(buf.full); try testing.expectEqual(@as(usize, 0), buf.tail); try testing.expectEqual(@as(usize, 0), buf.head); diff --git a/src/font/CodepointResolver.zig b/src/font/CodepointResolver.zig index 37093b59a..16536300c 100644 --- a/src/font/CodepointResolver.zig +++ b/src/font/CodepointResolver.zig @@ -37,7 +37,7 @@ collection: Collection, /// The set of statuses and whether they're enabled or not. This defaults /// to true. This can be changed at runtime with no ill effect. -styles: StyleStatus = StyleStatus.initFill(true), +styles: StyleStatus = .initFill(true), /// If discovery is available, we'll look up fonts where we can't find /// the codepoint. This can be set after initialization. @@ -140,7 +140,7 @@ pub fn getIndex( // handle this. if (self.sprite) |sprite| { if (sprite.hasCodepoint(cp, p)) { - return Collection.Index.initSpecial(.sprite); + return .initSpecial(.sprite); } } @@ -388,7 +388,7 @@ test getIndex { { errdefer c.deinit(alloc); - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -398,7 +398,7 @@ test getIndex { _ = try c.add( alloc, .regular, - .{ .loaded = try Face.init( + .{ .loaded = try .init( lib, testEmoji, .{ .size = .{ .points = 12 } }, @@ -408,7 +408,7 @@ test getIndex { _ = try c.add( alloc, .regular, - .{ .loaded = try Face.init( + .{ .loaded = try .init( lib, testEmojiText, .{ .size = .{ .points = 12 } }, @@ -467,17 +467,17 @@ test "getIndex disabled font style" { var c = Collection.init(); c.load_options = .{ .library = lib }; - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, ) }); - _ = try c.add(alloc, .bold, .{ .loaded = try Face.init( + _ = try c.add(alloc, .bold, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, ) }); - _ = try c.add(alloc, .italic, .{ .loaded = try Face.init( + _ = try c.add(alloc, .italic, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, diff --git a/src/font/Collection.zig b/src/font/Collection.zig index 59f89d402..8533331bc 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -55,7 +55,7 @@ load_options: ?LoadOptions = null, pub fn init() Collection { // Initialize our styles array, preallocating some space that is // likely to be used. - return .{ .faces = StyleArray.initFill(.{}) }; + return .{ .faces = .initFill(.{}) }; } pub fn deinit(self: *Collection, alloc: Allocator) void { @@ -707,7 +707,7 @@ test "add full" { defer c.deinit(alloc); for (0..Index.Special.start - 1) |_| { - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12 } }, @@ -755,7 +755,7 @@ test getFace { var c = init(); defer c.deinit(alloc); - const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init( + const idx = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -779,7 +779,7 @@ test getIndex { var c = init(); defer c.deinit(alloc); - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -811,7 +811,7 @@ test completeStyles { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -838,7 +838,7 @@ test setSize { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -861,7 +861,7 @@ test hasCodepoint { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init( + const idx = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -885,7 +885,7 @@ test "hasCodepoint emoji default graphical" { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init( + const idx = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmoji, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -908,7 +908,7 @@ test "metrics" { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, diff --git a/src/font/DeferredFace.zig b/src/font/DeferredFace.zig index 8794ccea9..f9ce0bff5 100644 --- a/src/font/DeferredFace.zig +++ b/src/font/DeferredFace.zig @@ -254,7 +254,7 @@ fn loadWebCanvas( opts: font.face.Options, ) !Face { const wc = self.wc.?; - return try Face.initNamed(wc.alloc, wc.font_str, opts, wc.presentation); + return try .initNamed(wc.alloc, wc.font_str, opts, wc.presentation); } /// Returns true if this face can satisfy the given codepoint and diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 72e97fad8..35770f920 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -319,7 +319,7 @@ fn testGrid(mode: TestMode, alloc: Allocator, lib: Library) !SharedGrid { switch (mode) { .normal => { - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig index 8ad30629e..e3e61907b 100644 --- a/src/font/SharedGridSet.zig +++ b/src/font/SharedGridSet.zig @@ -126,7 +126,7 @@ pub fn ref( .ref = 1, }; - grid.* = try SharedGrid.init(self.alloc, resolver: { + grid.* = try .init(self.alloc, resolver: { // Build our collection. This is the expensive operation that // involves finding fonts, loading them (maybe, some are deferred), // etc. @@ -258,7 +258,7 @@ fn collection( _ = try c.add( self.alloc, .regular, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.regular, load_options.faceOptions(), @@ -267,7 +267,7 @@ fn collection( _ = try c.add( self.alloc, .bold, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.bold, load_options.faceOptions(), @@ -276,7 +276,7 @@ fn collection( _ = try c.add( self.alloc, .italic, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.italic, load_options.faceOptions(), @@ -285,7 +285,7 @@ fn collection( _ = try c.add( self.alloc, .bold_italic, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.bold_italic, load_options.faceOptions(), @@ -318,7 +318,7 @@ fn collection( _ = try c.add( self.alloc, .regular, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.emoji, load_options.faceOptions(), @@ -327,7 +327,7 @@ fn collection( _ = try c.add( self.alloc, .regular, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.emoji_text, load_options.faceOptions(), @@ -391,7 +391,7 @@ fn discover(self: *SharedGridSet) !?*Discover { // If we initialized, use it if (self.font_discover) |*v| return v; - self.font_discover = Discover.init(); + self.font_discover = .init(); return &self.font_discover.?; } @@ -498,7 +498,7 @@ pub const Key = struct { /// each style. For example, bold is from /// offsets[@intFromEnum(.bold) - 1] to /// offsets[@intFromEnum(.bold)]. - style_offsets: StyleOffsets = .{0} ** style_offsets_len, + style_offsets: StyleOffsets = @splat(0), /// The codepoint map configuration. codepoint_map: CodepointMap = .{}, diff --git a/src/font/discovery.zig b/src/font/discovery.zig index 384799da5..9284f9486 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator; const assert = std.debug.assert; const fontconfig = @import("fontconfig"); const macos = @import("macos"); +const opentype = @import("opentype.zig"); const options = @import("main.zig").options; const Collection = @import("main.zig").Collection; const DeferredFace = @import("main.zig").DeferredFace; @@ -562,149 +563,266 @@ pub const CoreText = struct { desc: *const Descriptor, list: []*macos.text.FontDescriptor, ) void { - var desc_mut = desc.*; - if (desc_mut.style == null) { - // If there is no explicit style set, we set a preferred - // based on the style bool attributes. - // - // TODO: doesn't handle i18n font names well, we should have - // another mechanism that uses the weight attribute if it exists. - // Wait for this to be a real problem. - desc_mut.style = if (desc_mut.bold and desc_mut.italic) - "Bold Italic" - else if (desc_mut.bold) - "Bold" - else if (desc_mut.italic) - "Italic" - else - null; - } - - std.mem.sortUnstable(*macos.text.FontDescriptor, list, &desc_mut, struct { + std.mem.sortUnstable(*macos.text.FontDescriptor, list, desc, struct { fn lessThan( desc_inner: *const Descriptor, lhs: *macos.text.FontDescriptor, rhs: *macos.text.FontDescriptor, ) bool { - const lhs_score = score(desc_inner, lhs); - const rhs_score = score(desc_inner, rhs); + const lhs_score: Score = .score(desc_inner, lhs); + const rhs_score: Score = .score(desc_inner, rhs); // Higher score is "less" (earlier) return lhs_score.int() > rhs_score.int(); } }.lessThan); } - /// We represent our sorting score as a packed struct so that we can - /// compare scores numerically but build scores symbolically. + /// We represent our sorting score as a packed struct so that we + /// can compare scores numerically but build scores symbolically. + /// + /// Note that packed structs store their fields from least to most + /// significant, so the fields here are defined in increasing order + /// of precedence. const Score = packed struct { const Backing = @typeInfo(@This()).@"struct".backing_integer.?; - glyph_count: u16 = 0, // clamped if > intmax - traits: Traits = .unmatched, - style: Style = .unmatched, + /// Number of glyphs in the font, if two fonts have identical + /// scores otherwise then we prefer the one with more glyphs. + /// + /// (Number of glyphs clamped at u16 intmax) + glyph_count: u16 = 0, + /// A fuzzy match on the style string, less important than + /// an exact match, and less important than trait matches. + fuzzy_style: u8 = 0, + /// Whether the bold-ness of the font matches the descriptor. + /// This is less important than italic because a font that's italic + /// when it shouldn't be or not italic when it should be is a bigger + /// problem (subjectively) than being the wrong weight. + bold: bool = false, + /// Whether the italic-ness of the font matches the descriptor. + /// This is less important than an exact match on the style string + /// because we want users to be allowed to override trait matching + /// for the bold/italic/bold italic styles if they want. + italic: bool = false, + /// An exact (case-insensitive) match on the style string. + exact_style: bool = false, + /// Whether the font is monospace, this is more important than any of + /// the other fields unless we're looking for a specific codepoint, + /// in which case that is the most important thing. monospace: bool = false, + /// If we're looking for a codepoint, whether this font has it. codepoint: bool = false, - const Traits = enum(u8) { unmatched = 0, _ }; - const Style = enum(u8) { unmatched = 0, match = 0xFF, _ }; - pub fn int(self: Score) Backing { return @bitCast(self); } - }; - fn score(desc: *const Descriptor, ct_desc: *const macos.text.FontDescriptor) Score { - var score_acc: Score = .{}; + fn score(desc: *const Descriptor, ct_desc: *const macos.text.FontDescriptor) Score { + var self: Score = .{}; - // We always load the font if we can since some things can only be - // inspected on the font itself. - const font_: ?*macos.text.Font = macos.text.Font.createWithFontDescriptor( - ct_desc, - 12, - ) catch null; - defer if (font_) |font| font.release(); + // We always load the font if we can since some things can only be + // inspected on the font itself. Fonts that can't be loaded score + // 0 automatically because we don't want a font we can't load. + const font: *macos.text.Font = macos.text.Font.createWithFontDescriptor( + ct_desc, + 12, + ) catch return self; + defer font.release(); - // If we have a font, prefer the font with more glyphs. - if (font_) |font| { - const Type = @TypeOf(score_acc.glyph_count); - score_acc.glyph_count = std.math.cast( - Type, - font.getGlyphCount(), - ) orelse std.math.maxInt(Type); - } - - // If we're searching for a codepoint, prioritize fonts that - // have that codepoint. - if (desc.codepoint > 0) codepoint: { - const font = font_ orelse break :codepoint; - - // Turn UTF-32 into UTF-16 for CT API - var unichars: [2]u16 = undefined; - const pair = macos.foundation.stringGetSurrogatePairForLongCharacter( - desc.codepoint, - &unichars, - ); - const len: usize = if (pair) 2 else 1; - - // Get our glyphs - var glyphs = [2]macos.graphics.Glyph{ 0, 0 }; - score_acc.codepoint = font.getGlyphsForCharacters(unichars[0..len], glyphs[0..len]); - } - - // Get our symbolic traits for the descriptor so we can compare - // boolean attributes like bold, monospace, etc. - const symbolic_traits: macos.text.FontSymbolicTraits = traits: { - const traits = ct_desc.copyAttribute(.traits) orelse break :traits .{}; - defer traits.release(); - - const key = macos.text.FontTraitKey.symbolic.key(); - const symbolic = traits.getValue(macos.foundation.Number, key) orelse - break :traits .{}; - - break :traits macos.text.FontSymbolicTraits.init(symbolic); - }; - - score_acc.monospace = symbolic_traits.monospace; - - score_acc.style = style: { - const style = ct_desc.copyAttribute(.style_name) orelse - break :style .unmatched; - defer style.release(); - - // Get our style string - var buf: [128]u8 = undefined; - const style_str = style.cstring(&buf, .utf8) orelse break :style .unmatched; - - // If we have a specific desired style, attempt to search for that. - if (desc.style) |desired_style| { - // Matching style string gets highest score - if (std.mem.eql(u8, desired_style, style_str)) break :style .match; - } else if (!desc.bold and !desc.italic) { - // If we do not, and we have no symbolic traits, then we try - // to find "regular" (or no style). If we have symbolic traits - // we do nothing but we can improve scoring by taking that into - // account, too. - if (std.mem.eql(u8, "Regular", style_str)) { - break :style .match; - } + // We prefer fonts with more glyphs, all else being equal. + { + const Type = @TypeOf(self.glyph_count); + self.glyph_count = std.math.cast( + Type, + font.getGlyphCount(), + ) orelse std.math.maxInt(Type); } - // Otherwise the score is based on the length of the style string. - // Shorter styles are scored higher. This is a heuristic that - // if we don't have a desired style then shorter tends to be - // more often the "regular" style. - break :style @enumFromInt(100 -| style_str.len); - }; + // If we're searching for a codepoint, then we + // prioritize fonts that have that codepoint. + if (desc.codepoint > 0) { + // Turn UTF-32 into UTF-16 for CT API + var unichars: [2]u16 = undefined; + const pair = macos.foundation.stringGetSurrogatePairForLongCharacter( + desc.codepoint, + &unichars, + ); + const len: usize = if (pair) 2 else 1; - score_acc.traits = traits: { - var count: u8 = 0; - if (desc.bold == symbolic_traits.bold) count += 1; - if (desc.italic == symbolic_traits.italic) count += 1; - break :traits @enumFromInt(count); - }; + // Get our glyphs + var glyphs = [2]macos.graphics.Glyph{ 0, 0 }; + self.codepoint = font.getGlyphsForCharacters( + unichars[0..len], + glyphs[0..len], + ); + } - return score_acc; - } + // Get our symbolic traits for the descriptor so we can + // compare boolean attributes like bold, monospace, etc. + const symbolic_traits: macos.text.FontSymbolicTraits = traits: { + const traits = ct_desc.copyAttribute(.traits) orelse break :traits .{}; + defer traits.release(); + + const key = macos.text.FontTraitKey.symbolic.key(); + const symbolic = traits.getValue(macos.foundation.Number, key) orelse + break :traits .{}; + + break :traits macos.text.FontSymbolicTraits.init(symbolic); + }; + + self.monospace = symbolic_traits.monospace; + + // We try to derived data from the font itself, which is generally + // more reliable than only using the symbolic traits for this. + const is_bold: bool, const is_italic: bool = derived: { + // We start with initial guesses based on the symbolic traits, + // but refine these with more information if we can get it. + var is_italic = symbolic_traits.italic; + var is_bold = symbolic_traits.bold; + + // Read the 'head' table out of the font data if it's available. + if (head: { + const tag = macos.text.FontTableTag.init("head"); + const data = font.copyTable(tag) orelse break :head null; + defer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + break :head opentype.Head.init(ptr[0..len]) catch |err| { + log.warn("error parsing head table: {}", .{err}); + break :head null; + }; + }) |head_| { + const head: opentype.Head = head_; + is_bold = is_bold or (head.macStyle & 1 == 1); + is_italic = is_italic or (head.macStyle & 2 == 2); + } + + // Read the 'OS/2' table out of the font data if it's available. + if (os2: { + const tag = macos.text.FontTableTag.init("OS/2"); + const data = font.copyTable(tag) orelse break :os2 null; + defer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + break :os2 opentype.OS2.init(ptr[0..len]) catch |err| { + log.warn("error parsing OS/2 table: {}", .{err}); + break :os2 null; + }; + }) |os2| { + is_bold = is_bold or os2.fsSelection.bold; + is_italic = is_italic or os2.fsSelection.italic; + } + + // Check if we have variation axes in our descriptor, if we + // do then we can derive weight italic-ness or both from them. + if (font.copyAttribute(.variation_axes)) |axes| variations: { + defer axes.release(); + + // Copy the variation values for this instance of the font. + // if there are none then we just break out immediately. + const values: *macos.foundation.Dictionary = + font.copyAttribute(.variation) orelse break :variations; + defer values.release(); + + var buf: [1024]u8 = undefined; + + // If we see the 'ital' value then we ignore 'slnt'. + var ital_seen = false; + + const len = axes.getCount(); + for (0..len) |i| { + const dict = axes.getValueAtIndex(macos.foundation.Dictionary, i); + const Key = macos.text.FontVariationAxisKey; + const cf_id = dict.getValue(Key.identifier.Value(), Key.identifier.key()).?; + const cf_name = dict.getValue(Key.name.Value(), Key.name.key()).?; + const cf_def = dict.getValue(Key.default_value.Value(), Key.default_value.key()).?; + + const name_str = cf_name.cstring(&buf, .utf8) orelse ""; + + // Default value + var def: f64 = 0; + _ = cf_def.getValue(.double, &def); + // Value in this font + var val: f64 = def; + if (values.getValue( + macos.foundation.Number, + cf_id, + )) |cf_val| _ = cf_val.getValue(.double, &val); + + if (std.mem.eql(u8, "wght", name_str)) { + // Somewhat subjective threshold, we consider fonts + // bold if they have a 'wght' set greater than 600. + is_bold = val > 600; + continue; + } + if (std.mem.eql(u8, "ital", name_str)) { + is_italic = val > 0.5; + ital_seen = true; + continue; + } + if (!ital_seen and std.mem.eql(u8, "slnt", name_str)) { + // Arbitrary threshold of anything more than a 5 + // degree clockwise slant is considered italic. + is_italic = val <= -5.0; + continue; + } + } + } + + break :derived .{ is_bold, is_italic }; + }; + + self.bold = desc.bold == is_bold; + self.italic = desc.italic == is_italic; + + // Get the style string from the font. + var style_str_buf: [128]u8 = undefined; + const style_str: []const u8 = style_str: { + const style = ct_desc.copyAttribute(.style_name) orelse + break :style_str ""; + defer style.release(); + + break :style_str style.cstring(&style_str_buf, .utf8) orelse ""; + }; + + // The first string in this slice will be used for the exact match, + // and for the fuzzy match, all matching substrings will increase + // the rank. + const desired_styles: []const [:0]const u8 = desired: { + if (desc.style) |s| break :desired &.{s}; + + // If we don't have an explicitly desired style name, we base + // it on the bold and italic properties, this isn't ideal since + // fonts may use style names other than these, but it helps in + // some edge cases. + if (desc.bold) { + if (desc.italic) break :desired &.{ "bold italic", "bold", "italic", "oblique" }; + break :desired &.{ "bold", "upright" }; + } else if (desc.italic) { + break :desired &.{ "italic", "regular", "oblique" }; + } + break :desired &.{ "regular", "upright" }; + }; + + self.exact_style = std.ascii.eqlIgnoreCase( + style_str, + desired_styles[0], + ); + // Our "fuzzy match" score is 0 if the desired style isn't present + // in the string, otherwise we give higher priority for styles that + // have fewer characters not in the desired_styles list. + const fuzzy_type = @TypeOf(self.fuzzy_style); + self.fuzzy_style = @intCast(style_str.len); + for (desired_styles) |s| { + if (std.ascii.indexOfIgnoreCase(style_str, s) != null) { + self.fuzzy_style -|= @intCast(s.len); + } + } + self.fuzzy_style = std.math.maxInt(fuzzy_type) -| self.fuzzy_style; + + return self; + } + }; pub const DiscoverIterator = struct { alloc: Allocator, @@ -837,3 +955,85 @@ test "coretext codepoint" { // Should have other codepoints too try testing.expect(face.hasCodepoint('B', null)); } + +test "coretext sorting" { + if (options.backend != .coretext and options.backend != .coretext_freetype) + return error.SkipZigTest; + + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!// + // FIXME: Disabled for now because SF Pro is not available in CI + // The solution likely involves directly testing that the + // `sortMatchingDescriptors` function sorts a bundled test + // font correctly, instead of relying on the system fonts. + if (true) return error.SkipZigTest; + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!// + + const testing = std.testing; + const alloc = testing.allocator; + + var ct = CoreText.init(); + defer ct.deinit(); + + // We try to get a Regular, Italic, Bold, & Bold Italic version of SF Pro, + // which should be installed on all Macs, and has many styles which makes + // it a good test, since there will be many results for each discovery. + + // Regular + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Regular", name); + } + + // Regular Italic + // + // NOTE: This makes sure that we don't accidentally prefer "Thin Italic", + // which we previously did, because it has a shorter name. + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + .italic = true, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Regular Italic", name); + } + + // Bold + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + .bold = true, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Bold", name); + } + + // Bold Italic + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + .bold = true, + .italic = true, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Bold Italic", name); + } +} diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 639eae43c..06bba661f 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -97,7 +97,7 @@ pub const Face = struct { errdefer if (comptime harfbuzz_shaper) hb_font.destroy(); const color: ?ColorState = if (traits.color_glyphs) - try ColorState.init(ct_font) + try .init(ct_font) else null; errdefer if (color) |v| v.deinit(); diff --git a/src/font/face/freetype_convert.zig b/src/font/face/freetype_convert.zig index 6df350bfa..3a7cf8c98 100644 --- a/src/font/face/freetype_convert.zig +++ b/src/font/face/freetype_convert.zig @@ -30,7 +30,7 @@ fn genMap() Map { // Initialize to no converter var i: usize = 0; while (i < freetype.c.FT_PIXEL_MODE_MAX) : (i += 1) { - result[i] = AtlasArray.initFill(null); + result[i] = .initFill(null); } // Map our converters diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index f2ac5b85d..8e2c45c69 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -191,7 +191,7 @@ pub const Shaper = struct { // Create the CF release thread. var cf_release_thread = try alloc.create(CFReleaseThread); errdefer alloc.destroy(cf_release_thread); - cf_release_thread.* = try CFReleaseThread.init(alloc); + cf_release_thread.* = try .init(alloc); errdefer cf_release_thread.deinit(); // Start the CF release thread. @@ -1768,7 +1768,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { c.load_options = .{ .library = lib }; // Setup group - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12 } }, @@ -1776,7 +1776,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { if (font.options.backend != .coretext) { // Coretext doesn't support Noto's format - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmoji, .{ .size = .{ .points = 12 } }, @@ -1795,7 +1795,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { errdefer face.deinit(); _ = try c.add(alloc, .regular, .{ .deferred = face }); } - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmojiText, .{ .size = .{ .points = 12 } }, @@ -1803,7 +1803,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { const grid_ptr = try alloc.create(SharedGrid); errdefer alloc.destroy(grid_ptr); - grid_ptr.* = try SharedGrid.init(alloc, .{ .collection = c }); + grid_ptr.* = try .init(alloc, .{ .collection = c }); errdefer grid_ptr.*.deinit(alloc); var shaper = try Shaper.init(alloc, .{}); diff --git a/src/font/shaper/feature.zig b/src/font/shaper/feature.zig index 8e70d51da..66d0cb1f7 100644 --- a/src/font/shaper/feature.zig +++ b/src/font/shaper/feature.zig @@ -21,7 +21,7 @@ pub const Feature = struct { pub fn fromString(str: []const u8) ?Feature { var fbs = std.io.fixedBufferStream(str); const reader = fbs.reader(); - return Feature.fromReader(reader); + return .fromReader(reader); } /// Parse a single font feature setting from a std.io.Reader, with a version @@ -35,190 +35,156 @@ pub const Feature = struct { /// /// Ref: https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string pub fn fromReader(reader: anytype) ?Feature { - var tag: [4]u8 = undefined; + var tag_buf: [4]u8 = undefined; + var tag: []u8 = tag_buf[0..0]; var value: ?u32 = null; - // TODO: when we move to Zig 0.14 this can be replaced with a - // labeled switch continue pattern rather than this loop. - var state: union(enum) { + state: switch ((enum { /// Initial state. - start: void, - /// Parsing the tag, data is index. - tag: u2, + start, + /// Parsing the tag. + tag, /// In the space between the tag and the value. - space: void, + space, /// Parsing an integer parameter directly in to `value`. - int: void, + int, /// Parsing a boolean keyword parameter ("on"/"off"). - bool: void, + bool, /// Encountered an unrecoverable syntax error, advancing to boundary. - err: void, - /// Done parsing feature. - done: void, - } = .start; - while (true) { - // If we hit the end of the stream we just pretend it's a comma. - const byte = reader.readByte() catch ','; - switch (state) { - // If we're done then we skip whitespace until we see a ','. - .done => switch (byte) { - ' ', '\t' => continue, - ',' => break, - // If we see something other than whitespace or a ',' - // then this is an error since the intent is unclear. - else => { - state = .err; - continue; - }, + err, + /// Done parsing feature, skip whitespace until end. + done, + }).start) { + // If we're done then we skip whitespace until we see a ','. + .done => while (true) switch (reader.readByte() catch ',') { + ' ', '\t' => continue, + ',' => break, + // If we see something other than whitespace or a ',' + // then this is an error since the intent is unclear. + else => continue :state .err, + }, + + // If we're fast-forwarding from an error we just wanna + // stop at the first boundary and ignore all other bytes. + .err => { + reader.skipUntilDelimiterOrEof(',') catch {}; + return null; + }, + + .start => while (true) switch (reader.readByte() catch ',') { + // Ignore leading whitespace. + ' ', '\t' => continue, + // Empty feature string. + ',' => return null, + // '+' prefix to explicitly enable feature. + '+' => { + value = 1; + continue :state .tag; }, + // '-' prefix to explicitly disable feature. + '-' => { + value = 0; + continue :state .tag; + }, + // Quote mark introducing a tag. + '"', '\'' => { + continue :state .tag; + }, + // First letter of tag. + else => |byte| { + tag.len = 1; + tag[0] = byte; + continue :state .tag; + }, + }, - // If we're fast-forwarding from an error we just wanna - // stop at the first boundary and ignore all other bytes. - .err => if (byte == ',') return null, + .tag => while (true) switch (reader.readByte() catch ',') { + // If the tag is interrupted by a comma it's invalid. + ',' => return null, + // Ignore quote marks. This does technically ignore cases like + // "'k'e'r'n' = 0", but it's unambiguous so if someone really + // wants to do that in their config then... sure why not. + '"', '\'' => continue, + // In all other cases we add the byte to our tag. + else => |byte| { + tag.len += 1; + tag[tag.len - 1] = byte; + if (tag.len == 4) continue :state .space; + }, + }, - .start => switch (byte) { - // Ignore leading whitespace. - ' ', '\t' => continue, - // Empty feature string. - ',' => return null, - // '+' prefix to explicitly enable feature. - '+' => { - value = 1; - state = .{ .tag = 0 }; - continue; - }, - // '-' prefix to explicitly disable feature. - '-' => { + .space => while (true) switch (reader.readByte() catch ',') { + ' ', '\t' => continue, + // Ignore quote marks since we might have a + // closing quote from the tag still ahead. + '"', '\'' => continue, + // Allow an '=' (which we can safely ignore) + // only if we don't already have a value due + // to a '+' or '-' prefix. + '=' => if (value != null) continue :state .err, + ',' => { + // Specifying only a tag turns a feature on. + if (value == null) value = 1; + break; + }, + '0'...'9' => |byte| { + // If we already have value because of a + // '+' or '-' prefix then this is an error. + if (value != null) continue :state .err; + value = byte - '0'; + continue :state .int; + }, + 'o', 'O' => { + // If we already have value because of a + // '+' or '-' prefix then this is an error. + if (value != null) continue :state .err; + continue :state .bool; + }, + else => continue :state .err, + }, + + .int => while (true) switch (reader.readByte() catch ',') { + ',' => break, + '0'...'9' => |byte| { + // If our value gets too big while + // parsing we consider it an error. + value = std.math.mul(u32, value.?, 10) catch { + continue :state .err; + }; + value.? += byte - '0'; + }, + else => continue :state .err, + }, + + .bool => while (true) switch (reader.readByte() catch ',') { + ',' => return null, + 'n', 'N' => { + // "ofn" + if (value != null) { + assert(value == 0); + continue :state .err; + } + value = 1; + continue :state .done; + }, + 'f', 'F' => { + // To make sure we consume two 'f's. + if (value == null) { value = 0; - state = .{ .tag = 0 }; - continue; - }, - // Quote mark introducing a tag. - '"', '\'' => { - state = .{ .tag = 0 }; - continue; - }, - // First letter of tag. - else => { - tag[0] = byte; - state = .{ .tag = 1 }; - continue; - }, + } else { + assert(value == 0); + continue :state .done; + } }, - - .tag => |*i| switch (byte) { - // If the tag is interrupted by a comma it's invalid. - ',' => return null, - // Ignore quote marks. - '"', '\'' => continue, - // A prefix of '+' or '-' - // In all other cases we add the byte to our tag. - else => { - tag[i.*] = byte; - if (i.* == 3) { - state = .space; - continue; - } - i.* += 1; - }, - }, - - .space => switch (byte) { - ' ', '\t' => continue, - // Ignore quote marks since we might have a - // closing quote from the tag still ahead. - '"', '\'' => continue, - // Allow an '=' (which we can safely ignore) - // only if we don't already have a value due - // to a '+' or '-' prefix. - '=' => if (value != null) { - state = .err; - continue; - }, - ',' => { - // Specifying only a tag turns a feature on. - if (value == null) value = 1; - break; - }, - '0'...'9' => { - // If we already have value because of a - // '+' or '-' prefix then this is an error. - if (value != null) { - state = .err; - continue; - } - value = byte - '0'; - state = .int; - continue; - }, - 'o', 'O' => { - // If we already have value because of a - // '+' or '-' prefix then this is an error. - if (value != null) { - state = .err; - continue; - } - state = .bool; - continue; - }, - else => { - state = .err; - continue; - }, - }, - - .int => switch (byte) { - ',' => break, - '0'...'9' => { - // If our value gets too big while - // parsing we consider it an error. - value = std.math.mul(u32, value.?, 10) catch { - state = .err; - continue; - }; - value.? += byte - '0'; - }, - else => { - state = .err; - continue; - }, - }, - - .bool => switch (byte) { - ',' => return null, - 'n', 'N' => { - // "ofn" - if (value != null) { - assert(value == 0); - state = .err; - continue; - } - value = 1; - state = .done; - continue; - }, - 'f', 'F' => { - // To make sure we consume two 'f's. - if (value == null) { - value = 0; - } else { - assert(value == 0); - state = .done; - continue; - } - }, - else => { - state = .err; - continue; - }, - }, - } + else => continue :state .err, + }, } assert(value != null); + assert(tag.len == 4); return .{ - .tag = tag, + .tag = tag_buf, .value = value.?, }; } diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index eb8130f79..361cbbe93 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1227,7 +1227,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { c.load_options = .{ .library = lib }; // Setup group - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12 } }, @@ -1235,7 +1235,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { if (comptime !font.options.backend.hasCoretext()) { // Coretext doesn't support Noto's format - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmoji, .{ .size = .{ .points = 12 } }, @@ -1254,7 +1254,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { errdefer face.deinit(); _ = try c.add(alloc, .regular, .{ .deferred = face }); } - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmojiText, .{ .size = .{ .points = 12 } }, @@ -1262,7 +1262,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { const grid_ptr = try alloc.create(SharedGrid); errdefer alloc.destroy(grid_ptr); - grid_ptr.* = try SharedGrid.init(alloc, .{ .collection = c }); + grid_ptr.* = try .init(alloc, .{ .collection = c }); errdefer grid_ptr.*.deinit(alloc); var shaper = try Shaper.init(alloc, .{}); diff --git a/src/font/sprite/Box.zig b/src/font/sprite/Box.zig index 68acdabe5..f5140091d 100644 --- a/src/font/sprite/Box.zig +++ b/src/font/sprite/Box.zig @@ -516,40 +516,40 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void 0x257f => self.draw_lines(canvas, .{ .up = .heavy, .down = .light }), // '▀' UPPER HALF BLOCK - 0x2580 => self.draw_block(canvas, Alignment.upper, 1, half), + 0x2580 => self.draw_block(canvas, .upper, 1, half), // '▁' LOWER ONE EIGHTH BLOCK - 0x2581 => self.draw_block(canvas, Alignment.lower, 1, one_eighth), + 0x2581 => self.draw_block(canvas, .lower, 1, one_eighth), // '▂' LOWER ONE QUARTER BLOCK - 0x2582 => self.draw_block(canvas, Alignment.lower, 1, one_quarter), + 0x2582 => self.draw_block(canvas, .lower, 1, one_quarter), // '▃' LOWER THREE EIGHTHS BLOCK - 0x2583 => self.draw_block(canvas, Alignment.lower, 1, three_eighths), + 0x2583 => self.draw_block(canvas, .lower, 1, three_eighths), // '▄' LOWER HALF BLOCK - 0x2584 => self.draw_block(canvas, Alignment.lower, 1, half), + 0x2584 => self.draw_block(canvas, .lower, 1, half), // '▅' LOWER FIVE EIGHTHS BLOCK - 0x2585 => self.draw_block(canvas, Alignment.lower, 1, five_eighths), + 0x2585 => self.draw_block(canvas, .lower, 1, five_eighths), // '▆' LOWER THREE QUARTERS BLOCK - 0x2586 => self.draw_block(canvas, Alignment.lower, 1, three_quarters), + 0x2586 => self.draw_block(canvas, .lower, 1, three_quarters), // '▇' LOWER SEVEN EIGHTHS BLOCK - 0x2587 => self.draw_block(canvas, Alignment.lower, 1, seven_eighths), + 0x2587 => self.draw_block(canvas, .lower, 1, seven_eighths), // '█' FULL BLOCK 0x2588 => self.draw_full_block(canvas), // '▉' LEFT SEVEN EIGHTHS BLOCK - 0x2589 => self.draw_block(canvas, Alignment.left, seven_eighths, 1), + 0x2589 => self.draw_block(canvas, .left, seven_eighths, 1), // '▊' LEFT THREE QUARTERS BLOCK - 0x258a => self.draw_block(canvas, Alignment.left, three_quarters, 1), + 0x258a => self.draw_block(canvas, .left, three_quarters, 1), // '▋' LEFT FIVE EIGHTHS BLOCK - 0x258b => self.draw_block(canvas, Alignment.left, five_eighths, 1), + 0x258b => self.draw_block(canvas, .left, five_eighths, 1), // '▌' LEFT HALF BLOCK - 0x258c => self.draw_block(canvas, Alignment.left, half, 1), + 0x258c => self.draw_block(canvas, .left, half, 1), // '▍' LEFT THREE EIGHTHS BLOCK - 0x258d => self.draw_block(canvas, Alignment.left, three_eighths, 1), + 0x258d => self.draw_block(canvas, .left, three_eighths, 1), // '▎' LEFT ONE QUARTER BLOCK - 0x258e => self.draw_block(canvas, Alignment.left, one_quarter, 1), + 0x258e => self.draw_block(canvas, .left, one_quarter, 1), // '▏' LEFT ONE EIGHTH BLOCK - 0x258f => self.draw_block(canvas, Alignment.left, one_eighth, 1), + 0x258f => self.draw_block(canvas, .left, one_eighth, 1), // '▐' RIGHT HALF BLOCK - 0x2590 => self.draw_block(canvas, Alignment.right, half, 1), + 0x2590 => self.draw_block(canvas, .right, half, 1), // '░' 0x2591 => self.draw_light_shade(canvas), // '▒' @@ -557,9 +557,9 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void // '▓' 0x2593 => self.draw_dark_shade(canvas), // '▔' UPPER ONE EIGHTH BLOCK - 0x2594 => self.draw_block(canvas, Alignment.upper, 1, one_eighth), + 0x2594 => self.draw_block(canvas, .upper, 1, one_eighth), // '▕' RIGHT ONE EIGHTH BLOCK - 0x2595 => self.draw_block(canvas, Alignment.right, one_eighth, 1), + 0x2595 => self.draw_block(canvas, .right, one_eighth, 1), // '▖' 0x2596 => self.draw_quadrant(canvas, .{ .bl = true }), // '▗' @@ -581,6 +581,120 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void // '▟' 0x259f => self.draw_quadrant(canvas, .{ .tr = true, .bl = true, .br = true }), + // '◢' + 0x25e2 => self.draw_corner_triangle_shade(canvas, .br, .on), + // '◣' + 0x25e3 => self.draw_corner_triangle_shade(canvas, .bl, .on), + // '◤' + 0x25e4 => self.draw_corner_triangle_shade(canvas, .tl, .on), + // '◥' + 0x25e5 => self.draw_corner_triangle_shade(canvas, .tr, .on), + + // '◸' + 0x25f8 => { + const thickness_px = Thickness.light.height(self.metrics.box_thickness); + // top edge + self.rect( + canvas, + 0, + 0, + self.metrics.cell_width, + thickness_px, + ); + // left edge + self.rect( + canvas, + 0, + 0, + thickness_px, + self.metrics.cell_height -| 1, + ); + // diagonal + self.draw_cell_diagonal( + canvas, + .lower_left, + .upper_right, + ); + }, + // '◹' + 0x25f9 => { + const thickness_px = Thickness.light.height(self.metrics.box_thickness); + // top edge + self.rect( + canvas, + 0, + 0, + self.metrics.cell_width, + thickness_px, + ); + // right edge + self.rect( + canvas, + self.metrics.cell_width -| thickness_px, + 0, + self.metrics.cell_width, + self.metrics.cell_height -| 1, + ); + // diagonal + self.draw_cell_diagonal( + canvas, + .upper_left, + .lower_right, + ); + }, + // '◺' + 0x25fa => { + const thickness_px = Thickness.light.height(self.metrics.box_thickness); + // bottom edge + self.rect( + canvas, + 0, + self.metrics.cell_height -| thickness_px, + self.metrics.cell_width, + self.metrics.cell_height, + ); + // left edge + self.rect( + canvas, + 0, + 1, + thickness_px, + self.metrics.cell_height, + ); + // diagonal + self.draw_cell_diagonal( + canvas, + .upper_left, + .lower_right, + ); + }, + // '◿' + 0x25ff => { + const thickness_px = Thickness.light.height(self.metrics.box_thickness); + // bottom edge + self.rect( + canvas, + 0, + self.metrics.cell_height -| thickness_px, + self.metrics.cell_width, + self.metrics.cell_height, + ); + // right edge + self.rect( + canvas, + self.metrics.cell_width -| thickness_px, + 1, + self.metrics.cell_width, + self.metrics.cell_height, + ); + // diagonal + self.draw_cell_diagonal( + canvas, + .lower_left, + .upper_right, + ); + }, + 0x2800...0x28ff => self.draw_braille(canvas, cp), 0x1fb00...0x1fb3b => self.draw_sextant(canvas, cp), @@ -588,35 +702,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void octant_min...octant_max => self.draw_octant(canvas, cp), // '🬼' - 0x1fb3c => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb3c => try self.draw_smooth_mosaic(canvas, .from( \\... \\... \\#.. \\##. )), // '🬽' - 0x1fb3d => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb3d => try self.draw_smooth_mosaic(canvas, .from( \\... \\... \\#\. \\### )), // '🬾' - 0x1fb3e => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb3e => try self.draw_smooth_mosaic(canvas, .from( \\... \\#.. \\#\. \\##. )), // '🬿' - 0x1fb3f => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb3f => try self.draw_smooth_mosaic(canvas, .from( \\... \\#.. \\##. \\### )), // '🭀' - 0x1fb40 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb40 => try self.draw_smooth_mosaic(canvas, .from( \\#.. \\#.. \\##. @@ -624,42 +738,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭁' - 0x1fb41 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb41 => try self.draw_smooth_mosaic(canvas, .from( \\/## \\### \\### \\### )), // '🭂' - 0x1fb42 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb42 => try self.draw_smooth_mosaic(canvas, .from( \\./# \\### \\### \\### )), // '🭃' - 0x1fb43 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb43 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\.## \\### \\### )), // '🭄' - 0x1fb44 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb44 => try self.draw_smooth_mosaic(canvas, .from( \\..# \\.## \\### \\### )), // '🭅' - 0x1fb45 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb45 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\.## \\.## \\### )), // '🭆' - 0x1fb46 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb46 => try self.draw_smooth_mosaic(canvas, .from( \\... \\./# \\### @@ -667,35 +781,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭇' - 0x1fb47 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb47 => try self.draw_smooth_mosaic(canvas, .from( \\... \\... \\..# \\.## )), // '🭈' - 0x1fb48 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb48 => try self.draw_smooth_mosaic(canvas, .from( \\... \\... \\./# \\### )), // '🭉' - 0x1fb49 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb49 => try self.draw_smooth_mosaic(canvas, .from( \\... \\..# \\./# \\.## )), // '🭊' - 0x1fb4a => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4a => try self.draw_smooth_mosaic(canvas, .from( \\... \\..# \\.## \\### )), // '🭋' - 0x1fb4b => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4b => try self.draw_smooth_mosaic(canvas, .from( \\..# \\..# \\.## @@ -703,42 +817,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭌' - 0x1fb4c => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4c => try self.draw_smooth_mosaic(canvas, .from( \\##\ \\### \\### \\### )), // '🭍' - 0x1fb4d => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4d => try self.draw_smooth_mosaic(canvas, .from( \\#\. \\### \\### \\### )), // '🭎' - 0x1fb4e => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4e => try self.draw_smooth_mosaic(canvas, .from( \\##. \\##. \\### \\### )), // '🭏' - 0x1fb4f => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4f => try self.draw_smooth_mosaic(canvas, .from( \\#.. \\##. \\### \\### )), // '🭐' - 0x1fb50 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb50 => try self.draw_smooth_mosaic(canvas, .from( \\##. \\##. \\##. \\### )), // '🭑' - 0x1fb51 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb51 => try self.draw_smooth_mosaic(canvas, .from( \\... \\#\. \\### @@ -746,35 +860,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭒' - 0x1fb52 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb52 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\### \\\## )), // '🭓' - 0x1fb53 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb53 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\### \\.\# )), // '🭔' - 0x1fb54 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb54 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\.## \\.## )), // '🭕' - 0x1fb55 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb55 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\.## \\..# )), // '🭖' - 0x1fb56 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb56 => try self.draw_smooth_mosaic(canvas, .from( \\### \\.## \\.## @@ -782,35 +896,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭗' - 0x1fb57 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb57 => try self.draw_smooth_mosaic(canvas, .from( \\##. \\#.. \\... \\... )), // '🭘' - 0x1fb58 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb58 => try self.draw_smooth_mosaic(canvas, .from( \\### \\#/. \\... \\... )), // '🭙' - 0x1fb59 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb59 => try self.draw_smooth_mosaic(canvas, .from( \\##. \\#/. \\#.. \\... )), // '🭚' - 0x1fb5a => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5a => try self.draw_smooth_mosaic(canvas, .from( \\### \\##. \\#.. \\... )), // '🭛' - 0x1fb5b => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5b => try self.draw_smooth_mosaic(canvas, .from( \\##. \\##. \\#.. @@ -818,42 +932,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭜' - 0x1fb5c => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5c => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\#/. \\... )), // '🭝' - 0x1fb5d => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5d => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\### \\##/ )), // '🭞' - 0x1fb5e => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5e => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\### \\#/. )), // '🭟' - 0x1fb5f => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5f => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\##. \\##. )), // '🭠' - 0x1fb60 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb60 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\##. \\#.. )), // '🭡' - 0x1fb61 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb61 => try self.draw_smooth_mosaic(canvas, .from( \\### \\##. \\##. @@ -861,42 +975,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭢' - 0x1fb62 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb62 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\..# \\... \\... )), // '🭣' - 0x1fb63 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb63 => try self.draw_smooth_mosaic(canvas, .from( \\### \\.\# \\... \\... )), // '🭤' - 0x1fb64 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb64 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\.\# \\..# \\... )), // '🭥' - 0x1fb65 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb65 => try self.draw_smooth_mosaic(canvas, .from( \\### \\.## \\..# \\... )), // '🭦' - 0x1fb66 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb66 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\.## \\..# \\..# )), // '🭧' - 0x1fb67 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb67 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\.\# @@ -959,79 +1073,79 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void 0x1fb7b => self.draw_horizontal_one_eighth_block_n(canvas, 6), // '🮂' UPPER ONE QUARTER BLOCK - 0x1fb82 => self.draw_block(canvas, Alignment.upper, 1, one_quarter), + 0x1fb82 => self.draw_block(canvas, .upper, 1, one_quarter), // '🮃' UPPER THREE EIGHTHS BLOCK - 0x1fb83 => self.draw_block(canvas, Alignment.upper, 1, three_eighths), + 0x1fb83 => self.draw_block(canvas, .upper, 1, three_eighths), // '🮄' UPPER FIVE EIGHTHS BLOCK - 0x1fb84 => self.draw_block(canvas, Alignment.upper, 1, five_eighths), + 0x1fb84 => self.draw_block(canvas, .upper, 1, five_eighths), // '🮅' UPPER THREE QUARTERS BLOCK - 0x1fb85 => self.draw_block(canvas, Alignment.upper, 1, three_quarters), + 0x1fb85 => self.draw_block(canvas, .upper, 1, three_quarters), // '🮆' UPPER SEVEN EIGHTHS BLOCK - 0x1fb86 => self.draw_block(canvas, Alignment.upper, 1, seven_eighths), + 0x1fb86 => self.draw_block(canvas, .upper, 1, seven_eighths), // '🭼' LEFT AND LOWER ONE EIGHTH BLOCK 0x1fb7c => { - self.draw_block(canvas, Alignment.left, one_eighth, 1); - self.draw_block(canvas, Alignment.lower, 1, one_eighth); + self.draw_block(canvas, .left, one_eighth, 1); + self.draw_block(canvas, .lower, 1, one_eighth); }, // '🭽' LEFT AND UPPER ONE EIGHTH BLOCK 0x1fb7d => { - self.draw_block(canvas, Alignment.left, one_eighth, 1); - self.draw_block(canvas, Alignment.upper, 1, one_eighth); + self.draw_block(canvas, .left, one_eighth, 1); + self.draw_block(canvas, .upper, 1, one_eighth); }, // '🭾' RIGHT AND UPPER ONE EIGHTH BLOCK 0x1fb7e => { - self.draw_block(canvas, Alignment.right, one_eighth, 1); - self.draw_block(canvas, Alignment.upper, 1, one_eighth); + self.draw_block(canvas, .right, one_eighth, 1); + self.draw_block(canvas, .upper, 1, one_eighth); }, // '🭿' RIGHT AND LOWER ONE EIGHTH BLOCK 0x1fb7f => { - self.draw_block(canvas, Alignment.right, one_eighth, 1); - self.draw_block(canvas, Alignment.lower, 1, one_eighth); + self.draw_block(canvas, .right, one_eighth, 1); + self.draw_block(canvas, .lower, 1, one_eighth); }, // '🮀' UPPER AND LOWER ONE EIGHTH BLOCK 0x1fb80 => { - self.draw_block(canvas, Alignment.upper, 1, one_eighth); - self.draw_block(canvas, Alignment.lower, 1, one_eighth); + self.draw_block(canvas, .upper, 1, one_eighth); + self.draw_block(canvas, .lower, 1, one_eighth); }, // '🮁' 0x1fb81 => self.draw_horizontal_one_eighth_1358_block(canvas), // '🮇' RIGHT ONE QUARTER BLOCK - 0x1fb87 => self.draw_block(canvas, Alignment.right, one_quarter, 1), + 0x1fb87 => self.draw_block(canvas, .right, one_quarter, 1), // '🮈' RIGHT THREE EIGHTHS BLOCK - 0x1fb88 => self.draw_block(canvas, Alignment.right, three_eighths, 1), + 0x1fb88 => self.draw_block(canvas, .right, three_eighths, 1), // '🮉' RIGHT FIVE EIGHTHS BLOCK - 0x1fb89 => self.draw_block(canvas, Alignment.right, five_eighths, 1), + 0x1fb89 => self.draw_block(canvas, .right, five_eighths, 1), // '🮊' RIGHT THREE QUARTERS BLOCK - 0x1fb8a => self.draw_block(canvas, Alignment.right, three_quarters, 1), + 0x1fb8a => self.draw_block(canvas, .right, three_quarters, 1), // '🮋' RIGHT SEVEN EIGHTHS BLOCK - 0x1fb8b => self.draw_block(canvas, Alignment.right, seven_eighths, 1), + 0x1fb8b => self.draw_block(canvas, .right, seven_eighths, 1), // '🮌' - 0x1fb8c => self.draw_block_shade(canvas, Alignment.left, half, 1, .medium), + 0x1fb8c => self.draw_block_shade(canvas, .left, half, 1, .medium), // '🮍' - 0x1fb8d => self.draw_block_shade(canvas, Alignment.right, half, 1, .medium), + 0x1fb8d => self.draw_block_shade(canvas, .right, half, 1, .medium), // '🮎' - 0x1fb8e => self.draw_block_shade(canvas, Alignment.upper, 1, half, .medium), + 0x1fb8e => self.draw_block_shade(canvas, .upper, 1, half, .medium), // '🮏' - 0x1fb8f => self.draw_block_shade(canvas, Alignment.lower, 1, half, .medium), + 0x1fb8f => self.draw_block_shade(canvas, .lower, 1, half, .medium), // '🮐' 0x1fb90 => self.draw_medium_shade(canvas), // '🮑' 0x1fb91 => { self.draw_medium_shade(canvas); - self.draw_block(canvas, Alignment.upper, 1, half); + self.draw_block(canvas, .upper, 1, half); }, // '🮒' 0x1fb92 => { self.draw_medium_shade(canvas); - self.draw_block(canvas, Alignment.lower, 1, half); + self.draw_block(canvas, .lower, 1, half); }, // '🮔' 0x1fb94 => { self.draw_medium_shade(canvas); - self.draw_block(canvas, Alignment.right, half, 1); + self.draw_block(canvas, .right, half, 1); }, // '🮕' 0x1fb95 => self.draw_checkerboard_fill(canvas, 0), @@ -1117,194 +1231,194 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void }, // '🯎' - 0x1fbce => self.draw_block(canvas, Alignment.left, two_thirds, 1), + 0x1fbce => self.draw_block(canvas, .left, two_thirds, 1), // '🯏' - 0x1fbcf => self.draw_block(canvas, Alignment.left, one_third, 1), + 0x1fbcf => self.draw_block(canvas, .left, one_third, 1), // '🯐' 0x1fbd0 => self.draw_cell_diagonal( canvas, - Alignment.middle_right, - Alignment.lower_left, + .middle_right, + .lower_left, ), // '🯑' 0x1fbd1 => self.draw_cell_diagonal( canvas, - Alignment.upper_right, - Alignment.middle_left, + .upper_right, + .middle_left, ), // '🯒' 0x1fbd2 => self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.middle_right, + .upper_left, + .middle_right, ), // '🯓' 0x1fbd3 => self.draw_cell_diagonal( canvas, - Alignment.middle_left, - Alignment.lower_right, + .middle_left, + .lower_right, ), // '🯔' 0x1fbd4 => self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.lower_center, + .upper_left, + .lower_center, ), // '🯕' 0x1fbd5 => self.draw_cell_diagonal( canvas, - Alignment.upper_center, - Alignment.lower_right, + .upper_center, + .lower_right, ), // '🯖' 0x1fbd6 => self.draw_cell_diagonal( canvas, - Alignment.upper_right, - Alignment.lower_center, + .upper_right, + .lower_center, ), // '🯗' 0x1fbd7 => self.draw_cell_diagonal( canvas, - Alignment.upper_center, - Alignment.lower_left, + .upper_center, + .lower_left, ), // '🯘' 0x1fbd8 => { self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.middle_center, + .upper_left, + .middle_center, ); self.draw_cell_diagonal( canvas, - Alignment.middle_center, - Alignment.upper_right, + .middle_center, + .upper_right, ); }, // '🯙' 0x1fbd9 => { self.draw_cell_diagonal( canvas, - Alignment.upper_right, - Alignment.middle_center, + .upper_right, + .middle_center, ); self.draw_cell_diagonal( canvas, - Alignment.middle_center, - Alignment.lower_right, + .middle_center, + .lower_right, ); }, // '🯚' 0x1fbda => { self.draw_cell_diagonal( canvas, - Alignment.lower_left, - Alignment.middle_center, + .lower_left, + .middle_center, ); self.draw_cell_diagonal( canvas, - Alignment.middle_center, - Alignment.lower_right, + .middle_center, + .lower_right, ); }, // '🯛' 0x1fbdb => { self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.middle_center, + .upper_left, + .middle_center, ); self.draw_cell_diagonal( canvas, - Alignment.middle_center, - Alignment.lower_left, + .middle_center, + .lower_left, ); }, // '🯜' 0x1fbdc => { self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.lower_center, + .upper_left, + .lower_center, ); self.draw_cell_diagonal( canvas, - Alignment.lower_center, - Alignment.upper_right, + .lower_center, + .upper_right, ); }, // '🯝' 0x1fbdd => { self.draw_cell_diagonal( canvas, - Alignment.upper_right, - Alignment.middle_left, + .upper_right, + .middle_left, ); self.draw_cell_diagonal( canvas, - Alignment.middle_left, - Alignment.lower_right, + .middle_left, + .lower_right, ); }, // '🯞' 0x1fbde => { self.draw_cell_diagonal( canvas, - Alignment.lower_left, - Alignment.upper_center, + .lower_left, + .upper_center, ); self.draw_cell_diagonal( canvas, - Alignment.upper_center, - Alignment.lower_right, + .upper_center, + .lower_right, ); }, // '🯟' 0x1fbdf => { self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.middle_right, + .upper_left, + .middle_right, ); self.draw_cell_diagonal( canvas, - Alignment.middle_right, - Alignment.lower_left, + .middle_right, + .lower_left, ); }, // '🯠' - 0x1fbe0 => self.draw_circle(canvas, Alignment.top, false), + 0x1fbe0 => self.draw_circle(canvas, .top, false), // '🯡' - 0x1fbe1 => self.draw_circle(canvas, Alignment.right, false), + 0x1fbe1 => self.draw_circle(canvas, .right, false), // '🯢' - 0x1fbe2 => self.draw_circle(canvas, Alignment.bottom, false), + 0x1fbe2 => self.draw_circle(canvas, .bottom, false), // '🯣' - 0x1fbe3 => self.draw_circle(canvas, Alignment.left, false), + 0x1fbe3 => self.draw_circle(canvas, .left, false), // '🯤' - 0x1fbe4 => self.draw_block(canvas, Alignment.upper_center, 0.5, 0.5), + 0x1fbe4 => self.draw_block(canvas, .upper_center, 0.5, 0.5), // '🯥' - 0x1fbe5 => self.draw_block(canvas, Alignment.lower_center, 0.5, 0.5), + 0x1fbe5 => self.draw_block(canvas, .lower_center, 0.5, 0.5), // '🯦' - 0x1fbe6 => self.draw_block(canvas, Alignment.middle_left, 0.5, 0.5), + 0x1fbe6 => self.draw_block(canvas, .middle_left, 0.5, 0.5), // '🯧' - 0x1fbe7 => self.draw_block(canvas, Alignment.middle_right, 0.5, 0.5), + 0x1fbe7 => self.draw_block(canvas, .middle_right, 0.5, 0.5), // '🯨' - 0x1fbe8 => self.draw_circle(canvas, Alignment.top, true), + 0x1fbe8 => self.draw_circle(canvas, .top, true), // '🯩' - 0x1fbe9 => self.draw_circle(canvas, Alignment.right, true), + 0x1fbe9 => self.draw_circle(canvas, .right, true), // '🯪' - 0x1fbea => self.draw_circle(canvas, Alignment.bottom, true), + 0x1fbea => self.draw_circle(canvas, .bottom, true), // '🯫' - 0x1fbeb => self.draw_circle(canvas, Alignment.left, true), + 0x1fbeb => self.draw_circle(canvas, .left, true), // '🯬' - 0x1fbec => self.draw_circle(canvas, Alignment.top_right, true), + 0x1fbec => self.draw_circle(canvas, .top_right, true), // '🯭' - 0x1fbed => self.draw_circle(canvas, Alignment.bottom_left, true), + 0x1fbed => self.draw_circle(canvas, .bottom_left, true), // '🯮' - 0x1fbee => self.draw_circle(canvas, Alignment.bottom_right, true), + 0x1fbee => self.draw_circle(canvas, .bottom_right, true), // '🯯' - 0x1fbef => self.draw_circle(canvas, Alignment.top_left, true), + 0x1fbef => self.draw_circle(canvas, .top_left, true), // (Below:) // Branch drawing character set, used for drawing git-like @@ -2488,10 +2602,10 @@ fn draw_sextant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { if (sex.tl) self.rect(canvas, 0, 0, x_halfs[0], y_thirds[0]); if (sex.tr) self.rect(canvas, x_halfs[1], 0, self.metrics.cell_width, y_thirds[0]); - if (sex.ml) self.rect(canvas, 0, y_thirds[0], x_halfs[0], y_thirds[1]); - if (sex.mr) self.rect(canvas, x_halfs[1], y_thirds[0], self.metrics.cell_width, y_thirds[1]); - if (sex.bl) self.rect(canvas, 0, y_thirds[1], x_halfs[0], self.metrics.cell_height); - if (sex.br) self.rect(canvas, x_halfs[1], y_thirds[1], self.metrics.cell_width, self.metrics.cell_height); + if (sex.ml) self.rect(canvas, 0, y_thirds[1], x_halfs[0], y_thirds[2]); + if (sex.mr) self.rect(canvas, x_halfs[1], y_thirds[1], self.metrics.cell_width, y_thirds[2]); + if (sex.bl) self.rect(canvas, 0, y_thirds[3], x_halfs[0], self.metrics.cell_height); + if (sex.br) self.rect(canvas, x_halfs[1], y_thirds[3], self.metrics.cell_width, self.metrics.cell_height); } fn draw_octant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { @@ -2517,7 +2631,7 @@ fn draw_octant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { const octants: [octants_len]Octant = comptime octants: { @setEvalBranchQuota(10_000); - var result: [octants_len]Octant = .{Octant{}} ** octants_len; + var result: [octants_len]Octant = @splat(.{}); var i: usize = 0; const data = @embedFile("octants.txt"); @@ -2545,42 +2659,58 @@ fn draw_octant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { const oct = octants[cp - octant_min]; if (oct.@"1") self.rect(canvas, 0, 0, x_halfs[0], y_quads[0]); if (oct.@"2") self.rect(canvas, x_halfs[1], 0, self.metrics.cell_width, y_quads[0]); - if (oct.@"3") self.rect(canvas, 0, y_quads[0], x_halfs[0], y_quads[1]); - if (oct.@"4") self.rect(canvas, x_halfs[1], y_quads[0], self.metrics.cell_width, y_quads[1]); - if (oct.@"5") self.rect(canvas, 0, y_quads[1], x_halfs[0], y_quads[2]); - if (oct.@"6") self.rect(canvas, x_halfs[1], y_quads[1], self.metrics.cell_width, y_quads[2]); - if (oct.@"7") self.rect(canvas, 0, y_quads[2], x_halfs[0], self.metrics.cell_height); - if (oct.@"8") self.rect(canvas, x_halfs[1], y_quads[2], self.metrics.cell_width, self.metrics.cell_height); + if (oct.@"3") self.rect(canvas, 0, y_quads[1], x_halfs[0], y_quads[2]); + if (oct.@"4") self.rect(canvas, x_halfs[1], y_quads[1], self.metrics.cell_width, y_quads[2]); + if (oct.@"5") self.rect(canvas, 0, y_quads[3], x_halfs[0], y_quads[4]); + if (oct.@"6") self.rect(canvas, x_halfs[1], y_quads[3], self.metrics.cell_width, y_quads[4]); + if (oct.@"7") self.rect(canvas, 0, y_quads[5], x_halfs[0], self.metrics.cell_height); + if (oct.@"8") self.rect(canvas, x_halfs[1], y_quads[5], self.metrics.cell_width, self.metrics.cell_height); } +/// xHalfs[0] should be used as the right edge of a left-aligned half. +/// xHalfs[1] should be used as the left edge of a right-aligned half. fn xHalfs(self: Box) [2]u32 { + const float_width: f64 = @floatFromInt(self.metrics.cell_width); + const half_width: u32 = @intFromFloat(@round(0.5 * float_width)); + return .{ half_width, self.metrics.cell_width - half_width }; +} + +/// Use these values as such: +/// yThirds[0] bottom edge of the first third. +/// yThirds[1] top edge of the second third. +/// yThirds[2] bottom edge of the second third. +/// yThirds[3] top edge of the final third. +fn yThirds(self: Box) [4]u32 { + const float_height: f64 = @floatFromInt(self.metrics.cell_height); + const one_third_height: u32 = @intFromFloat(@round(one_third * float_height)); + const two_thirds_height: u32 = @intFromFloat(@round(two_thirds * float_height)); return .{ - @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2))), - @as(u32, @intFromFloat(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2)), + one_third_height, + self.metrics.cell_height - two_thirds_height, + two_thirds_height, + self.metrics.cell_height - one_third_height, }; } -fn yThirds(self: Box) [2]u32 { - return switch (@mod(self.metrics.cell_height, 3)) { - 0 => .{ self.metrics.cell_height / 3, 2 * self.metrics.cell_height / 3 }, - 1 => .{ self.metrics.cell_height / 3, 2 * self.metrics.cell_height / 3 + 1 }, - 2 => .{ self.metrics.cell_height / 3 + 1, 2 * self.metrics.cell_height / 3 }, - else => unreachable, - }; -} - -// assume octants might be striped across multiple rows of cells. to maximize -// distance between excess pixellines, we want (1) an arbitrary region (there -// will be a pattern of 1'-3-1'-3-1'-3 no matter what), (2) discontiguous -// regions (0 and 2 or 1 and 3), and (3) an arbitrary three regions (there will -// be a pattern of 3-1-3-1-3-1 no matter what). -fn yQuads(self: Box) [3]u32 { - return switch (@mod(self.metrics.cell_height, 4)) { - 0 => .{ self.metrics.cell_height / 4, 2 * self.metrics.cell_height / 4, 3 * self.metrics.cell_height / 4 }, - 1 => .{ self.metrics.cell_height / 4, 2 * self.metrics.cell_height / 4 + 1, 3 * self.metrics.cell_height / 4 }, - 2 => .{ self.metrics.cell_height / 4 + 1, 2 * self.metrics.cell_height / 4, 3 * self.metrics.cell_height / 4 + 1 }, - 3 => .{ self.metrics.cell_height / 4 + 1, 2 * self.metrics.cell_height / 4 + 1, 3 * self.metrics.cell_height / 4 }, - else => unreachable, +/// Use these values as such: +/// yQuads[0] bottom edge of first quarter. +/// yQuads[1] top edge of second quarter. +/// yQuads[2] bottom edge of second quarter. +/// yQuads[3] top edge of third quarter. +/// yQuads[4] bottom edge of third quarter +/// yQuads[5] top edge of fourth quarter. +fn yQuads(self: Box) [6]u32 { + const float_height: f64 = @floatFromInt(self.metrics.cell_height); + const quarter_height: u32 = @intFromFloat(@round(0.25 * float_height)); + const half_height: u32 = @intFromFloat(@round(0.50 * float_height)); + const three_quarters_height: u32 = @intFromFloat(@round(0.75 * float_height)); + return .{ + quarter_height, + self.metrics.cell_height - three_quarters_height, + half_height, + self.metrics.cell_height - half_height, + three_quarters_height, + self.metrics.cell_height - quarter_height, }; } @@ -2591,8 +2721,12 @@ fn draw_smooth_mosaic( ) !void { const y_thirds = self.yThirds(); const top: f64 = 0.0; - const upper: f64 = @floatFromInt(y_thirds[0]); - const lower: f64 = @floatFromInt(y_thirds[1]); + // We average the edge positions for the y_thirds boundaries here + // rather than having to deal with varying alignments depending on + // the surrounding pieces. The most this will be off by is half of + // a pixel, so hopefully it's not noticeable. + const upper: f64 = 0.5 * (@as(f64, @floatFromInt(y_thirds[0])) + @as(f64, @floatFromInt(y_thirds[1]))); + const lower: f64 = 0.5 * (@as(f64, @floatFromInt(y_thirds[2])) + @as(f64, @floatFromInt(y_thirds[3]))); const bottom: f64 = @floatFromInt(self.metrics.cell_height); const left: f64 = 0.0; const center: f64 = @round(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2); @@ -3177,6 +3311,15 @@ fn testRenderAll(self: Box, alloc: Allocator, atlas: *font.Atlas) !void { else => {}, } } + + // Geometric Shapes: filled and outlined corners + for ([_]u21{ '◢', '◣', '◤', '◥', '◸', '◹', '◺', '◿' }) |char| { + _ = try self.renderGlyph( + alloc, + atlas, + char, + ); + } } test "render all sprites" { diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index f15423ada..af0c0af6a 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -190,6 +190,11 @@ const Kind = enum { // ▀ ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ ▉ ▊ ▋ ▌ ▍ ▎ ▏ ▐ ░ ▒ ▓ ▔ ▕ ▖ ▗ ▘ ▙ ▚ ▛ ▜ ▝ ▞ ▟ 0x2580...0x259F, + // "Geometric Shapes" block + 0x25e2...0x25e5, // ◢◣◤◥ + 0x25f8...0x25fa, // ◸◹◺ + 0x25ff, // ◿ + // "Braille" block 0x2800...0x28FF, diff --git a/src/font/sprite/canvas.zig b/src/font/sprite/canvas.zig index ed00aef12..a5ca7b290 100644 --- a/src/font/sprite/canvas.zig +++ b/src/font/sprite/canvas.zig @@ -150,7 +150,7 @@ pub const Canvas = struct { /// Acquires a z2d drawing context, caller MUST deinit context. pub fn getContext(self: *Canvas) z2d.Context { - return z2d.Context.init(self.alloc, &self.sfc); + return .init(self.alloc, &self.sfc); } /// Draw and fill a single pixel diff --git a/src/font/sprite/testdata/Box.ppm b/src/font/sprite/testdata/Box.ppm index 0feb3ebe4..6082475af 100644 Binary files a/src/font/sprite/testdata/Box.ppm and b/src/font/sprite/testdata/Box.ppm differ diff --git a/src/global.zig b/src/global.zig index 375c10538..d11dd775b 100644 --- a/src/global.zig +++ b/src/global.zig @@ -139,7 +139,7 @@ pub const GlobalState = struct { std.log.info("libxev default backend={s}", .{@tagName(xev.backend)}); // As early as possible, initialize our resource limits. - self.rlimits = ResourceLimits.init(); + self.rlimits = .init(); // Initialize our crash reporting. crash.init(self.alloc) catch |err| { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 6583e1462..cccf12ac4 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -5,6 +5,7 @@ const Binding = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const ziglyph = @import("ziglyph"); const key = @import("key.zig"); const KeyEvent = key.KeyEvent; @@ -62,15 +63,17 @@ pub const Parser = struct { const flags, const start_idx = try parseFlags(raw_input); const input = raw_input[start_idx..]; - // Find the first = which splits are mapping into the trigger + // Find the last = which splits are mapping into the trigger // and action, respectively. - const eql_idx = std.mem.indexOf(u8, input, "=") orelse return Error.InvalidFormat; + // We use the last = because the keybind itself could contain + // raw equal signs (for the = codepoint) + const eql_idx = std.mem.lastIndexOf(u8, input, "=") orelse return Error.InvalidFormat; // Sequence iterator goes up to the equal, action is after. We can // parse the action now. return .{ .trigger_it = .{ .input = input[0..eql_idx] }, - .action = try Action.parse(input[eql_idx + 1 ..]), + .action = try .parse(input[eql_idx + 1 ..]), .flags = flags, }; } @@ -99,9 +102,12 @@ pub const Parser = struct { if (flags.performable) return Error.InvalidFormat; flags.performable = true; } else { - // If we don't recognize the prefix then we're done. - // There are trigger-specific prefixes like "physical:" so - // this lets us fall into that. + // If we don't recognize the prefix then we're done. We + // let any unknown prefix fallthrough to trigger-specific + // parsing in case there are trigger-specific prefixes + // (none currently but historically there was `physical:` + // at one point). Breaking here lets us always implement new + // prefixes. break; } @@ -154,7 +160,7 @@ const SequenceIterator = struct { const rem = self.input[self.i..]; const idx = std.mem.indexOf(u8, rem, ">") orelse rem.len; defer self.i += idx + 1; - return try Trigger.parse(rem[0..idx]); + return try .parse(rem[0..idx]); } /// Returns true if there are no more triggers to parse. @@ -202,14 +208,12 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool { const lhs_key: c_int = blk: { switch (lhs.trigger.key) { - .translated => break :blk @intFromEnum(lhs.trigger.key.translated), .physical => break :blk @intFromEnum(lhs.trigger.key.physical), .unicode => break :blk @intCast(lhs.trigger.key.unicode), } }; const rhs_key: c_int = blk: { switch (rhs.trigger.key) { - .translated => break :blk @intFromEnum(rhs.trigger.key.translated), .physical => break :blk @intFromEnum(rhs.trigger.key.physical), .unicode => break :blk @intCast(rhs.trigger.key.unicode), } @@ -220,107 +224,191 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool { /// The set of actions that a keybinding can take. pub const Action = union(enum) { - /// Ignore this key combination, don't send it to the child process, just - /// black hole it. + /// Ignore this key combination. + /// + /// Ghostty will not process this combination nor forward it to the child + /// process within the terminal, but it may still be processed by the OS or + /// other applications. ignore, - /// This action is used to flag that the binding should be removed from - /// the set. This should never exist in an active set and `set.put` has an - /// assertion to verify this. + /// Unbind a previously bound key binding. + /// + /// This cannot unbind bindings that were not bound by Ghostty or the user + /// (e.g. bindings set by the OS or some other application). unbind, - /// Send a CSI sequence. The value should be the CSI sequence without the - /// CSI header (`ESC [` or `\x1b[`). + /// Send a CSI sequence. + /// + /// The value should be the CSI sequence without the CSI header (`ESC [` or + /// `\x1b[`). + /// + /// For example, `csi:0m` can be sent to reset all styles of the current text. csi: []const u8, /// Send an `ESC` sequence. esc: []const u8, - /// Send the given text. Uses Zig string literal syntax. This is currently - /// not validated. If the text is invalid (i.e. contains an invalid escape - /// sequence), the error will currently only show up in logs. + /// Send the specified text. + /// + /// Uses Zig string literal syntax. This is currently not validated. + /// If the text is invalid (i.e. contains an invalid escape sequence), + /// the error will currently only show up in logs. text: []const u8, /// Send data to the pty depending on whether cursor key mode is enabled /// (`application`) or disabled (`normal`). cursor_key: CursorKey, - /// Reset the terminal. This can fix a lot of issues when a running - /// program puts the terminal into a broken state. This is equivalent to - /// when you type "reset" and press enter. + /// Reset the terminal. + /// + /// This can fix a lot of issues when a running program puts the terminal + /// into a broken state, equivalent to running the `reset` command. /// /// If you do this while in a TUI program such as vim, this may break /// the program. If you do this while in a shell, you may have to press /// enter after to get a new prompt. reset, - /// Copy and paste. + /// Copy the selected text to the clipboard. copy_to_clipboard, + + /// Paste the contents of the default clipboard. paste_from_clipboard, + + /// Paste the contents of the selection clipboard. paste_from_selection, - /// Copy the URL under the cursor to the clipboard. If there is no - /// URL under the cursor, this does nothing. + /// If there is a URL under the cursor, copy it to the default clipboard. copy_url_to_clipboard, - /// Increase/decrease the font size by a certain amount. + /// Increase the font size by the specified amount in points (pt). + /// + /// For example, `increase_font_size:1.5` will increase the font size + /// by 1.5 points. increase_font_size: f32, + + /// Decrease the font size by the specified amount in points (pt). + /// + /// For example, `decrease_font_size:1.5` will decrease the font size + /// by 1.5 points. decrease_font_size: f32, /// Reset the font size to the original configured size. reset_font_size, - /// Clear the screen. This also clears all scrollback. + /// Clear the screen and all scrollback. clear_screen, /// Select all text on the screen. select_all, - /// Scroll the screen varying amounts. + /// Scroll to the top of the screen. scroll_to_top, + + /// Scroll to the bottom of the screen. scroll_to_bottom, + + /// Scroll to the selected text. scroll_to_selection, + + /// Scroll the screen up by one page. scroll_page_up, + + /// Scroll the screen down by one page. scroll_page_down, + + /// Scroll the screen by the specified fraction of a page. + /// + /// Positive values scroll downwards, and negative values scroll upwards. + /// + /// For example, `scroll_page_fractional:0.5` would scroll the screen + /// downwards by half a page, while `scroll_page_fractional:-1.5` would + /// scroll it upwards by one and a half pages. scroll_page_fractional: f32, + + /// Scroll the screen by the specified amount of lines. + /// + /// Positive values scroll downwards, and negative values scroll upwards. + /// + /// For example, `scroll_page_lines:3` would scroll the screen downwards + /// by 3 lines, while `scroll_page_lines:-10` would scroll it upwards by 10 + /// lines. scroll_page_lines: i16, - /// Adjust the current selection in a given direction. Does nothing if no - /// selection exists. + /// Adjust the current selection in the given direction or position, + /// relative to the cursor. /// - /// Arguments: - /// - left, right, up, down, page_up, page_down, home, end, - /// beginning_of_line, end_of_line + /// WARNING: This does not create a new selection, and does nothing when + /// there currently isn't one. + /// + /// Valid arguments are: + /// + /// - `left`, `right` + /// + /// Adjust the selection one cell to the left or right respectively. + /// + /// - `up`, `down` + /// + /// Adjust the selection one line upwards or downwards respectively. + /// + /// - `page_up`, `page_down` + /// + /// Adjust the selection one page upwards or downwards respectively. + /// + /// - `home`, `end` + /// + /// Adjust the selection to the top-left or the bottom-right corner + /// of the screen respectively. + /// + /// - `beginning_of_line`, `end_of_line` + /// + /// Adjust the selection to the beginning or the end of the line + /// respectively. /// - /// Example: Extend selection to the right - /// keybind = shift+right=adjust_selection:right adjust_selection: AdjustSelection, - /// Jump the viewport forward or back by prompt. Positive number is the - /// number of prompts to jump forward, negative is backwards. + /// Jump the viewport forward or back by the given number of prompts. + /// + /// Requires shell integration. + /// + /// Positive values scroll downwards, and negative values scroll upwards. jump_to_prompt: i16, - /// Write the entire scrollback into a temporary file. The action - /// determines what to do with the filepath. Valid values are: + /// Write the entire scrollback into a temporary file with the specified + /// action. The action determines what to do with the filepath. + /// + /// Valid actions are: + /// + /// - `paste` + /// + /// Paste the file path into the terminal. + /// + /// - `open` + /// + /// Open the file in the default OS editor for text files. /// - /// - "paste": Paste the file path into the terminal. - /// - "open": Open the file in the default OS editor for text files. /// The default OS editor is determined by using `open` on macOS /// and `xdg-open` on Linux. /// write_scrollback_file: WriteScreenAction, - /// Same as write_scrollback_file but writes the full screen contents. - /// See write_scrollback_file for available values. + /// Write the contents of the screen into a temporary file with the + /// specified action. + /// + /// See `write_scrollback_file` for possible actions. write_screen_file: WriteScreenAction, - /// Same as write_scrollback_file but writes the selected text. - /// If there is no selected text this does nothing (it doesn't - /// even create an empty file). See write_scrollback_file for - /// available values. + /// Write the currently selected text into a temporary file with the + /// specified action. + /// + /// See `write_scrollback_file` for possible actions. + /// + /// Does nothing when no text is selected. write_selection_file: WriteScreenAction, - /// Open a new window. If the application isn't currently focused, + /// Open a new window. + /// + /// If the application isn't currently focused, /// this will bring it to the front. new_window, @@ -333,184 +421,275 @@ pub const Action = union(enum) { /// Go to the next tab. next_tab, - /// Go to the last tab (the one with the highest index) + /// Go to the last tab. last_tab, - /// Go to the tab with the specific number, 1-indexed. If the tab number - /// is higher than the number of tabs, this will go to the last tab. + /// Go to the tab with the specific index, starting from 1. + /// + /// If the tab number is higher than the number of tabs, + /// this will go to the last tab. goto_tab: usize, /// Moves a tab by a relative offset. - /// Adjusts the tab position based on `offset`. For example `move_tab:-1` for left, `move_tab:1` for right. - /// If the new position is out of bounds, it wraps around cyclically within the tab range. + /// + /// Positive values move the tab forwards, and negative values move it + /// backwards. If the new position is out of bounds, it is wrapped around + /// cyclically within the tab list. + /// + /// For example, `move_tab:1` moves the tab one position forwards, and if + /// it was already the last tab in the list, it wraps around and becomes + /// the first tab in the list. Likewise, `move_tab:-1` moves the tab one + /// position backwards, and if it was the first tab, then it will become + /// the last tab. move_tab: isize, /// Toggle the tab overview. - /// This only works with libadwaita version 1.4.0 or newer. + /// + /// This is only supported on Linux and when the system's libadwaita + /// version is 1.4 or newer. The current libadwaita version can be + /// found by running `ghostty +version`. toggle_tab_overview, - /// Change the title of the current focused surface via a prompt. + /// Change the title of the current focused surface via a pop-up prompt. + /// + /// This requires libadwaita 1.5 or newer on Linux. The current libadwaita + /// version can be found by running `ghostty +version`. prompt_surface_title, - /// Create a new split in the given direction. + /// Create a new split in the specified direction. /// - /// Arguments: - /// - right, down, left, up, auto (splits along the larger direction) + /// Valid arguments: + /// + /// - `right`, `down`, `left`, `up` + /// + /// Creates a new split in the corresponding direction. + /// + /// - `auto` + /// + /// Creates a new split along the larger direction. + /// For example, if the parent split is currently wider than it is tall, + /// then a left-right split would be created, and vice versa. /// - /// Example: Create split on the right - /// keybind = cmd+shift+d=new_split:right new_split: SplitDirection, - /// Focus on a split in a given direction. For example `goto_split:up`. - /// Valid values are left, right, up, down, previous and next. + /// Focus on a split either in the specified direction (`right`, `down`, + /// `left` and `up`), or in the adjacent split in the order of creation + /// (`previous` and `next`). goto_split: SplitFocusDirection, - /// zoom/unzoom the current split. + /// Zoom in or out of the current split. + /// + /// When a split is zoomed into, it will take up the entire space in + /// the current tab, hiding other splits. The tab or tab bar would also + /// reflect this by displaying an icon indicating the zoomed state. toggle_split_zoom, - /// Resize the current split in a given direction. - /// - /// Arguments: - /// - up, down, left, right - /// - the number of pixels to resize the split by - /// - /// Example: Move divider up 10 pixels - /// keybind = cmd+shift+up=resize_split:up,10 + /// Resize the current split in the specified direction and amount in + /// pixels. The two arguments should be joined with a comma (`,`), + /// like in `resize_split:up,10`. resize_split: SplitResizeParameter, - /// Equalize all splits in the current window + /// Equalize the size of all splits in the current window. equalize_splits, /// Reset the window to the default size. The "default size" is the /// size that a new window would be created with. This has no effect /// if the window is fullscreen. + /// + /// Only implemented on macOS. reset_window_size, - /// Control the terminal inspector visibility. + /// Control the visibility of the terminal inspector. /// - /// Arguments: - /// - toggle, show, hide - /// - /// Example: Toggle inspector visibility - /// keybind = cmd+i=inspector:toggle + /// Valid arguments: `toggle`, `show`, `hide`. inspector: InspectorMode, - /// Open the configuration file in the default OS editor. If your default OS - /// editor isn't configured then this will fail. Currently, any failures to - /// open the configuration will show up only in the logs. + /// Show the GTK inspector. + /// + /// Has no effect on macOS. + show_gtk_inspector, + + /// Open the configuration file in the default OS editor. + /// + /// If your default OS editor isn't configured then this will fail. + /// Currently, any failures to open the configuration will show up only in + /// the logs. open_config, - /// Reload the configuration. The exact meaning depends on the app runtime - /// in use but this usually involves re-reading the configuration file - /// and applying any changes. Note that not all changes can be applied at - /// runtime. + /// Reload the configuration. + /// + /// The exact meaning depends on the app runtime in use, but this usually + /// involves re-reading the configuration file and applying any changes + /// Note that not all changes can be applied at runtime. reload_config, /// Close the current "surface", whether that is a window, tab, split, etc. - /// This only closes ONE surface. This will trigger close confirmation as - /// configured. + /// + /// This might trigger a close confirmation popup, depending on the value + /// of the `confirm-close-surface` configuration setting. close_surface, - /// Close the current tab, regardless of how many splits there may be. - /// This will trigger close confirmation as configured. + /// Close the current tab and all splits therein. + /// + /// This might trigger a close confirmation popup, depending on the value + /// of the `confirm-close-surface` configuration setting. close_tab, - /// Close the window, regardless of how many tabs or splits there may be. - /// This will trigger close confirmation as configured. + /// Close the current window and all tabs and splits therein. + /// + /// This might trigger a close confirmation popup, depending on the value + /// of the `confirm-close-surface` configuration setting. close_window, - /// Close all windows. This will trigger close confirmation as configured. - /// This only works for macOS currently. + /// Close all windows. + /// + /// WARNING: This action has been deprecated and has no effect on either + /// Linux or macOS. Users are instead encouraged to use `all:close_window` + /// instead. close_all_windows, - /// Toggle maximized window state. This only works on Linux. + /// Maximize or unmaximize the current window. + /// + /// This has no effect on macOS as it does not have the concept of + /// maximized windows. toggle_maximize, - /// Toggle fullscreen mode of window. + /// Fullscreen or unfullscreen the current window. toggle_fullscreen, - /// Toggle window decorations on and off. This only works on Linux. + /// Toggle window decorations (titlebar, buttons, etc.) for the current window. + /// + /// Only implemented on Linux. toggle_window_decorations, - /// Toggle whether the terminal window is always on top of other - /// windows even when it is not focused. Terminal windows always start - /// as normal (not always on top) windows. + /// Toggle whether the terminal window should always float on top of other + /// windows even when unfocused. /// - /// This only works on macOS. + /// Terminal windows always start as normal (not float-on-top) windows. + /// + /// Only implemented on macOS. toggle_window_float_on_top, - /// Toggle secure input mode on or off. This is used to prevent apps - /// that monitor input from seeing what you type. This is useful for - /// entering passwords or other sensitive information. + /// Toggle secure input mode. /// - /// This applies to the entire application, not just the focused - /// terminal. You must toggle it off to disable it, or quit Ghostty. + /// This is used to prevent apps from monitoring your keyboard input + /// when entering passwords or other sensitive information. /// - /// This only works on macOS, since this is a system API on macOS. + /// This applies to the entire application, not just the focused terminal. + /// You must manually untoggle it or quit Ghostty entirely to disable it. + /// + /// Only implemented on macOS, as this uses a built-in system API. toggle_secure_input, - /// Toggle the command palette. The command palette is a UI element - /// that lets you see what actions you can perform, their associated - /// keybindings (if any), a search bar to filter the actions, and - /// the ability to then execute the action. + /// Toggle the command palette. /// - /// This only works on macOS. + /// The command palette is a popup that lets you see what actions + /// you can perform, their associated keybindings (if any), a search bar + /// to filter the actions, and the ability to then execute the action. + /// + /// This requires libadwaita 1.5 or newer on Linux. The current libadwaita + /// version can be found by running `ghostty +version`. toggle_command_palette, - /// Toggle the "quick" terminal. The quick terminal is a terminal that - /// appears on demand from a keybinding, often sliding in from a screen - /// edge such as the top. This is useful for quick access to a terminal - /// without having to open a new window or tab. + /// Toggle the quick terminal. /// - /// When the quick terminal loses focus, it disappears. The terminal state - /// is preserved between appearances, so you can always press the keybinding - /// to bring it back up. + /// The quick terminal, also known as the "Quake-style" or drop-down + /// terminal, is a terminal window that appears on demand from a keybinding, + /// often sliding in from a screen edge such as the top. This is useful for + /// quick access to a terminal without having to open a new window or tab. /// - /// To enable the quick terminal globally so that Ghostty doesn't - /// have to be focused, prefix your keybind with `global`. Example: + /// The terminal state is preserved between appearances, so showing the + /// quick terminal after it was already hidden would display the same + /// window instead of creating a new one. + /// + /// As quick terminals are often useful when other windows are currently + /// focused, they are best used with *global* keybinds. For example, one + /// can define the following key bind to toggle the quick terminal from + /// anywhere within the system by pressing `` Cmd+` ``: /// /// ```ini - /// keybind = global:cmd+grave_accent=toggle_quick_terminal + /// keybind = global:cmd+backquote=toggle_quick_terminal /// ``` /// /// The quick terminal has some limitations: /// - /// - It is a singleton; only one instance can exist at a time. - /// - It does not support tabs, but it does support splits. - /// - It will not be restored when the application is restarted - /// (for systems that support window restoration). - /// - It supports fullscreen, but fullscreen will always be a non-native - /// fullscreen (macos-non-native-fullscreen = true). This only applies - /// to the quick terminal window. This is a requirement due to how - /// the quick terminal is rendered. + /// - Only one quick terminal instance can exist at a time. + /// + /// - Unlike normal terminal windows, the quick terminal will not be + /// restored when the application is restarted on systems that support + /// window restoration like macOS. + /// + /// - On Linux, the quick terminal is only supported on Wayland and not + /// X11, and only on Wayland compositors that support the `wlr-layer-shell-v1` + /// protocol. In practice, this means that only GNOME users would not be + /// able to use this feature. + /// + /// - On Linux, slide-in animations are only supported on KDE, and when + /// the "Sliding Popups" KWin plugin is enabled. + /// + /// If you do not have this plugin enabled, open System Settings > Apps + /// & Windows > Window Management > Desktop Effects, and enable the + /// plugin in the plugin list. Ghostty would then need to be restarted + /// fully for this to take effect. + /// + /// - Quick terminal tabs are only supported on Linux and not on macOS. + /// This is because tabs on macOS require a title bar. + /// + /// - On macOS, a fullscreened quick terminal will always be in non-native + /// fullscreen mode. This is a requirement due to how the quick terminal + /// is rendered. /// /// See the various configurations for the quick terminal in the /// configuration file to customize its behavior. - /// - /// Supported on macOS and some desktop environments on Linux, namely - /// those that support the `wlr-layer-shell` Wayland protocol - /// (i.e. most desktop environments and window managers except GNOME). - /// - /// Slide-in animations on Linux are only supported on KDE when the - /// "Sliding Popups" KWin plugin is enabled. If you do not have this - /// plugin enabled, open System Settings > Apps & Windows > Window - /// Management > Desktop Effects, and enable the plugin in the plugin list. - /// Ghostty would then need to be restarted for this to take effect. toggle_quick_terminal, - /// Show/hide all windows. If all windows become shown, we also ensure + /// Show or hide all windows. If all windows become shown, we also ensure /// Ghostty becomes focused. When hiding all windows, focus is yielded /// to the next application as determined by the OS. /// /// Note: When the focused surface is fullscreen, this method does nothing. /// - /// This currently only works on macOS. + /// Only implemented on macOS. toggle_visibility, - /// Quit ghostty. + /// Check for updates. + /// + /// Only implemented on macOS. + check_for_updates, + + /// Undo the last undoable action for the focused surface or terminal, + /// if possible. This can undo actions such as closing tabs or + /// windows. + /// + /// Not every action in Ghostty can be undone or redone. The list + /// of actions support undo/redo is currently limited to: + /// + /// - New window, close window + /// - New tab, close tab + /// - New split, close split + /// + /// All actions are only undoable/redoable for a limited time. + /// For example, restoring a closed split can only be done for + /// some number of seconds since the split was closed. The exact + /// amount is configured with `TODO`. + /// + /// The undo/redo actions being limited ensures that there is + /// bounded memory usage over time, closed surfaces don't continue running + /// in the background indefinitely, and the keybinds become available + /// for terminal applications to use. + /// + /// Only implemented on macOS. + undo, + + /// Redo the last undoable action for the focused surface or terminal, + /// if possible. See "undo" for more details on what can and cannot + /// be undone or redone. + redo, + + /// Quit Ghostty. quit, - /// Crash ghostty in the desired thread for the focused surface. + /// Crash Ghostty in the desired thread for the focused surface. /// /// WARNING: This is a hard crash (panic) and data can be lost. /// @@ -520,9 +699,17 @@ pub const Action = union(enum) { /// /// The value determines the crash location: /// - /// - "main" - crash on the main (GUI) thread. - /// - "io" - crash on the IO thread for the focused surface. - /// - "render" - crash on the render thread for the focused surface. + /// - `main` + /// + /// Crash on the main (GUI) thread. + /// + /// - `io` + /// + /// Crash on the IO thread for the focused surface. + /// + /// - `render` + /// + /// Crash on the render thread for the focused surface. /// crash: CrashThread, @@ -789,10 +976,14 @@ pub const Action = union(enum) { .quit, .toggle_quick_terminal, .toggle_visibility, + .check_for_updates, + .show_gtk_inspector, => .app, // These are app but can be special-cased in a surface context. .new_window, + .undo, + .redo, => .app, // Obviously surface actions. @@ -1065,18 +1256,12 @@ pub const Action = union(enum) { /// This must be kept in sync with include/ghostty.h ghostty_input_trigger_s pub const Trigger = struct { /// The key that has to be pressed for a binding to take action. - key: Trigger.Key = .{ .translated = .invalid }, + key: Trigger.Key = .{ .physical = .unidentified }, /// The key modifiers that must be active for this to match. mods: key.Mods = .{}, pub const Key = union(C.Tag) { - /// key is the translated version of a key. This is the key that - /// a logical keyboard layout at the OS level would translate the - /// physical key to. For example if you use a US hardware keyboard - /// but have a Dvorak layout, the key would be the Dvorak key. - translated: key.Key, - /// key is the "physical" version. This is the same as mapped for /// standard US keyboard layouts. For non-US keyboard layouts, this /// is used to bind to a physical key location rather than a translated @@ -1091,18 +1276,16 @@ pub const Trigger = struct { /// The extern struct used for triggers in the C API. pub const C = extern struct { - tag: Tag = .translated, - key: C.Key = .{ .translated = .invalid }, + tag: Tag = .physical, + key: C.Key = .{ .physical = .unidentified }, mods: key.Mods = .{}, pub const Tag = enum(c_int) { - translated, physical, unicode, }; pub const Key = extern union { - translated: key.Key, physical: key.Key, unicode: u32, }; @@ -1115,10 +1298,11 @@ pub const Trigger = struct { pub fn parse(input: []const u8) !Trigger { if (input.len == 0) return Error.InvalidFormat; var result: Trigger = .{}; - var iter = std.mem.tokenizeScalar(u8, input, '+'); - loop: while (iter.next()) |part| { - // All parts must be non-empty - if (part.len == 0) return Error.InvalidFormat; + var rem: []const u8 = input; + loop: while (rem.len > 0) { + const idx = std.mem.indexOfScalar(u8, rem, '+') orelse rem.len; + const part = rem[0..idx]; + rem = if (idx >= rem.len) "" else rem[idx + 1 ..]; // Check if its a modifier const modsInfo = @typeInfo(key.Mods).@"struct"; @@ -1150,24 +1334,24 @@ pub const Trigger = struct { } } - // If the key starts with "physical" then this is an physical key. - const physical_prefix = "physical:"; - const physical = std.mem.startsWith(u8, part, physical_prefix); - const key_part = if (physical) part[physical_prefix.len..] else part; + // Anything after this point is a key and we only support + // single keys. + if (!result.isKeyUnset()) return Error.InvalidFormat; + + // If the part is empty it means that it is actually + // a literal `+`, which we treat as a Unicode character. + if (part.len == 0) { + result.key = .{ .unicode = '+' }; + continue :loop; + } // Check if its a key const keysInfo = @typeInfo(key.Key).@"enum"; inline for (keysInfo.fields) |field| { - if (!std.mem.eql(u8, field.name, "invalid")) { - if (std.mem.eql(u8, key_part, field.name)) { - // Repeat not allowed - if (!result.isKeyUnset()) return Error.InvalidFormat; - + if (!std.mem.eql(u8, field.name, "unidentified")) { + if (std.mem.eql(u8, part, field.name)) { const keyval = @field(key.Key, field.name); - result.key = if (physical) - .{ .physical = keyval } - else - .{ .translated = keyval }; + result.key = .{ .physical = keyval }; continue :loop; } } @@ -1177,35 +1361,163 @@ pub const Trigger = struct { // character then we can use that as a key. if (result.isKeyUnset()) unicode: { // Invalid UTF8 drops to invalid format - const view = std.unicode.Utf8View.init(key_part) catch break :unicode; + const view = std.unicode.Utf8View.init(part) catch break :unicode; var it = view.iterator(); // No codepoints or multiple codepoints drops to invalid format const cp = it.nextCodepoint() orelse break :unicode; if (it.nextCodepoint() != null) break :unicode; - // If this is ASCII and we have a translated key, set that. - if (std.math.cast(u8, cp)) |ascii| { - if (key.Key.fromASCII(ascii)) |k| { - result.key = .{ .translated = k }; - continue :loop; - } - } - result.key = .{ .unicode = cp }; continue :loop; } + // Look for a matching w3c name next. + if (key.Key.fromW3C(part)) |w3c_key| { + result.key = .{ .physical = w3c_key }; + continue :loop; + } + + // If we're still unset then we look for backwards compatible + // keys with Ghostty 1.1.x. We do this last so its least likely + // to impact performance for modern users. + if (backwards_compatible_keys.get(part)) |old_key| { + result.key = old_key; + continue :loop; + } + // We didn't recognize this value return Error.InvalidFormat; } return result; } + + /// The values that are backwards compatible with Ghostty 1.1.x. + /// Ghostty 1.2+ doesn't support these anymore since we moved to + /// W3C key codes. + const backwards_compatible_keys = std.StaticStringMap(Key).initComptime(.{ + .{ "zero", Key{ .unicode = '0' } }, + .{ "one", Key{ .unicode = '1' } }, + .{ "two", Key{ .unicode = '2' } }, + .{ "three", Key{ .unicode = '3' } }, + .{ "four", Key{ .unicode = '4' } }, + .{ "five", Key{ .unicode = '5' } }, + .{ "six", Key{ .unicode = '6' } }, + .{ "seven", Key{ .unicode = '7' } }, + .{ "eight", Key{ .unicode = '8' } }, + .{ "nine", Key{ .unicode = '9' } }, + .{ "plus", Key{ .unicode = '+' } }, + .{ "apostrophe", Key{ .unicode = '\'' } }, + .{ "grave_accent", Key{ .physical = .backquote } }, + .{ "left_bracket", Key{ .physical = .bracket_left } }, + .{ "right_bracket", Key{ .physical = .bracket_right } }, + .{ "up", Key{ .physical = .arrow_up } }, + .{ "down", Key{ .physical = .arrow_down } }, + .{ "left", Key{ .physical = .arrow_left } }, + .{ "right", Key{ .physical = .arrow_right } }, + .{ "kp_0", Key{ .physical = .numpad_0 } }, + .{ "kp_1", Key{ .physical = .numpad_1 } }, + .{ "kp_2", Key{ .physical = .numpad_2 } }, + .{ "kp_3", Key{ .physical = .numpad_3 } }, + .{ "kp_4", Key{ .physical = .numpad_4 } }, + .{ "kp_5", Key{ .physical = .numpad_5 } }, + .{ "kp_6", Key{ .physical = .numpad_6 } }, + .{ "kp_7", Key{ .physical = .numpad_7 } }, + .{ "kp_8", Key{ .physical = .numpad_8 } }, + .{ "kp_9", Key{ .physical = .numpad_9 } }, + .{ "kp_add", Key{ .physical = .numpad_add } }, + .{ "kp_subtract", Key{ .physical = .numpad_subtract } }, + .{ "kp_multiply", Key{ .physical = .numpad_multiply } }, + .{ "kp_divide", Key{ .physical = .numpad_divide } }, + .{ "kp_decimal", Key{ .physical = .numpad_decimal } }, + .{ "kp_enter", Key{ .physical = .numpad_enter } }, + .{ "kp_equal", Key{ .physical = .numpad_equal } }, + .{ "kp_separator", Key{ .physical = .numpad_separator } }, + .{ "kp_left", Key{ .physical = .numpad_left } }, + .{ "kp_right", Key{ .physical = .numpad_right } }, + .{ "kp_up", Key{ .physical = .numpad_up } }, + .{ "kp_down", Key{ .physical = .numpad_down } }, + .{ "kp_page_up", Key{ .physical = .numpad_page_up } }, + .{ "kp_page_down", Key{ .physical = .numpad_page_down } }, + .{ "kp_home", Key{ .physical = .numpad_home } }, + .{ "kp_end", Key{ .physical = .numpad_end } }, + .{ "kp_insert", Key{ .physical = .numpad_insert } }, + .{ "kp_delete", Key{ .physical = .numpad_delete } }, + .{ "kp_begin", Key{ .physical = .numpad_begin } }, + .{ "left_shift", Key{ .physical = .shift_left } }, + .{ "right_shift", Key{ .physical = .shift_right } }, + .{ "left_control", Key{ .physical = .control_left } }, + .{ "right_control", Key{ .physical = .control_right } }, + .{ "left_alt", Key{ .physical = .alt_left } }, + .{ "right_alt", Key{ .physical = .alt_right } }, + .{ "left_super", Key{ .physical = .meta_left } }, + .{ "right_super", Key{ .physical = .meta_right } }, + + // Physical variants. This is a blunt approach to this but its + // glue for backwards compatibility so I'm not too worried about + // making this super nice. + .{ "physical:zero", Key{ .physical = .digit_0 } }, + .{ "physical:one", Key{ .physical = .digit_1 } }, + .{ "physical:two", Key{ .physical = .digit_2 } }, + .{ "physical:three", Key{ .physical = .digit_3 } }, + .{ "physical:four", Key{ .physical = .digit_4 } }, + .{ "physical:five", Key{ .physical = .digit_5 } }, + .{ "physical:six", Key{ .physical = .digit_6 } }, + .{ "physical:seven", Key{ .physical = .digit_7 } }, + .{ "physical:eight", Key{ .physical = .digit_8 } }, + .{ "physical:nine", Key{ .physical = .digit_9 } }, + .{ "physical:apostrophe", Key{ .physical = .quote } }, + .{ "physical:grave_accent", Key{ .physical = .backquote } }, + .{ "physical:left_bracket", Key{ .physical = .bracket_left } }, + .{ "physical:right_bracket", Key{ .physical = .bracket_right } }, + .{ "physical:up", Key{ .physical = .arrow_up } }, + .{ "physical:down", Key{ .physical = .arrow_down } }, + .{ "physical:left", Key{ .physical = .arrow_left } }, + .{ "physical:right", Key{ .physical = .arrow_right } }, + .{ "physical:kp_0", Key{ .physical = .numpad_0 } }, + .{ "physical:kp_1", Key{ .physical = .numpad_1 } }, + .{ "physical:kp_2", Key{ .physical = .numpad_2 } }, + .{ "physical:kp_3", Key{ .physical = .numpad_3 } }, + .{ "physical:kp_4", Key{ .physical = .numpad_4 } }, + .{ "physical:kp_5", Key{ .physical = .numpad_5 } }, + .{ "physical:kp_6", Key{ .physical = .numpad_6 } }, + .{ "physical:kp_7", Key{ .physical = .numpad_7 } }, + .{ "physical:kp_8", Key{ .physical = .numpad_8 } }, + .{ "physical:kp_9", Key{ .physical = .numpad_9 } }, + .{ "physical:kp_add", Key{ .physical = .numpad_add } }, + .{ "physical:kp_subtract", Key{ .physical = .numpad_subtract } }, + .{ "physical:kp_multiply", Key{ .physical = .numpad_multiply } }, + .{ "physical:kp_divide", Key{ .physical = .numpad_divide } }, + .{ "physical:kp_decimal", Key{ .physical = .numpad_decimal } }, + .{ "physical:kp_enter", Key{ .physical = .numpad_enter } }, + .{ "physical:kp_equal", Key{ .physical = .numpad_equal } }, + .{ "physical:kp_separator", Key{ .physical = .numpad_separator } }, + .{ "physical:kp_left", Key{ .physical = .numpad_left } }, + .{ "physical:kp_right", Key{ .physical = .numpad_right } }, + .{ "physical:kp_up", Key{ .physical = .numpad_up } }, + .{ "physical:kp_down", Key{ .physical = .numpad_down } }, + .{ "physical:kp_page_up", Key{ .physical = .numpad_page_up } }, + .{ "physical:kp_page_down", Key{ .physical = .numpad_page_down } }, + .{ "physical:kp_home", Key{ .physical = .numpad_home } }, + .{ "physical:kp_end", Key{ .physical = .numpad_end } }, + .{ "physical:kp_insert", Key{ .physical = .numpad_insert } }, + .{ "physical:kp_delete", Key{ .physical = .numpad_delete } }, + .{ "physical:kp_begin", Key{ .physical = .numpad_begin } }, + .{ "physical:left_shift", Key{ .physical = .shift_left } }, + .{ "physical:right_shift", Key{ .physical = .shift_right } }, + .{ "physical:left_control", Key{ .physical = .control_left } }, + .{ "physical:right_control", Key{ .physical = .control_right } }, + .{ "physical:left_alt", Key{ .physical = .alt_left } }, + .{ "physical:right_alt", Key{ .physical = .alt_right } }, + .{ "physical:left_super", Key{ .physical = .meta_left } }, + .{ "physical:right_super", Key{ .physical = .meta_right } }, + }); + /// Returns true if this trigger has no key set. pub fn isKeyUnset(self: Trigger) bool { return switch (self.key) { - .translated => |v| v == .invalid, + .physical => |v| v == .unidentified, else => false, }; } @@ -1219,16 +1531,37 @@ pub const Trigger = struct { /// Hash the trigger into the given hasher. fn hashIncremental(self: Trigger, hasher: anytype) void { - std.hash.autoHash(hasher, self.key); + std.hash.autoHash(hasher, std.meta.activeTag(self.key)); + switch (self.key) { + .physical => |v| std.hash.autoHash(hasher, v), + .unicode => |cp| std.hash.autoHash( + hasher, + foldedCodepoint(cp), + ), + } std.hash.autoHash(hasher, self.mods.binding()); } + /// The codepoint we use for comparisons. Case folding can result + /// in more codepoints so we need to use a 3 element array. + fn foldedCodepoint(cp: u21) [3]u21 { + // ASCII fast path + if (ziglyph.letter.isAsciiLetter(cp)) { + return .{ ziglyph.letter.toLower(cp), 0, 0 }; + } + + // Unicode slow path. Case folding can resultin more codepoints. + // If more codepoints are produced then we return the codepoint + // as-is which isn't correct but until we have a failing test + // then I don't want to handle this. + return ziglyph.letter.toCaseFold(cp); + } + /// Convert the trigger to a C API compatible trigger. pub fn cval(self: Trigger) C { return .{ .tag = self.key, .key = switch (self.key) { - .translated => |v| .{ .translated = v }, .physical => |v| .{ .physical = v }, .unicode => |v| .{ .unicode = @intCast(v) }, }, @@ -1254,8 +1587,7 @@ pub const Trigger = struct { // Key switch (self.key) { - .translated => |k| try writer.print("{s}", .{@tagName(k)}), - .physical => |k| try writer.print("physical:{s}", .{@tagName(k)}), + .physical => |k| try writer.print("{s}", .{@tagName(k)}), .unicode => |c| try writer.print("{u}", .{c}), } } @@ -1620,13 +1952,27 @@ pub const Set = struct { pub fn getEvent(self: *const Set, event: KeyEvent) ?Entry { var trigger: Trigger = .{ .mods = event.mods.binding(), - .key = .{ .translated = event.key }, + .key = .{ .physical = event.key }, }; if (self.get(trigger)) |v| return v; - trigger.key = .{ .physical = event.physical_key }; - if (self.get(trigger)) |v| return v; + // If our UTF-8 text is exactly one codepoint, we try to match that. + if (event.utf8.len > 0) unicode: { + const view = std.unicode.Utf8View.init(event.utf8) catch break :unicode; + var it = view.iterator(); + // No codepoints or multiple codepoints drops to invalid format + const cp = it.nextCodepoint() orelse break :unicode; + if (it.nextCodepoint() != null) break :unicode; + + trigger.key = .{ .unicode = cp }; + if (self.get(trigger)) |v| return v; + } + + // Finally fallback to the full unshifted codepoint if we have one. + // Question: should we be doing this if we have UTF-8 text? I + // suspect "no" but we don't currently have any failing scenarios + // to verify this. if (event.unshifted_codepoint > 0) { trigger.key = .{ .unicode = event.unshifted_codepoint }; if (self.get(trigger)) |v| return v; @@ -1637,19 +1983,7 @@ pub const Set = struct { /// Remove a binding for a given trigger. pub fn remove(self: *Set, alloc: Allocator, t: Trigger) void { - // Remove whatever this trigger is self.removeExact(alloc, t); - - // If we have a physical we remove translated and vice versa. - const alternate: Trigger.Key = switch (t.key) { - .unicode => return, - .translated => |k| .{ .physical = k }, - .physical => |k| .{ .translated = k }, - }; - - var alt_t: Trigger = t; - alt_t.key = alternate; - self.removeExact(alloc, alt_t); } fn removeExact(self: *Set, alloc: Allocator, t: Trigger) void { @@ -1750,37 +2084,24 @@ test "parse: triggers" { // single character try testing.expectEqual( Binding{ - .trigger = .{ .key = .{ .translated = .a } }, + .trigger = .{ .key = .{ .unicode = 'a' } }, .action = .{ .ignore = {} }, }, try parseSingle("a=ignore"), ); - // unicode keys that map to translated - try testing.expectEqual(Binding{ - .trigger = .{ .key = .{ .translated = .one } }, - .action = .{ .ignore = {} }, - }, try parseSingle("1=ignore")); - try testing.expectEqual(Binding{ - .trigger = .{ - .mods = .{ .super = true }, - .key = .{ .translated = .period }, - }, - .action = .{ .ignore = {} }, - }, try parseSingle("cmd+.=ignore")); - // single modifier try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("shift+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .ctrl = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("ctrl+a=ignore")); @@ -1789,7 +2110,7 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true, .ctrl = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("shift+ctrl+a=ignore")); @@ -1798,7 +2119,7 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("a+shift=ignore")); @@ -1807,10 +2128,10 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .physical = .a }, + .key = .{ .physical = .key_a }, }, .action = .{ .ignore = {} }, - }, try parseSingle("shift+physical:a=ignore")); + }, try parseSingle("shift+key_a=ignore")); // unicode keys try testing.expectEqual(Binding{ @@ -1825,7 +2146,7 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, .flags = .{ .consumed = false }, @@ -1835,17 +2156,17 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .physical = .a }, + .key = .{ .physical = .key_a }, }, .action = .{ .ignore = {} }, .flags = .{ .consumed = false }, - }, try parseSingle("unconsumed:physical:a+shift=ignore")); + }, try parseSingle("unconsumed:key_a+shift=ignore")); // performable keys try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, .flags = .{ .performable = true }, @@ -1861,6 +2182,117 @@ test "parse: triggers" { try testing.expectError(Error.InvalidFormat, parseSingle("a+b=ignore")); } +test "parse: w3c key names" { + const testing = std.testing; + + // Exact match + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .physical = .key_a } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("KeyA=ignore"), + ); + + // Case-sensitive + try testing.expectError(Error.InvalidFormat, parseSingle("Keya=ignore")); +} + +test "parse: plus sign" { + const testing = std.testing; + + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .unicode = '+' } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("+=ignore"), + ); + + // Modifier + try testing.expectEqual( + Binding{ + .trigger = .{ + .key = .{ .unicode = '+' }, + .mods = .{ .ctrl = true }, + }, + .action = .{ .ignore = {} }, + }, + try parseSingle("ctrl++=ignore"), + ); + + try testing.expectError(Error.InvalidFormat, parseSingle("++=ignore")); +} + +test "parse: equals sign" { + const testing = std.testing; + + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .unicode = '=' } }, + .action = .ignore, + }, + try parseSingle("==ignore"), + ); + + // Modifier + try testing.expectEqual( + Binding{ + .trigger = .{ + .key = .{ .unicode = '=' }, + .mods = .{ .ctrl = true }, + }, + .action = .ignore, + }, + try parseSingle("ctrl+==ignore"), + ); + + try testing.expectError(Error.InvalidFormat, parseSingle("=ignore")); +} + +// For Ghostty 1.2+ we changed our key names to match the W3C and removed +// `physical:`. This tests the backwards compatibility with the old format. +// Note that our backwards compatibility isn't 100% perfect since triggers +// like `a` now map to unicode instead of "translated" (which was also +// removed). But we did our best here with what was unambiguous. +test "parse: backwards compatibility with <= 1.1.x" { + const testing = std.testing; + + // simple, for sanity + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .unicode = '0' } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("zero=ignore"), + ); + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .physical = .digit_0 } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("physical:zero=ignore"), + ); + + // duplicates + try testing.expectError(Error.InvalidFormat, parseSingle("zero+one=ignore")); + + // test our full map + for ( + Trigger.backwards_compatible_keys.keys(), + Trigger.backwards_compatible_keys.values(), + ) |k, v| { + var buf: [128]u8 = undefined; + try testing.expectEqual( + Binding{ + .trigger = .{ .key = v }, + .action = .{ .ignore = {} }, + }, + try parseSingle(try std.fmt.bufPrint(&buf, "{s}=ignore", .{k})), + ); + } +} + test "parse: global triggers" { const testing = std.testing; @@ -1868,7 +2300,7 @@ test "parse: global triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, .flags = .{ .global = true }, @@ -1878,17 +2310,17 @@ test "parse: global triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .physical = .a }, + .key = .{ .physical = .key_a }, }, .action = .{ .ignore = {} }, .flags = .{ .global = true }, - }, try parseSingle("global:physical:a+shift=ignore")); + }, try parseSingle("global:key_a+shift=ignore")); // global unconsumed keys try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, .flags = .{ @@ -1911,7 +2343,7 @@ test "parse: all triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, .flags = .{ .all = true }, @@ -1921,17 +2353,17 @@ test "parse: all triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .physical = .a }, + .key = .{ .physical = .key_a }, }, .action = .{ .ignore = {} }, .flags = .{ .all = true }, - }, try parseSingle("all:physical:a+shift=ignore")); + }, try parseSingle("all:key_a+shift=ignore")); // all unconsumed keys try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, .flags = .{ @@ -1953,14 +2385,14 @@ test "parse: modifier aliases" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .super = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("cmd+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .super = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("command+a=ignore")); @@ -1968,14 +2400,14 @@ test "parse: modifier aliases" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .alt = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("opt+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .alt = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("option+a=ignore")); @@ -1983,7 +2415,7 @@ test "parse: modifier aliases" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .ctrl = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("control+a=ignore")); @@ -2002,7 +2434,7 @@ test "parse: action no parameters" { // no parameters try testing.expectEqual( Binding{ - .trigger = .{ .key = .{ .translated = .a } }, + .trigger = .{ .key = .{ .unicode = 'a' } }, .action = .{ .ignore = {} }, }, try parseSingle("a=ignore"), @@ -2108,15 +2540,15 @@ test "sequence iterator" { // single character { var it: SequenceIterator = .{ .input = "a" }; - try testing.expectEqual(Trigger{ .key = .{ .translated = .a } }, (try it.next()).?); + try testing.expectEqual(Trigger{ .key = .{ .unicode = 'a' } }, (try it.next()).?); try testing.expect(try it.next() == null); } // multi character { var it: SequenceIterator = .{ .input = "a>b" }; - try testing.expectEqual(Trigger{ .key = .{ .translated = .a } }, (try it.next()).?); - try testing.expectEqual(Trigger{ .key = .{ .translated = .b } }, (try it.next()).?); + try testing.expectEqual(Trigger{ .key = .{ .unicode = 'a' } }, (try it.next()).?); + try testing.expectEqual(Trigger{ .key = .{ .unicode = 'b' } }, (try it.next()).?); try testing.expect(try it.next() == null); } @@ -2135,7 +2567,7 @@ test "sequence iterator" { // empty ending sequence { var it: SequenceIterator = .{ .input = "a>" }; - try testing.expectEqual(Trigger{ .key = .{ .translated = .a } }, (try it.next()).?); + try testing.expectEqual(Trigger{ .key = .{ .unicode = 'a' } }, (try it.next()).?); try testing.expectError(Error.InvalidFormat, it.next()); } } @@ -2149,7 +2581,7 @@ test "parse: sequences" { try testing.expectEqual(Parser.Elem{ .binding = .{ .trigger = .{ .mods = .{ .ctrl = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, } }, (try p.next()).?); @@ -2160,11 +2592,11 @@ test "parse: sequences" { { var p = try Parser.init("a>b=ignore"); try testing.expectEqual(Parser.Elem{ .leader = .{ - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, } }, (try p.next()).?); try testing.expectEqual(Parser.Elem{ .binding = .{ .trigger = .{ - .key = .{ .translated = .b }, + .key = .{ .unicode = 'b' }, }, .action = .{ .ignore = {} }, } }, (try p.next()).?); @@ -2183,7 +2615,7 @@ test "set: parseAndPut typical binding" { // Creates forward mapping { - const action = s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf; + const action = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf; try testing.expect(action.action == .new_window); try testing.expectEqual(Flags{}, action.flags); } @@ -2191,7 +2623,7 @@ test "set: parseAndPut typical binding" { // Creates reverse mapping { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } } @@ -2206,7 +2638,7 @@ test "set: parseAndPut unconsumed binding" { // Creates forward mapping { - const trigger: Trigger = .{ .key = .{ .translated = .a } }; + const trigger: Trigger = .{ .key = .{ .unicode = 'a' } }; const action = s.get(trigger).?.value_ptr.*.leaf; try testing.expect(action.action == .new_window); try testing.expectEqual(Flags{ .consumed = false }, action.flags); @@ -2215,7 +2647,7 @@ test "set: parseAndPut unconsumed binding" { // Creates reverse mapping { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } } @@ -2231,25 +2663,7 @@ test "set: parseAndPut removed binding" { // Creates forward mapping { - const trigger: Trigger = .{ .key = .{ .translated = .a } }; - try testing.expect(s.get(trigger) == null); - } - try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); -} - -test "set: parseAndPut removed physical binding" { - const testing = std.testing; - const alloc = testing.allocator; - - var s: Set = .{}; - defer s.deinit(alloc); - - try s.parseAndPut(alloc, "physical:a=new_window"); - try s.parseAndPut(alloc, "a=unbind"); - - // Creates forward mapping - { - const trigger: Trigger = .{ .key = .{ .physical = .a } }; + const trigger: Trigger = .{ .key = .{ .unicode = 'a' } }; try testing.expect(s.get(trigger) == null); } try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); @@ -2265,13 +2679,13 @@ test "set: parseAndPut sequence" { try s.parseAndPut(alloc, "a>b=new_window"); var current: *Set = &s; { - const t: Trigger = .{ .key = .{ .translated = .a } }; + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leader); current = e.leader; } { - const t: Trigger = .{ .key = .{ .translated = .b } }; + const t: Trigger = .{ .key = .{ .unicode = 'b' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_window); @@ -2290,20 +2704,20 @@ test "set: parseAndPut sequence with two actions" { try s.parseAndPut(alloc, "a>c=new_tab"); var current: *Set = &s; { - const t: Trigger = .{ .key = .{ .translated = .a } }; + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leader); current = e.leader; } { - const t: Trigger = .{ .key = .{ .translated = .b } }; + const t: Trigger = .{ .key = .{ .unicode = 'b' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_window); try testing.expectEqual(Flags{}, e.leaf.flags); } { - const t: Trigger = .{ .key = .{ .translated = .c } }; + const t: Trigger = .{ .key = .{ .unicode = 'c' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_tab); @@ -2322,13 +2736,13 @@ test "set: parseAndPut overwrite sequence" { try s.parseAndPut(alloc, "a>b=new_window"); var current: *Set = &s; { - const t: Trigger = .{ .key = .{ .translated = .a } }; + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leader); current = e.leader; } { - const t: Trigger = .{ .key = .{ .translated = .b } }; + const t: Trigger = .{ .key = .{ .unicode = 'b' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_window); @@ -2347,13 +2761,13 @@ test "set: parseAndPut overwrite leader" { try s.parseAndPut(alloc, "a>b=new_window"); var current: *Set = &s; { - const t: Trigger = .{ .key = .{ .translated = .a } }; + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leader); current = e.leader; } { - const t: Trigger = .{ .key = .{ .translated = .b } }; + const t: Trigger = .{ .key = .{ .unicode = 'b' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_window); @@ -2372,7 +2786,7 @@ test "set: parseAndPut unbind sequence unbinds leader" { try s.parseAndPut(alloc, "a>b=unbind"); var current: *Set = &s; { - const t: Trigger = .{ .key = .{ .translated = .a } }; + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; try testing.expect(current.get(t) == null); } } @@ -2387,7 +2801,7 @@ test "set: parseAndPut unbind sequence unbinds leader if not set" { try s.parseAndPut(alloc, "a>b=unbind"); var current: *Set = &s; { - const t: Trigger = .{ .key = .{ .translated = .a } }; + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; try testing.expect(current.get(t) == null); } } @@ -2405,7 +2819,7 @@ test "set: parseAndPut sequence preserves reverse mapping" { // Creates reverse mapping { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } } @@ -2419,13 +2833,13 @@ test "set: put overwrites sequence" { try s.parseAndPut(alloc, "ctrl+a>b=new_window"); try s.put(alloc, .{ .mods = .{ .ctrl = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .{ .new_window = {} }); // Creates reverse mapping { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } } @@ -2436,24 +2850,24 @@ test "set: maintains reverse mapping" { var s: Set = .{}; defer s.deinit(alloc); - try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } // should be most recent - try s.put(alloc, .{ .key = .{ .translated = .b } }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .unicode = 'b' } }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .b); + try testing.expect(trigger.key.unicode == 'b'); } // removal should replace - s.remove(alloc, .{ .key = .{ .translated = .b } }); + s.remove(alloc, .{ .key = .{ .unicode = 'b' } }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } } @@ -2464,29 +2878,29 @@ test "set: performable is not part of reverse mappings" { var s: Set = .{}; defer s.deinit(alloc); - try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } // trigger should be non-performable try s.putFlags( alloc, - .{ .key = .{ .translated = .b } }, + .{ .key = .{ .unicode = 'b' } }, .{ .new_window = {} }, .{ .performable = true }, ); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } // removal of performable should do nothing - s.remove(alloc, .{ .key = .{ .translated = .b } }); + s.remove(alloc, .{ .key = .{ .unicode = 'b' } }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } } @@ -2497,14 +2911,14 @@ test "set: overriding a mapping updates reverse" { var s: Set = .{}; defer s.deinit(alloc); - try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } // should be most recent - try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_tab = {} }); + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_tab = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }); try testing.expect(trigger == null); @@ -2518,24 +2932,134 @@ test "set: consumed state" { var s: Set = .{}; defer s.deinit(alloc); - try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.* == .leaf); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf.flags.consumed); + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.* == .leaf); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); try s.putFlags( alloc, - .{ .key = .{ .translated = .a } }, + .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }, .{ .consumed = false }, ); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.* == .leaf); - try testing.expect(!s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf.flags.consumed); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.* == .leaf); + try testing.expect(!s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); - try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.* == .leaf); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf.flags.consumed); + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.* == .leaf); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); } +test "set: getEvent physical" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+quote=new_window"); + + // Physical matches on physical + { + const action = s.getEvent(.{ + .key = .quote, + .mods = .{ .ctrl = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Physical does not match on UTF8/codepoint + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + .utf8 = "'", + .unshifted_codepoint = '\'', + }); + try testing.expect(action == null); + } +} + +test "set: getEvent codepoint" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+'=new_window"); + + // Matches on codepoint + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + .utf8 = "", + .unshifted_codepoint = '\'', + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Matches on UTF-8 + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + .utf8 = "'", + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Doesn't match on physical + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + }); + try testing.expect(action == null); + } +} + +test "set: getEvent codepoint case folding" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+A=new_window"); + + // Lowercase codepoint + { + const action = s.getEvent(.{ + .key = .key_j, + .mods = .{ .ctrl = true }, + .utf8 = "", + .unshifted_codepoint = 'a', + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Uppercase codepoint + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + .utf8 = "", + .unshifted_codepoint = 'A', + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Negative case for sanity + { + const action = s.getEvent(.{ + .key = .key_j, + .mods = .{ .ctrl = true }, + }); + try testing.expect(action == null); + } +} test "Action: clone" { const testing = std.testing; var arena = std.heap.ArenaAllocator.init(testing.allocator); diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig index e79856a94..b5f18b5a2 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/KeyEncoder.zig @@ -103,11 +103,15 @@ fn kitty( // and UTF8 text we just send it directly since we assume that is // whats happening. See legacy()'s similar logic for more details // on how to verify this. - if (self.event.utf8.len > 0) { + if (self.event.utf8.len > 0) utf8: { switch (self.event.key) { - .enter => return try copyToBuf(buf, self.event.utf8), - .backspace => return "", else => {}, + inline .enter, .backspace => |tag| { + // See legacy for why we handle this this way. + if (isControlUtf8(self.event.utf8)) break :utf8; + if (comptime tag == .backspace) return ""; + return try copyToBuf(buf, self.event.utf8); + }, } } @@ -142,7 +146,9 @@ fn kitty( // the real world issue is usually control characters. const view = try std.unicode.Utf8View.init(self.event.utf8); var it = view.iterator(); - while (it.nextCodepoint()) |cp| if (isControl(cp)) break :plain_text; + while (it.nextCodepoint()) |cp| { + if (isControl(cp)) break :plain_text; + } return try copyToBuf(buf, self.event.utf8); } @@ -158,7 +164,7 @@ fn kitty( var seq: KittySequence = .{ .key = entry.code, .final = entry.final, - .mods = KittyMods.fromInput( + .mods = .fromInput( self.event.action, self.event.key, all_mods, @@ -208,7 +214,9 @@ fn kitty( } } - if (self.kitty_flags.report_associated and seq.event != .release) associated: { + if (self.kitty_flags.report_associated and + seq.event != .release) + associated: { // Determine if the Alt modifier should be treated as an actual // modifier (in which case it prevents associated text) or as // the macOS Option key, which does not prevent associated text. @@ -272,11 +280,21 @@ fn legacy( // - Korean: escape commits the dead key state // - Korean: backspace should delete a single preedit char // - if (self.event.utf8.len > 0) { + if (self.event.utf8.len > 0) utf8: { switch (self.event.key) { else => {}, - .backspace => return "", - .enter, .escape => break :pc_style, + inline .backspace, .enter, .escape => |tag| { + // We want to ignore control characters. This is because + // some apprts (macOS) will send control characters as + // UTF-8 encodings and we handle that manually. + if (isControlUtf8(self.event.utf8)) break :utf8; + + // Backspace encodes nothing because we modified IME. + // Enter/escape don't encode the PC-style encoding + // because we want to encode committed text. + if (comptime tag == .backspace) return ""; + break :pc_style; + }, } } @@ -571,7 +589,9 @@ fn ctrlSeq( if (!mods.ctrl) return null; const char, const unset_mods = unset_mods: { - var unset_mods = mods; + // We need to only get binding modifiers so we strip lock + // keys, sides, etc. + var unset_mods = mods.binding(); // Remove alt from our modifiers because it does not impact whether // we are generating a ctrl sequence and we handle the ESC-prefix @@ -640,7 +660,7 @@ fn ctrlSeq( // only matches Kitty in behavior. But I believe this is a // justified divergence because it's a useful distinction. - break :unset_mods .{ char, unset_mods.binding() }; + break :unset_mods .{ char, unset_mods }; }; // After unsetting, we only continue if we have ONLY control set. @@ -710,6 +730,12 @@ fn isControl(cp: u21) bool { return cp < 0x20 or cp == 0x7F; } +/// Returns true if this string is comprised of a single +/// control character. This returns false for multi-byte strings. +fn isControlUtf8(str: []const u8) bool { + return str.len == 1 and isControl(@intCast(str[0])); +} + /// This is the bitmask for fixterm CSI u modifiers. const CsiUMods = packed struct(u3) { shift: bool = false, @@ -1082,7 +1108,7 @@ test "kitty: plain text" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .a, + .key = .key_a, .mods = .{}, .utf8 = "abcd", }, @@ -1098,7 +1124,7 @@ test "kitty: repeat with just disambiguate" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .a, + .key = .key_a, .action = .repeat, .mods = .{}, .utf8 = "a", @@ -1222,7 +1248,7 @@ test "kitty: enter with all flags" { test "kitty: ctrl with all flags" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ - .event = .{ .key = .left_control, .mods = .{ .ctrl = true }, .utf8 = "" }, + .event = .{ .key = .control_left, .mods = .{ .ctrl = true }, .utf8 = "" }, .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1240,7 +1266,7 @@ test "kitty: ctrl release with ctrl mod set" { var enc: KeyEncoder = .{ .event = .{ .action = .release, - .key = .left_control, + .key = .control_left, .mods = .{ .ctrl = true }, .utf8 = "", }, @@ -1272,7 +1298,7 @@ test "kitty: composing with no modifier" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .a, + .key = .key_a, .mods = .{ .shift = true }, .composing = true, }, @@ -1287,7 +1313,7 @@ test "kitty: composing with modifier" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .left_shift, + .key = .shift_left, .mods = .{ .shift = true }, .composing = true, }, @@ -1302,7 +1328,7 @@ test "kitty: shift+a on US keyboard" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .a, + .key = .key_a, .mods = .{ .shift = true }, .utf8 = "A", .unshifted_codepoint = 97, // lowercase A @@ -1321,7 +1347,7 @@ test "kitty: matching unshifted codepoint" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .a, + .key = .key_a, .mods = .{ .shift = true }, .utf8 = "A", .unshifted_codepoint = 65, @@ -1344,7 +1370,7 @@ test "kitty: report alternates with caps" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .j, + .key = .key_j, .mods = .{ .caps_lock = true }, .utf8 = "J", .unshifted_codepoint = 106, @@ -1450,7 +1476,7 @@ test "kitty: report alternates with hu layout release" { var enc: KeyEncoder = .{ .event = .{ .action = .release, - .key = .left_bracket, + .key = .bracket_left, .mods = .{ .ctrl = true }, .utf8 = "", .unshifted_codepoint = 337, @@ -1473,7 +1499,7 @@ test "kitty: up arrow with utf8" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .up, + .key = .arrow_up, .mods = .{}, .utf8 = &.{30}, }, @@ -1505,7 +1531,7 @@ test "kitty: left shift" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .left_shift, + .key = .shift_left, .mods = .{}, .utf8 = "", }, @@ -1521,7 +1547,7 @@ test "kitty: left shift with report all" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .left_shift, + .key = .shift_left, .mods = .{}, .utf8 = "", }, @@ -1539,7 +1565,7 @@ test "kitty: report associated with alt text on macOS with option" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .w, + .key = .key_w, .mods = .{ .alt = true }, .utf8 = "∑", .unshifted_codepoint = 119, @@ -1565,7 +1591,7 @@ test "kitty: report associated with alt text on macOS with alt" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .w, + .key = .key_w, .mods = .{ .alt = true }, .utf8 = "∑", .unshifted_codepoint = 119, @@ -1588,7 +1614,7 @@ test "kitty: report associated with alt text on macOS with alt" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .w, + .key = .key_w, .mods = .{}, .utf8 = "∑", .unshifted_codepoint = 119, @@ -1611,7 +1637,7 @@ test "kitty: report associated with modifiers" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .j, + .key = .key_j, .mods = .{ .ctrl = true }, .utf8 = "j", .unshifted_codepoint = 106, @@ -1632,7 +1658,7 @@ test "kitty: report associated" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .j, + .key = .key_j, .mods = .{ .shift = true }, .utf8 = "J", .unshifted_codepoint = 106, @@ -1654,7 +1680,7 @@ test "kitty: report associated on release" { var enc: KeyEncoder = .{ .event = .{ .action = .release, - .key = .j, + .key = .key_j, .mods = .{ .shift = true }, .utf8 = "J", .unshifted_codepoint = 106, @@ -1713,7 +1739,7 @@ test "kitty: enter with utf8 (dead key state)" { test "kitty: keypad number" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ - .event = .{ .key = .kp_1, .mods = .{}, .utf8 = "1" }, + .event = .{ .key = .numpad_1, .mods = .{}, .utf8 = "1" }, .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1807,7 +1833,7 @@ test "legacy: ctrl+alt+c" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .c, + .key = .key_c, .mods = .{ .ctrl = true, .alt = true }, .utf8 = "c", }, @@ -1821,7 +1847,7 @@ test "legacy: alt+c" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .c, + .key = .key_c, .utf8 = "c", .mods = .{ .alt = true }, }, @@ -1837,7 +1863,7 @@ test "legacy: alt+e only unshifted" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .e, + .key = .key_e, .unshifted_codepoint = 'e', .mods = .{ .alt = true }, }, @@ -1855,7 +1881,7 @@ test "legacy: alt+x macos" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .c, + .key = .key_c, .utf8 = "≈", .unshifted_codepoint = 'c', .mods = .{ .alt = true }, @@ -1891,7 +1917,7 @@ test "legacy: alt+ф" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .f, + .key = .key_f, .utf8 = "ф", .mods = .{ .alt = true }, }, @@ -1906,7 +1932,7 @@ test "legacy: ctrl+c" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .c, + .key = .key_c, .mods = .{ .ctrl = true }, .utf8 = "c", }, @@ -1947,7 +1973,7 @@ test "legacy: ctrl+shift+char with modify other state 2" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .h, + .key = .key_h, .mods = .{ .ctrl = true, .shift = true }, .utf8 = "H", }, @@ -1962,7 +1988,7 @@ test "legacy: fixterm awkward letters" { var buf: [128]u8 = undefined; { var enc: KeyEncoder = .{ .event = .{ - .key = .i, + .key = .key_i, .mods = .{ .ctrl = true }, .utf8 = "i", } }; @@ -1971,7 +1997,7 @@ test "legacy: fixterm awkward letters" { } { var enc: KeyEncoder = .{ .event = .{ - .key = .m, + .key = .key_m, .mods = .{ .ctrl = true }, .utf8 = "m", } }; @@ -1980,7 +2006,7 @@ test "legacy: fixterm awkward letters" { } { var enc: KeyEncoder = .{ .event = .{ - .key = .left_bracket, + .key = .bracket_left, .mods = .{ .ctrl = true }, .utf8 = "[", } }; @@ -1989,7 +2015,7 @@ test "legacy: fixterm awkward letters" { } { var enc: KeyEncoder = .{ .event = .{ - .key = .two, + .key = .digit_2, .mods = .{ .ctrl = true, .shift = true }, .utf8 = "@", .unshifted_codepoint = '2', @@ -2005,7 +2031,7 @@ test "legacy: ctrl+shift+letter ascii" { var buf: [128]u8 = undefined; { var enc: KeyEncoder = .{ .event = .{ - .key = .m, + .key = .key_m, .mods = .{ .ctrl = true, .shift = true }, .utf8 = "M", .unshifted_codepoint = 'm', @@ -2019,7 +2045,7 @@ test "legacy: shift+function key should use all mods" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .up, + .key = .arrow_up, .mods = .{ .shift = true }, .consumed_mods = .{ .shift = true }, }, @@ -2033,7 +2059,7 @@ test "legacy: keypad enter" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .kp_enter, + .key = .numpad_enter, .mods = .{}, .consumed_mods = .{}, }, @@ -2047,7 +2073,7 @@ test "legacy: keypad 1" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .kp_1, + .key = .numpad_1, .mods = .{}, .consumed_mods = .{}, .utf8 = "1", @@ -2062,7 +2088,7 @@ test "legacy: keypad 1 with application keypad" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .kp_1, + .key = .numpad_1, .mods = .{}, .consumed_mods = .{}, .utf8 = "1", @@ -2078,7 +2104,7 @@ test "legacy: keypad 1 with application keypad and numlock" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .kp_1, + .key = .numpad_1, .mods = .{ .num_lock = true }, .consumed_mods = .{}, .utf8 = "1", @@ -2094,7 +2120,7 @@ test "legacy: keypad 1 with application keypad and numlock ignore" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .kp_1, + .key = .numpad_1, .mods = .{ .num_lock = false }, .consumed_mods = .{}, .utf8 = "1", @@ -2189,8 +2215,7 @@ test "legacy: hu layout ctrl+ő sends proper codepoint" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .left_bracket, - .physical_key = .left_bracket, + .key = .bracket_left, .mods = .{ .ctrl = true }, .utf8 = "ő", .unshifted_codepoint = 337, @@ -2207,7 +2232,7 @@ test "legacy: super-only on macOS with text" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .b, + .key = .key_b, .utf8 = "b", .mods = .{ .super = true }, }, @@ -2223,7 +2248,7 @@ test "legacy: super and other mods on macOS with text" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .b, + .key = .key_b, .utf8 = "B", .mods = .{ .super = true, .shift = true }, }, @@ -2233,51 +2258,73 @@ test "legacy: super and other mods on macOS with text" { try testing.expectEqualStrings("", actual); } +test "legacy: backspace with DEL utf8" { + var buf: [128]u8 = undefined; + var enc: KeyEncoder = .{ + .event = .{ + .key = .backspace, + .utf8 = &.{0x7F}, + .unshifted_codepoint = 0x08, + }, + }; + + const actual = try enc.legacy(&buf); + try testing.expectEqualStrings("\x7F", actual); +} + test "ctrlseq: normal ctrl c" { - const seq = ctrlSeq(.invalid, "c", 'c', .{ .ctrl = true }); + const seq = ctrlSeq(.unidentified, "c", 'c', .{ .ctrl = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: normal ctrl c, right control" { - const seq = ctrlSeq(.invalid, "c", 'c', .{ .ctrl = true, .sides = .{ .ctrl = .right } }); + const seq = ctrlSeq(.unidentified, "c", 'c', .{ .ctrl = true, .sides = .{ .ctrl = .right } }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: alt should be allowed" { - const seq = ctrlSeq(.invalid, "c", 'c', .{ .alt = true, .ctrl = true }); + const seq = ctrlSeq(.unidentified, "c", 'c', .{ .alt = true, .ctrl = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: no ctrl does nothing" { - try testing.expect(ctrlSeq(.invalid, "c", 'c', .{}) == null); + try testing.expect(ctrlSeq(.unidentified, "c", 'c', .{}) == null); } test "ctrlseq: shifted non-character" { - const seq = ctrlSeq(.invalid, "_", '-', .{ .ctrl = true, .shift = true }); + const seq = ctrlSeq(.unidentified, "_", '-', .{ .ctrl = true, .shift = true }); try testing.expectEqual(@as(u8, 0x1F), seq.?); } test "ctrlseq: caps ascii letter" { - const seq = ctrlSeq(.invalid, "C", 'c', .{ .ctrl = true, .caps_lock = true }); + const seq = ctrlSeq(.unidentified, "C", 'c', .{ .ctrl = true, .caps_lock = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: shift does not generate ctrl seq" { - try testing.expect(ctrlSeq(.invalid, "C", 'c', .{ .shift = true }) == null); - try testing.expect(ctrlSeq(.invalid, "C", 'c', .{ .shift = true, .ctrl = true }) == null); + try testing.expect(ctrlSeq(.unidentified, "C", 'c', .{ .shift = true }) == null); + try testing.expect(ctrlSeq(.unidentified, "C", 'c', .{ .shift = true, .ctrl = true }) == null); } test "ctrlseq: russian ctrl c" { - const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true }); + const seq = ctrlSeq(.key_c, "с", 0x0441, .{ .ctrl = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: russian shifted ctrl c" { - const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true, .shift = true }); + const seq = ctrlSeq(.key_c, "с", 0x0441, .{ .ctrl = true, .shift = true }); try testing.expect(seq == null); } test "ctrlseq: russian alt ctrl c" { - const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true, .alt = true }); + const seq = ctrlSeq(.key_c, "с", 0x0441, .{ .ctrl = true, .alt = true }); + try testing.expectEqual(@as(u8, 0x03), seq.?); +} + +test "ctrlseq: right ctrl c" { + const seq = ctrlSeq(.key_c, "с", 'c', .{ + .ctrl = true, + .sides = .{ .ctrl = .right }, + }); try testing.expectEqual(@as(u8, 0x03), seq.?); } diff --git a/src/input/command.zig b/src/input/command.zig index 1f685269b..94fbf56a5 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -119,7 +119,7 @@ fn actionCommands(action: Action.Key) []const Command { .paste_from_clipboard => comptime &.{.{ .action = .paste_from_clipboard, .title = "Paste from Clipboard", - .description = "Paste the contents of the clipboard.", + .description = "Paste the contents of the main clipboard.", }}, .paste_from_selection => comptime &.{.{ @@ -274,6 +274,39 @@ fn actionCommands(action: Action.Key) []const Command { }, }, + .goto_split => comptime &.{ + .{ + .action = .{ .goto_split = .previous }, + .title = "Focus Split: Previous", + .description = "Focus the previous split, if any.", + }, + .{ + .action = .{ .goto_split = .next }, + .title = "Focus Split: Next", + .description = "Focus the next split, if any.", + }, + .{ + .action = .{ .goto_split = .left }, + .title = "Focus Split: Left", + .description = "Focus the split to the left, if it exists.", + }, + .{ + .action = .{ .goto_split = .right }, + .title = "Focus Split: Right", + .description = "Focus the split to the right, if it exists.", + }, + .{ + .action = .{ .goto_split = .up }, + .title = "Focus Split: Up", + .description = "Focus the split above, if it exists.", + }, + .{ + .action = .{ .goto_split = .down }, + .title = "Focus Split: Down", + .description = "Focus the split below, if it exists.", + }, + }, + .toggle_split_zoom => comptime &.{.{ .action = .toggle_split_zoom, .title = "Toggle Split Zoom", @@ -298,6 +331,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle the inspector.", }}, + .show_gtk_inspector => comptime &.{.{ + .action = .show_gtk_inspector, + .title = "Show the GTK Inspector", + .description = "Show the GTK inspector.", + }}, + .open_config => comptime &.{.{ .action = .open_config, .title = "Open Config", @@ -364,6 +403,24 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle secure input mode.", }}, + .check_for_updates => comptime &.{.{ + .action = .check_for_updates, + .title = "Check for Updates", + .description = "Check for updates to the application.", + }}, + + .undo => comptime &.{.{ + .action = .undo, + .title = "Undo", + .description = "Undo the last action.", + }}, + + .redo => comptime &.{.{ + .action = .redo, + .title = "Redo", + .description = "Redo the last undone action.", + }}, + .quit => comptime &.{.{ .action = .quit, .title = "Quit", @@ -384,7 +441,6 @@ fn actionCommands(action: Action.Key) []const Command { .jump_to_prompt, .write_scrollback_file, .goto_tab, - .goto_split, .resize_split, .crash, => comptime &.{}, diff --git a/src/input/function_keys.zig b/src/input/function_keys.zig index 612112e28..33a5b89c0 100644 --- a/src/input/function_keys.zig +++ b/src/input/function_keys.zig @@ -75,10 +75,10 @@ pub const KeyEntryArray = std.EnumArray(key.Key, []const Entry); pub const keys = keys: { var result = KeyEntryArray.initFill(&.{}); - result.set(.up, pcStyle("\x1b[1;{}A") ++ cursorKey("\x1b[A", "\x1bOA")); - result.set(.down, pcStyle("\x1b[1;{}B") ++ cursorKey("\x1b[B", "\x1bOB")); - result.set(.right, pcStyle("\x1b[1;{}C") ++ cursorKey("\x1b[C", "\x1bOC")); - result.set(.left, pcStyle("\x1b[1;{}D") ++ cursorKey("\x1b[D", "\x1bOD")); + result.set(.arrow_up, pcStyle("\x1b[1;{}A") ++ cursorKey("\x1b[A", "\x1bOA")); + result.set(.arrow_down, pcStyle("\x1b[1;{}B") ++ cursorKey("\x1b[B", "\x1bOB")); + result.set(.arrow_right, pcStyle("\x1b[1;{}C") ++ cursorKey("\x1b[C", "\x1bOC")); + result.set(.arrow_left, pcStyle("\x1b[1;{}D") ++ cursorKey("\x1b[D", "\x1bOD")); result.set(.home, pcStyle("\x1b[1;{}H") ++ cursorKey("\x1b[H", "\x1bOH")); result.set(.end, pcStyle("\x1b[1;{}F") ++ cursorKey("\x1b[F", "\x1bOF")); result.set(.insert, pcStyle("\x1b[2;{}~") ++ .{Entry{ .sequence = "\x1B[2~" }}); @@ -101,33 +101,33 @@ pub const keys = keys: { result.set(.f12, pcStyle("\x1b[24;{}~") ++ .{Entry{ .sequence = "\x1B[24~" }}); // Keypad keys - result.set(.kp_0, kpKeys("p")); - result.set(.kp_1, kpKeys("q")); - result.set(.kp_2, kpKeys("r")); - result.set(.kp_3, kpKeys("s")); - result.set(.kp_4, kpKeys("t")); - result.set(.kp_5, kpKeys("u")); - result.set(.kp_6, kpKeys("v")); - result.set(.kp_7, kpKeys("w")); - result.set(.kp_8, kpKeys("x")); - result.set(.kp_9, kpKeys("y")); - result.set(.kp_decimal, kpKeys("n")); - result.set(.kp_divide, kpKeys("o")); - result.set(.kp_multiply, kpKeys("j")); - result.set(.kp_subtract, kpKeys("m")); - result.set(.kp_add, kpKeys("k")); - result.set(.kp_enter, kpKeys("M") ++ .{Entry{ .sequence = "\r" }}); - result.set(.kp_up, pcStyle("\x1b[1;{}A") ++ cursorKey("\x1b[A", "\x1bOA")); - result.set(.kp_down, pcStyle("\x1b[1;{}B") ++ cursorKey("\x1b[B", "\x1bOB")); - result.set(.kp_right, pcStyle("\x1b[1;{}C") ++ cursorKey("\x1b[C", "\x1bOC")); - result.set(.kp_left, pcStyle("\x1b[1;{}D") ++ cursorKey("\x1b[D", "\x1bOD")); - result.set(.kp_begin, pcStyle("\x1b[1;{}E") ++ cursorKey("\x1b[E", "\x1bOE")); - result.set(.kp_home, pcStyle("\x1b[1;{}H") ++ cursorKey("\x1b[H", "\x1bOH")); - result.set(.kp_end, pcStyle("\x1b[1;{}F") ++ cursorKey("\x1b[F", "\x1bOF")); - result.set(.kp_insert, pcStyle("\x1b[2;{}~") ++ .{Entry{ .sequence = "\x1B[2~" }}); - result.set(.kp_delete, pcStyle("\x1b[3;{}~") ++ .{Entry{ .sequence = "\x1B[3~" }}); - result.set(.kp_page_up, pcStyle("\x1b[5;{}~") ++ .{Entry{ .sequence = "\x1B[5~" }}); - result.set(.kp_page_down, pcStyle("\x1b[6;{}~") ++ .{Entry{ .sequence = "\x1B[6~" }}); + result.set(.numpad_0, kpKeys("p")); + result.set(.numpad_1, kpKeys("q")); + result.set(.numpad_2, kpKeys("r")); + result.set(.numpad_3, kpKeys("s")); + result.set(.numpad_4, kpKeys("t")); + result.set(.numpad_5, kpKeys("u")); + result.set(.numpad_6, kpKeys("v")); + result.set(.numpad_7, kpKeys("w")); + result.set(.numpad_8, kpKeys("x")); + result.set(.numpad_9, kpKeys("y")); + result.set(.numpad_decimal, kpKeys("n")); + result.set(.numpad_divide, kpKeys("o")); + result.set(.numpad_multiply, kpKeys("j")); + result.set(.numpad_subtract, kpKeys("m")); + result.set(.numpad_add, kpKeys("k")); + result.set(.numpad_enter, kpKeys("M") ++ .{Entry{ .sequence = "\r" }}); + result.set(.numpad_up, pcStyle("\x1b[1;{}A") ++ cursorKey("\x1b[A", "\x1bOA")); + result.set(.numpad_down, pcStyle("\x1b[1;{}B") ++ cursorKey("\x1b[B", "\x1bOB")); + result.set(.numpad_right, pcStyle("\x1b[1;{}C") ++ cursorKey("\x1b[C", "\x1bOC")); + result.set(.numpad_left, pcStyle("\x1b[1;{}D") ++ cursorKey("\x1b[D", "\x1bOD")); + result.set(.numpad_begin, pcStyle("\x1b[1;{}E") ++ cursorKey("\x1b[E", "\x1bOE")); + result.set(.numpad_home, pcStyle("\x1b[1;{}H") ++ cursorKey("\x1b[H", "\x1bOH")); + result.set(.numpad_end, pcStyle("\x1b[1;{}F") ++ cursorKey("\x1b[F", "\x1bOF")); + result.set(.numpad_insert, pcStyle("\x1b[2;{}~") ++ .{Entry{ .sequence = "\x1B[2~" }}); + result.set(.numpad_delete, pcStyle("\x1b[3;{}~") ++ .{Entry{ .sequence = "\x1B[3~" }}); + result.set(.numpad_page_up, pcStyle("\x1b[5;{}~") ++ .{Entry{ .sequence = "\x1B[5~" }}); + result.set(.numpad_page_down, pcStyle("\x1b[6;{}~") ++ .{Entry{ .sequence = "\x1B[6~" }}); result.set(.backspace, &.{ // Modify Keys Normal diff --git a/src/input/key.zig b/src/input/key.zig index ec65170f2..28aa3ccf4 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -16,12 +16,10 @@ pub const KeyEvent = struct { /// The action: press, release, etc. action: Action = .press, - /// "key" is the logical key that was pressed. For example, if - /// a Dvorak keyboard layout is being used on a US keyboard, - /// the "i" physical key will be reported as "c". The physical - /// key is the key that was physically pressed on the keyboard. - key: Key, - physical_key: Key = .invalid, + /// The keycode of the physical key that was pressed. This is agnostic + /// to the layout. Layout-dependent matching can only be done via the + /// UTF-8 or unshifted codepoint. + key: Key = .unidentified, /// Mods are the modifiers that are pressed. mods: Mods = .{}, @@ -63,7 +61,6 @@ pub const KeyEvent = struct { // These are all the fields that are explicitly part of Trigger. std.hash.autoHash(&hasher, self.key); - std.hash.autoHash(&hasher, self.physical_key); std.hash.autoHash(&hasher, self.unshifted_codepoint); std.hash.autoHash(&hasher, self.mods.binding()); @@ -152,9 +149,6 @@ pub const Mods = packed struct(Mods.Backing) { pub fn translation(self: Mods, option_as_alt: config.OptionAsAlt) Mods { var result = self; - // Control is never used for translation. - result.ctrl = false; - // macos-option-as-alt for darwin if (comptime builtin.target.os.tag.isDarwin()) alt: { // Alt has to be set only on the correct side @@ -190,14 +184,6 @@ pub const Mods = packed struct(Mods.Backing) { ); } - test "translation removes control" { - const testing = std.testing; - - const mods: Mods = .{ .ctrl = true }; - const result = mods.translation(.true); - try testing.expectEqual(Mods{}, result); - } - test "translation macos-option-as-alt" { if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; @@ -255,95 +241,164 @@ pub const Action = enum(c_int) { repeat, }; -/// The set of keys that can map to keybindings. These have no fixed enum -/// values because we map platform-specific keys to this set. Note that -/// this only needs to accommodate what maps to a key. If a key is not bound -/// to anything and the key can be mapped to a printable character, then that -/// unicode character is sent directly to the pty. +/// The set of key codes that Ghostty is aware of. These represent +/// physical keys on the keyboard. The logical key (or key string) +/// is the string that is generated by the key event and that is up +/// to the apprt to provide. /// -/// This is backed by a c_int so we can use this as-is for our embedding API. +/// Note that these are layout-independent. For example, the "a" +/// key on a US keyboard is the same as the "ф" key on a Russian +/// keyboard, but both will report the "a" enum value in the key +/// event. These values are based on the W3C standard. See: +/// https://www.w3.org/TR/uievents-code /// -/// IMPORTANT: Any changes here update include/ghostty.h +/// Layout-dependent strings are provided in the KeyEvent struct as +/// UTF-8 and are produced by the associated apprt. Ghostty core has +/// no mechanism to map input events to strings without the apprt. +/// +/// IMPORTANT: Any changes here update include/ghostty.h ghostty_input_key_e pub const Key = enum(c_int) { - invalid, + unidentified, - // a-z - a, - b, - c, - d, - e, - f, - g, - h, - i, - j, - k, - l, - m, - n, - o, - p, - q, - r, - s, - t, - u, - v, - w, - x, - y, - z, - - // numbers - zero, - one, - two, - three, - four, - five, - six, - seven, - eight, - nine, - - // punctuation - semicolon, - space, - apostrophe, + // "Writing System Keys" § 3.1.1 + backquote, + backslash, + bracket_left, + bracket_right, comma, - grave_accent, // ` - period, - slash, - minus, - plus, + digit_0, + digit_1, + digit_2, + digit_3, + digit_4, + digit_5, + digit_6, + digit_7, + digit_8, + digit_9, equal, - left_bracket, // [ - right_bracket, // ] - backslash, // \ + intl_backslash, + intl_ro, + intl_yen, + key_a, + key_b, + key_c, + key_d, + key_e, + key_f, + key_g, + key_h, + key_i, + key_j, + key_k, + key_l, + key_m, + key_n, + key_o, + key_p, + key_q, + key_r, + key_s, + key_t, + key_u, + key_v, + key_w, + key_x, + key_y, + key_z, + minus, + period, + quote, + semicolon, + slash, - // control - up, - down, - right, - left, - home, - end, - insert, - delete, - caps_lock, - scroll_lock, - num_lock, - page_up, - page_down, - escape, - enter, - tab, + // "Functional Keys" § 3.1.2 + alt_left, + alt_right, backspace, - print_screen, - pause, + caps_lock, + context_menu, + control_left, + control_right, + enter, + meta_left, + meta_right, + shift_left, + shift_right, + space, + tab, + convert, + kana_mode, + non_convert, - // function keys + // "Control Pad Section" § 3.2 + delete, + end, + help, + home, + insert, + page_down, + page_up, + + // "Arrow Pad Section" § 3.3 + arrow_down, + arrow_left, + arrow_right, + arrow_up, + + // "Numpad Section" § 3.4 + num_lock, + numpad_0, + numpad_1, + numpad_2, + numpad_3, + numpad_4, + numpad_5, + numpad_6, + numpad_7, + numpad_8, + numpad_9, + numpad_add, + numpad_backspace, + numpad_clear, + numpad_clear_entry, + numpad_comma, + numpad_decimal, + numpad_divide, + numpad_enter, + numpad_equal, + numpad_memory_add, + numpad_memory_clear, + numpad_memory_recall, + numpad_memory_store, + numpad_memory_subtract, + numpad_multiply, + numpad_paren_left, + numpad_paren_right, + numpad_subtract, + + // > For numpads that provide keys not listed here, a code value string + // > should be created by starting with "Numpad" and appending an + // > appropriate description of the key. + // + // These numpad entries are distinguished by various encoding protocols + // (legacy and Kitty) so we support them here in case the apprt can + // produce them. + numpad_separator, + numpad_up, + numpad_down, + numpad_right, + numpad_left, + numpad_begin, + numpad_home, + numpad_end, + numpad_insert, + numpad_delete, + numpad_page_up, + numpad_page_down, + + // "Function Section" § 3.5 + escape, f1, f2, f3, @@ -369,52 +424,40 @@ pub const Key = enum(c_int) { f23, f24, f25, + @"fn", + fn_lock, + print_screen, + scroll_lock, + pause, - // keypad - kp_0, - kp_1, - kp_2, - kp_3, - kp_4, - kp_5, - kp_6, - kp_7, - kp_8, - kp_9, - kp_decimal, - kp_divide, - kp_multiply, - kp_subtract, - kp_add, - kp_enter, - kp_equal, - kp_separator, - kp_left, - kp_right, - kp_up, - kp_down, - kp_page_up, - kp_page_down, - kp_home, - kp_end, - kp_insert, - kp_delete, - kp_begin, + // "Media Keys" § 3.6 + browser_back, + browser_favorites, + browser_forward, + browser_home, + browser_refresh, + browser_search, + browser_stop, + eject, + launch_app_1, + launch_app_2, + launch_mail, + media_play_pause, + media_select, + media_stop, + media_track_next, + media_track_previous, + power, + sleep, + audio_volume_down, + audio_volume_mute, + audio_volume_up, + wake_up, - // TODO: media keys - - // modifiers - left_shift, - left_control, - left_alt, - left_super, - right_shift, - right_control, - right_alt, - right_super, - - // To support more keys (there are obviously more!) add them here - // and ensure the mapping is up to date in the Window key handler. + // "Legacy, Non-standard, and Special Keys" § 3.7 + copy, + cut, + paste, /// Converts an ASCII character to a key, if possible. This returns /// null if the character is unknown. @@ -445,6 +488,74 @@ pub const Key = enum(c_int) { }; } + /// Converts a W3C key code to a Ghostty key enum value. + /// + /// All required W3C key codes are supported, but there are a number of + /// non-standard key codes that are not supported. In the case the value is + /// invalid or unsupported, this function will return null. + pub fn fromW3C(code: []const u8) ?Key { + var result: [128]u8 = undefined; + + // If the code is bigger than our buffer it can't possibly match. + if (code.len > result.len) return null; + + // First just check the whole thing lowercased, this is the simple case + if (std.meta.stringToEnum( + Key, + std.ascii.lowerString(&result, code), + )) |key| return key; + + // We need to convert FooBar to foo_bar + var fbs = std.io.fixedBufferStream(&result); + const w = fbs.writer(); + for (code, 0..) |ch, i| switch (ch) { + 'a'...'z' => w.writeByte(ch) catch return null, + + // Caps and numbers trigger underscores + 'A'...'Z', '0'...'9' => { + if (i > 0) w.writeByte('_') catch return null; + w.writeByte(std.ascii.toLower(ch)) catch return null; + }, + + // We don't know of any key codes that aren't alphanumeric. + else => return null, + }; + + return std.meta.stringToEnum(Key, fbs.getWritten()); + } + + /// Converts a Ghostty key enum value to a W3C key code. + pub fn w3c(self: Key) []const u8 { + return switch (self) { + inline else => |tag| comptime w3c: { + @setEvalBranchQuota(50_000); + + const name = @tagName(tag); + + var buf: [128]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const w = fbs.writer(); + var i: usize = 0; + while (i < name.len) { + if (i == 0) { + w.writeByte(std.ascii.toUpper(name[i])) catch unreachable; + } else if (name[i] == '_') { + i += 1; + w.writeByte(std.ascii.toUpper(name[i])) catch unreachable; + } else { + w.writeByte(name[i]) catch unreachable; + } + + i += 1; + } + + const written = buf; + const result = written[0..fbs.getWritten().len]; + break :w3c result; + }, + }; + } + /// True if this key represents a printable character. pub fn printable(self: Key) bool { return switch (self) { @@ -464,14 +575,14 @@ pub const Key = enum(c_int) { /// True if this key is a modifier. pub fn modifier(self: Key) bool { return switch (self) { - .left_shift, - .left_control, - .left_alt, - .left_super, - .right_shift, - .right_control, - .right_alt, - .right_super, + .shift_left, + .control_left, + .alt_left, + .meta_left, + .shift_right, + .control_right, + .alt_right, + .meta_right, => true, else => false, @@ -483,7 +594,7 @@ pub const Key = enum(c_int) { return switch (self) { inline else => |tag| { const name = @tagName(tag); - const result = comptime std.mem.startsWith(u8, name, "kp_"); + const result = comptime std.mem.startsWith(u8, name, "numpad_"); return result; }, }; @@ -509,61 +620,61 @@ pub const Key = enum(c_int) { /// Returns the cimgui key constant for this key. pub fn imguiKey(self: Key) ?c_uint { return switch (self) { - .a => cimgui.c.ImGuiKey_A, - .b => cimgui.c.ImGuiKey_B, - .c => cimgui.c.ImGuiKey_C, - .d => cimgui.c.ImGuiKey_D, - .e => cimgui.c.ImGuiKey_E, - .f => cimgui.c.ImGuiKey_F, - .g => cimgui.c.ImGuiKey_G, - .h => cimgui.c.ImGuiKey_H, - .i => cimgui.c.ImGuiKey_I, - .j => cimgui.c.ImGuiKey_J, - .k => cimgui.c.ImGuiKey_K, - .l => cimgui.c.ImGuiKey_L, - .m => cimgui.c.ImGuiKey_M, - .n => cimgui.c.ImGuiKey_N, - .o => cimgui.c.ImGuiKey_O, - .p => cimgui.c.ImGuiKey_P, - .q => cimgui.c.ImGuiKey_Q, - .r => cimgui.c.ImGuiKey_R, - .s => cimgui.c.ImGuiKey_S, - .t => cimgui.c.ImGuiKey_T, - .u => cimgui.c.ImGuiKey_U, - .v => cimgui.c.ImGuiKey_V, - .w => cimgui.c.ImGuiKey_W, - .x => cimgui.c.ImGuiKey_X, - .y => cimgui.c.ImGuiKey_Y, - .z => cimgui.c.ImGuiKey_Z, + .key_a => cimgui.c.ImGuiKey_A, + .key_b => cimgui.c.ImGuiKey_B, + .key_c => cimgui.c.ImGuiKey_C, + .key_d => cimgui.c.ImGuiKey_D, + .key_e => cimgui.c.ImGuiKey_E, + .key_f => cimgui.c.ImGuiKey_F, + .key_g => cimgui.c.ImGuiKey_G, + .key_h => cimgui.c.ImGuiKey_H, + .key_i => cimgui.c.ImGuiKey_I, + .key_j => cimgui.c.ImGuiKey_J, + .key_k => cimgui.c.ImGuiKey_K, + .key_l => cimgui.c.ImGuiKey_L, + .key_m => cimgui.c.ImGuiKey_M, + .key_n => cimgui.c.ImGuiKey_N, + .key_o => cimgui.c.ImGuiKey_O, + .key_p => cimgui.c.ImGuiKey_P, + .key_q => cimgui.c.ImGuiKey_Q, + .key_r => cimgui.c.ImGuiKey_R, + .key_s => cimgui.c.ImGuiKey_S, + .key_t => cimgui.c.ImGuiKey_T, + .key_u => cimgui.c.ImGuiKey_U, + .key_v => cimgui.c.ImGuiKey_V, + .key_w => cimgui.c.ImGuiKey_W, + .key_x => cimgui.c.ImGuiKey_X, + .key_y => cimgui.c.ImGuiKey_Y, + .key_z => cimgui.c.ImGuiKey_Z, - .zero => cimgui.c.ImGuiKey_0, - .one => cimgui.c.ImGuiKey_1, - .two => cimgui.c.ImGuiKey_2, - .three => cimgui.c.ImGuiKey_3, - .four => cimgui.c.ImGuiKey_4, - .five => cimgui.c.ImGuiKey_5, - .six => cimgui.c.ImGuiKey_6, - .seven => cimgui.c.ImGuiKey_7, - .eight => cimgui.c.ImGuiKey_8, - .nine => cimgui.c.ImGuiKey_9, + .digit_0 => cimgui.c.ImGuiKey_0, + .digit_1 => cimgui.c.ImGuiKey_1, + .digit_2 => cimgui.c.ImGuiKey_2, + .digit_3 => cimgui.c.ImGuiKey_3, + .digit_4 => cimgui.c.ImGuiKey_4, + .digit_5 => cimgui.c.ImGuiKey_5, + .digit_6 => cimgui.c.ImGuiKey_6, + .digit_7 => cimgui.c.ImGuiKey_7, + .digit_8 => cimgui.c.ImGuiKey_8, + .digit_9 => cimgui.c.ImGuiKey_9, .semicolon => cimgui.c.ImGuiKey_Semicolon, .space => cimgui.c.ImGuiKey_Space, - .apostrophe => cimgui.c.ImGuiKey_Apostrophe, + .quote => cimgui.c.ImGuiKey_Apostrophe, .comma => cimgui.c.ImGuiKey_Comma, - .grave_accent => cimgui.c.ImGuiKey_GraveAccent, + .backquote => cimgui.c.ImGuiKey_GraveAccent, .period => cimgui.c.ImGuiKey_Period, .slash => cimgui.c.ImGuiKey_Slash, .minus => cimgui.c.ImGuiKey_Minus, .equal => cimgui.c.ImGuiKey_Equal, - .left_bracket => cimgui.c.ImGuiKey_LeftBracket, - .right_bracket => cimgui.c.ImGuiKey_RightBracket, + .bracket_left => cimgui.c.ImGuiKey_LeftBracket, + .bracket_right => cimgui.c.ImGuiKey_RightBracket, .backslash => cimgui.c.ImGuiKey_Backslash, - .up => cimgui.c.ImGuiKey_UpArrow, - .down => cimgui.c.ImGuiKey_DownArrow, - .left => cimgui.c.ImGuiKey_LeftArrow, - .right => cimgui.c.ImGuiKey_RightArrow, + .arrow_up => cimgui.c.ImGuiKey_UpArrow, + .arrow_down => cimgui.c.ImGuiKey_DownArrow, + .arrow_left => cimgui.c.ImGuiKey_LeftArrow, + .arrow_right => cimgui.c.ImGuiKey_RightArrow, .home => cimgui.c.ImGuiKey_Home, .end => cimgui.c.ImGuiKey_End, .insert => cimgui.c.ImGuiKey_Insert, @@ -579,6 +690,7 @@ pub const Key = enum(c_int) { .backspace => cimgui.c.ImGuiKey_Backspace, .print_screen => cimgui.c.ImGuiKey_PrintScreen, .pause => cimgui.c.ImGuiKey_Pause, + .context_menu => cimgui.c.ImGuiKey_Menu, .f1 => cimgui.c.ImGuiKey_F1, .f2 => cimgui.c.ImGuiKey_F2, @@ -593,48 +705,47 @@ pub const Key = enum(c_int) { .f11 => cimgui.c.ImGuiKey_F11, .f12 => cimgui.c.ImGuiKey_F12, - .kp_0 => cimgui.c.ImGuiKey_Keypad0, - .kp_1 => cimgui.c.ImGuiKey_Keypad1, - .kp_2 => cimgui.c.ImGuiKey_Keypad2, - .kp_3 => cimgui.c.ImGuiKey_Keypad3, - .kp_4 => cimgui.c.ImGuiKey_Keypad4, - .kp_5 => cimgui.c.ImGuiKey_Keypad5, - .kp_6 => cimgui.c.ImGuiKey_Keypad6, - .kp_7 => cimgui.c.ImGuiKey_Keypad7, - .kp_8 => cimgui.c.ImGuiKey_Keypad8, - .kp_9 => cimgui.c.ImGuiKey_Keypad9, - .kp_decimal => cimgui.c.ImGuiKey_KeypadDecimal, - .kp_divide => cimgui.c.ImGuiKey_KeypadDivide, - .kp_multiply => cimgui.c.ImGuiKey_KeypadMultiply, - .kp_subtract => cimgui.c.ImGuiKey_KeypadSubtract, - .kp_add => cimgui.c.ImGuiKey_KeypadAdd, - .kp_enter => cimgui.c.ImGuiKey_KeypadEnter, - .kp_equal => cimgui.c.ImGuiKey_KeypadEqual, + .numpad_0 => cimgui.c.ImGuiKey_Keypad0, + .numpad_1 => cimgui.c.ImGuiKey_Keypad1, + .numpad_2 => cimgui.c.ImGuiKey_Keypad2, + .numpad_3 => cimgui.c.ImGuiKey_Keypad3, + .numpad_4 => cimgui.c.ImGuiKey_Keypad4, + .numpad_5 => cimgui.c.ImGuiKey_Keypad5, + .numpad_6 => cimgui.c.ImGuiKey_Keypad6, + .numpad_7 => cimgui.c.ImGuiKey_Keypad7, + .numpad_8 => cimgui.c.ImGuiKey_Keypad8, + .numpad_9 => cimgui.c.ImGuiKey_Keypad9, + .numpad_decimal => cimgui.c.ImGuiKey_KeypadDecimal, + .numpad_divide => cimgui.c.ImGuiKey_KeypadDivide, + .numpad_multiply => cimgui.c.ImGuiKey_KeypadMultiply, + .numpad_subtract => cimgui.c.ImGuiKey_KeypadSubtract, + .numpad_add => cimgui.c.ImGuiKey_KeypadAdd, + .numpad_enter => cimgui.c.ImGuiKey_KeypadEnter, + .numpad_equal => cimgui.c.ImGuiKey_KeypadEqual, // We map KP_SEPARATOR to Comma because traditionally a numpad would // have a numeric separator key. Most modern numpads do not - .kp_separator => cimgui.c.ImGuiKey_Comma, - .kp_left => cimgui.c.ImGuiKey_LeftArrow, - .kp_right => cimgui.c.ImGuiKey_RightArrow, - .kp_up => cimgui.c.ImGuiKey_UpArrow, - .kp_down => cimgui.c.ImGuiKey_DownArrow, - .kp_page_up => cimgui.c.ImGuiKey_PageUp, - .kp_page_down => cimgui.c.ImGuiKey_PageUp, - .kp_home => cimgui.c.ImGuiKey_Home, - .kp_end => cimgui.c.ImGuiKey_End, - .kp_insert => cimgui.c.ImGuiKey_Insert, - .kp_delete => cimgui.c.ImGuiKey_Delete, - .kp_begin => cimgui.c.ImGuiKey_NamedKey_BEGIN, + .numpad_left => cimgui.c.ImGuiKey_LeftArrow, + .numpad_right => cimgui.c.ImGuiKey_RightArrow, + .numpad_up => cimgui.c.ImGuiKey_UpArrow, + .numpad_down => cimgui.c.ImGuiKey_DownArrow, + .numpad_page_up => cimgui.c.ImGuiKey_PageUp, + .numpad_page_down => cimgui.c.ImGuiKey_PageUp, + .numpad_home => cimgui.c.ImGuiKey_Home, + .numpad_end => cimgui.c.ImGuiKey_End, + .numpad_insert => cimgui.c.ImGuiKey_Insert, + .numpad_delete => cimgui.c.ImGuiKey_Delete, + .numpad_begin => cimgui.c.ImGuiKey_NamedKey_BEGIN, - .left_shift => cimgui.c.ImGuiKey_LeftShift, - .left_control => cimgui.c.ImGuiKey_LeftCtrl, - .left_alt => cimgui.c.ImGuiKey_LeftAlt, - .left_super => cimgui.c.ImGuiKey_LeftSuper, - .right_shift => cimgui.c.ImGuiKey_RightShift, - .right_control => cimgui.c.ImGuiKey_RightCtrl, - .right_alt => cimgui.c.ImGuiKey_RightAlt, - .right_super => cimgui.c.ImGuiKey_RightSuper, + .shift_left => cimgui.c.ImGuiKey_LeftShift, + .control_left => cimgui.c.ImGuiKey_LeftCtrl, + .alt_left => cimgui.c.ImGuiKey_LeftAlt, + .meta_left => cimgui.c.ImGuiKey_LeftSuper, + .shift_right => cimgui.c.ImGuiKey_RightShift, + .control_right => cimgui.c.ImGuiKey_RightCtrl, + .alt_right => cimgui.c.ImGuiKey_RightAlt, + .meta_right => cimgui.c.ImGuiKey_RightSuper, - .invalid, + // These keys aren't represented in cimgui .f13, .f14, .f15, @@ -648,9 +759,55 @@ pub const Key = enum(c_int) { .f23, .f24, .f25, + .intl_backslash, + .intl_ro, + .intl_yen, + .convert, + .kana_mode, + .non_convert, + .numpad_separator, + .numpad_backspace, + .numpad_clear, + .numpad_clear_entry, + .numpad_comma, + .numpad_memory_add, + .numpad_memory_clear, + .numpad_memory_recall, + .numpad_memory_store, + .numpad_memory_subtract, + .numpad_paren_left, + .numpad_paren_right, + .@"fn", + .fn_lock, + .browser_back, + .browser_favorites, + .browser_forward, + .browser_home, + .browser_refresh, + .browser_search, + .browser_stop, + .eject, + .launch_app_1, + .launch_app_2, + .launch_mail, + .media_play_pause, + .media_select, + .media_stop, + .media_track_next, + .media_track_previous, + .power, + .sleep, + .audio_volume_down, + .audio_volume_mute, + .audio_volume_up, + .wake_up, + .help, + .copy, + .cut, + .paste, + => null, - // These keys aren't represented in cimgui - .plus, + .unidentified, => null, }; } @@ -659,107 +816,118 @@ pub const Key = enum(c_int) { /// or ctrl. pub fn ctrlOrSuper(self: Key) bool { if (comptime builtin.target.os.tag.isDarwin()) { - return self == .left_super or self == .right_super; + return self == .meta_left or self == .meta_right; } - return self == .left_control or self == .right_control; + return self == .control_left or self == .control_right; } /// true if this key is either left or right shift. pub fn leftOrRightShift(self: Key) bool { - return self == .left_shift or self == .right_shift; + return self == .shift_left or self == .shift_right; } /// true if this key is either left or right alt. pub fn leftOrRightAlt(self: Key) bool { - return self == .left_alt or self == .right_alt; + return self == .alt_left or self == .alt_right; } test "fromASCII should not return keypad keys" { const testing = std.testing; - try testing.expect(Key.fromASCII('0').? == .zero); + try testing.expect(Key.fromASCII('0').? == .digit_0); try testing.expect(Key.fromASCII('*') == null); } test "keypad keys" { const testing = std.testing; - try testing.expect(Key.kp_0.keypad()); - try testing.expect(!Key.one.keypad()); + try testing.expect(Key.numpad_0.keypad()); + try testing.expect(!Key.digit_1.keypad()); + } + + test "w3c" { + // All our keys should convert to and from the W3C format. + // We don't support every key in the W3C spec, so we only + // check the enum fields. + const testing = std.testing; + inline for (@typeInfo(Key).@"enum".fields) |field| { + const key = @field(Key, field.name); + const w3c_name = key.w3c(); + try testing.expectEqual(key, Key.fromW3C(w3c_name).?); + } } const codepoint_map: []const struct { u21, Key } = &.{ - .{ 'a', .a }, - .{ 'b', .b }, - .{ 'c', .c }, - .{ 'd', .d }, - .{ 'e', .e }, - .{ 'f', .f }, - .{ 'g', .g }, - .{ 'h', .h }, - .{ 'i', .i }, - .{ 'j', .j }, - .{ 'k', .k }, - .{ 'l', .l }, - .{ 'm', .m }, - .{ 'n', .n }, - .{ 'o', .o }, - .{ 'p', .p }, - .{ 'q', .q }, - .{ 'r', .r }, - .{ 's', .s }, - .{ 't', .t }, - .{ 'u', .u }, - .{ 'v', .v }, - .{ 'w', .w }, - .{ 'x', .x }, - .{ 'y', .y }, - .{ 'z', .z }, - .{ '0', .zero }, - .{ '1', .one }, - .{ '2', .two }, - .{ '3', .three }, - .{ '4', .four }, - .{ '5', .five }, - .{ '6', .six }, - .{ '7', .seven }, - .{ '8', .eight }, - .{ '9', .nine }, + .{ 'a', .key_a }, + .{ 'b', .key_b }, + .{ 'c', .key_c }, + .{ 'd', .key_d }, + .{ 'e', .key_e }, + .{ 'f', .key_f }, + .{ 'g', .key_g }, + .{ 'h', .key_h }, + .{ 'i', .key_i }, + .{ 'j', .key_j }, + .{ 'k', .key_k }, + .{ 'l', .key_l }, + .{ 'm', .key_m }, + .{ 'n', .key_n }, + .{ 'o', .key_o }, + .{ 'p', .key_p }, + .{ 'q', .key_q }, + .{ 'r', .key_r }, + .{ 's', .key_s }, + .{ 't', .key_t }, + .{ 'u', .key_u }, + .{ 'v', .key_v }, + .{ 'w', .key_w }, + .{ 'x', .key_x }, + .{ 'y', .key_y }, + .{ 'z', .key_z }, + .{ '0', .digit_0 }, + .{ '1', .digit_1 }, + .{ '2', .digit_2 }, + .{ '3', .digit_3 }, + .{ '4', .digit_4 }, + .{ '5', .digit_5 }, + .{ '6', .digit_6 }, + .{ '7', .digit_7 }, + .{ '8', .digit_8 }, + .{ '9', .digit_9 }, .{ ';', .semicolon }, .{ ' ', .space }, - .{ '\'', .apostrophe }, + .{ '\'', .quote }, .{ ',', .comma }, - .{ '`', .grave_accent }, + .{ '`', .backquote }, .{ '.', .period }, .{ '/', .slash }, .{ '-', .minus }, - .{ '+', .plus }, .{ '=', .equal }, - .{ '[', .left_bracket }, - .{ ']', .right_bracket }, + .{ '[', .bracket_left }, + .{ ']', .bracket_right }, .{ '\\', .backslash }, // Control characters .{ '\t', .tab }, - // Keypad entries. We just assume keypad with the kp_ prefix + // Keypad entries. We just assume keypad with the numpad_ prefix // so that has some special meaning. These must also always be last, // so that our `fromASCII` function doesn't accidentally map them // over normal numerics and other keys. - .{ '0', .kp_0 }, - .{ '1', .kp_1 }, - .{ '2', .kp_2 }, - .{ '3', .kp_3 }, - .{ '4', .kp_4 }, - .{ '5', .kp_5 }, - .{ '6', .kp_6 }, - .{ '7', .kp_7 }, - .{ '8', .kp_8 }, - .{ '9', .kp_9 }, - .{ '.', .kp_decimal }, - .{ '/', .kp_divide }, - .{ '*', .kp_multiply }, - .{ '-', .kp_subtract }, - .{ '+', .kp_add }, - .{ '=', .kp_equal }, + .{ '0', .numpad_0 }, + .{ '1', .numpad_1 }, + .{ '2', .numpad_2 }, + .{ '3', .numpad_3 }, + .{ '4', .numpad_4 }, + .{ '5', .numpad_5 }, + .{ '6', .numpad_6 }, + .{ '7', .numpad_7 }, + .{ '8', .numpad_8 }, + .{ '9', .numpad_9 }, + .{ '.', .numpad_decimal }, + .{ '/', .numpad_divide }, + .{ '*', .numpad_multiply }, + .{ '-', .numpad_subtract }, + .{ '+', .numpad_add }, + .{ '=', .numpad_equal }, }; }; diff --git a/src/input/keycodes.zig b/src/input/keycodes.zig index 67ce46daf..a85f36d31 100644 --- a/src/input/keycodes.zig +++ b/src/input/keycodes.zig @@ -19,7 +19,7 @@ pub const entries: []const Entry = entries: { for (raw_entries, 0..) |raw, i| { @setEvalBranchQuota(10000); result[i] = .{ - .key = code_to_key.get(raw[5]) orelse .invalid, + .key = code_to_key.get(raw[5]) orelse .unidentified, .usb = raw[0], .code = raw[5], .native = raw[native_idx], @@ -45,42 +45,42 @@ pub const Entry = struct { const code_to_key = code_to_key: { @setEvalBranchQuota(5000); break :code_to_key std.StaticStringMap(Key).initComptime(.{ - .{ "KeyA", .a }, - .{ "KeyB", .b }, - .{ "KeyC", .c }, - .{ "KeyD", .d }, - .{ "KeyE", .e }, - .{ "KeyF", .f }, - .{ "KeyG", .g }, - .{ "KeyH", .h }, - .{ "KeyI", .i }, - .{ "KeyJ", .j }, - .{ "KeyK", .k }, - .{ "KeyL", .l }, - .{ "KeyM", .m }, - .{ "KeyN", .n }, - .{ "KeyO", .o }, - .{ "KeyP", .p }, - .{ "KeyQ", .q }, - .{ "KeyR", .r }, - .{ "KeyS", .s }, - .{ "KeyT", .t }, - .{ "KeyU", .u }, - .{ "KeyV", .v }, - .{ "KeyW", .w }, - .{ "KeyX", .x }, - .{ "KeyY", .y }, - .{ "KeyZ", .z }, - .{ "Digit1", .one }, - .{ "Digit2", .two }, - .{ "Digit3", .three }, - .{ "Digit4", .four }, - .{ "Digit5", .five }, - .{ "Digit6", .six }, - .{ "Digit7", .seven }, - .{ "Digit8", .eight }, - .{ "Digit9", .nine }, - .{ "Digit0", .zero }, + .{ "KeyA", .key_a }, + .{ "KeyB", .key_b }, + .{ "KeyC", .key_c }, + .{ "KeyD", .key_d }, + .{ "KeyE", .key_e }, + .{ "KeyF", .key_f }, + .{ "KeyG", .key_g }, + .{ "KeyH", .key_h }, + .{ "KeyI", .key_i }, + .{ "KeyJ", .key_j }, + .{ "KeyK", .key_k }, + .{ "KeyL", .key_l }, + .{ "KeyM", .key_m }, + .{ "KeyN", .key_n }, + .{ "KeyO", .key_o }, + .{ "KeyP", .key_p }, + .{ "KeyQ", .key_q }, + .{ "KeyR", .key_r }, + .{ "KeyS", .key_s }, + .{ "KeyT", .key_t }, + .{ "KeyU", .key_u }, + .{ "KeyV", .key_v }, + .{ "KeyW", .key_w }, + .{ "KeyX", .key_x }, + .{ "KeyY", .key_y }, + .{ "KeyZ", .key_z }, + .{ "Digit1", .digit_1 }, + .{ "Digit2", .digit_2 }, + .{ "Digit3", .digit_3 }, + .{ "Digit4", .digit_4 }, + .{ "Digit5", .digit_5 }, + .{ "Digit6", .digit_6 }, + .{ "Digit7", .digit_7 }, + .{ "Digit8", .digit_8 }, + .{ "Digit9", .digit_9 }, + .{ "Digit0", .digit_0 }, .{ "Enter", .enter }, .{ "Escape", .escape }, .{ "Backspace", .backspace }, @@ -88,12 +88,12 @@ const code_to_key = code_to_key: { .{ "Space", .space }, .{ "Minus", .minus }, .{ "Equal", .equal }, - .{ "BracketLeft", .left_bracket }, - .{ "BracketRight", .right_bracket }, + .{ "BracketLeft", .bracket_left }, + .{ "BracketRight", .bracket_right }, .{ "Backslash", .backslash }, .{ "Semicolon", .semicolon }, - .{ "Quote", .apostrophe }, - .{ "Backquote", .grave_accent }, + .{ "Quote", .quote }, + .{ "Backquote", .backquote }, .{ "Comma", .comma }, .{ "Period", .period }, .{ "Slash", .slash }, @@ -130,37 +130,41 @@ const code_to_key = code_to_key: { .{ "PageUp", .page_up }, .{ "Delete", .delete }, .{ "End", .end }, + .{ "Copy", .copy }, + .{ "Cut", .cut }, + .{ "Paste", .paste }, .{ "PageDown", .page_down }, - .{ "ArrowRight", .right }, - .{ "ArrowLeft", .left }, - .{ "ArrowDown", .down }, - .{ "ArrowUp", .up }, + .{ "ArrowRight", .arrow_right }, + .{ "ArrowLeft", .arrow_left }, + .{ "ArrowDown", .arrow_down }, + .{ "ArrowUp", .arrow_up }, .{ "NumLock", .num_lock }, - .{ "NumpadDivide", .kp_divide }, - .{ "NumpadMultiply", .kp_multiply }, - .{ "NumpadSubtract", .kp_subtract }, - .{ "NumpadAdd", .kp_add }, - .{ "NumpadEnter", .kp_enter }, - .{ "Numpad1", .kp_1 }, - .{ "Numpad2", .kp_2 }, - .{ "Numpad3", .kp_3 }, - .{ "Numpad4", .kp_4 }, - .{ "Numpad5", .kp_5 }, - .{ "Numpad6", .kp_6 }, - .{ "Numpad7", .kp_7 }, - .{ "Numpad8", .kp_8 }, - .{ "Numpad9", .kp_9 }, - .{ "Numpad0", .kp_0 }, - .{ "NumpadDecimal", .kp_decimal }, - .{ "NumpadEqual", .kp_equal }, - .{ "ControlLeft", .left_control }, - .{ "ShiftLeft", .left_shift }, - .{ "AltLeft", .left_alt }, - .{ "MetaLeft", .left_super }, - .{ "ControlRight", .right_control }, - .{ "ShiftRight", .right_shift }, - .{ "AltRight", .right_alt }, - .{ "MetaRight", .right_super }, + .{ "NumpadDivide", .numpad_divide }, + .{ "NumpadMultiply", .numpad_multiply }, + .{ "NumpadSubtract", .numpad_subtract }, + .{ "NumpadAdd", .numpad_add }, + .{ "NumpadEnter", .numpad_enter }, + .{ "Numpad1", .numpad_1 }, + .{ "Numpad2", .numpad_2 }, + .{ "Numpad3", .numpad_3 }, + .{ "Numpad4", .numpad_4 }, + .{ "Numpad5", .numpad_5 }, + .{ "Numpad6", .numpad_6 }, + .{ "Numpad7", .numpad_7 }, + .{ "Numpad8", .numpad_8 }, + .{ "Numpad9", .numpad_9 }, + .{ "Numpad0", .numpad_0 }, + .{ "NumpadDecimal", .numpad_decimal }, + .{ "NumpadEqual", .numpad_equal }, + .{ "ContextMenu", .context_menu }, + .{ "ControlLeft", .control_left }, + .{ "ShiftLeft", .shift_left }, + .{ "AltLeft", .alt_left }, + .{ "MetaLeft", .meta_left }, + .{ "ControlRight", .control_right }, + .{ "ShiftRight", .shift_right }, + .{ "AltRight", .alt_right }, + .{ "MetaRight", .meta_right }, }); }; diff --git a/src/input/kitty.zig b/src/input/kitty.zig index 6e9cdddf8..7ebbd7757 100644 --- a/src/input/kitty.zig +++ b/src/input/kitty.zig @@ -49,10 +49,10 @@ const raw_entries: []const RawEntry = &.{ .{ .backspace, 127, 'u', false }, .{ .insert, 2, '~', false }, .{ .delete, 3, '~', false }, - .{ .left, 1, 'D', false }, - .{ .right, 1, 'C', false }, - .{ .up, 1, 'A', false }, - .{ .down, 1, 'B', false }, + .{ .arrow_left, 1, 'D', false }, + .{ .arrow_right, 1, 'C', false }, + .{ .arrow_up, 1, 'A', false }, + .{ .arrow_down, 1, 'B', false }, .{ .page_up, 5, '~', false }, .{ .page_down, 6, '~', false }, .{ .home, 1, 'H', false }, @@ -89,46 +89,44 @@ const raw_entries: []const RawEntry = &.{ .{ .f24, 57387, 'u', false }, .{ .f25, 57388, 'u', false }, - .{ .kp_0, 57399, 'u', false }, - .{ .kp_1, 57400, 'u', false }, - .{ .kp_2, 57401, 'u', false }, - .{ .kp_3, 57402, 'u', false }, - .{ .kp_4, 57403, 'u', false }, - .{ .kp_5, 57404, 'u', false }, - .{ .kp_6, 57405, 'u', false }, - .{ .kp_7, 57406, 'u', false }, - .{ .kp_8, 57407, 'u', false }, - .{ .kp_9, 57408, 'u', false }, - .{ .kp_decimal, 57409, 'u', false }, - .{ .kp_divide, 57410, 'u', false }, - .{ .kp_multiply, 57411, 'u', false }, - .{ .kp_subtract, 57412, 'u', false }, - .{ .kp_add, 57413, 'u', false }, - .{ .kp_enter, 57414, 'u', false }, - .{ .kp_equal, 57415, 'u', false }, - .{ .kp_separator, 57416, 'u', false }, - .{ .kp_left, 57417, 'u', false }, - .{ .kp_right, 57418, 'u', false }, - .{ .kp_up, 57419, 'u', false }, - .{ .kp_down, 57420, 'u', false }, - .{ .kp_page_up, 57421, 'u', false }, - .{ .kp_page_down, 57422, 'u', false }, - .{ .kp_home, 57423, 'u', false }, - .{ .kp_end, 57424, 'u', false }, - .{ .kp_insert, 57425, 'u', false }, - .{ .kp_delete, 57426, 'u', false }, - .{ .kp_begin, 57427, 'u', false }, + .{ .numpad_0, 57399, 'u', false }, + .{ .numpad_1, 57400, 'u', false }, + .{ .numpad_2, 57401, 'u', false }, + .{ .numpad_3, 57402, 'u', false }, + .{ .numpad_4, 57403, 'u', false }, + .{ .numpad_5, 57404, 'u', false }, + .{ .numpad_6, 57405, 'u', false }, + .{ .numpad_7, 57406, 'u', false }, + .{ .numpad_8, 57407, 'u', false }, + .{ .numpad_9, 57408, 'u', false }, + .{ .numpad_decimal, 57409, 'u', false }, + .{ .numpad_divide, 57410, 'u', false }, + .{ .numpad_multiply, 57411, 'u', false }, + .{ .numpad_subtract, 57412, 'u', false }, + .{ .numpad_add, 57413, 'u', false }, + .{ .numpad_enter, 57414, 'u', false }, + .{ .numpad_equal, 57415, 'u', false }, + .{ .numpad_separator, 57416, 'u', false }, + .{ .numpad_left, 57417, 'u', false }, + .{ .numpad_right, 57418, 'u', false }, + .{ .numpad_up, 57419, 'u', false }, + .{ .numpad_down, 57420, 'u', false }, + .{ .numpad_page_up, 57421, 'u', false }, + .{ .numpad_page_down, 57422, 'u', false }, + .{ .numpad_home, 57423, 'u', false }, + .{ .numpad_end, 57424, 'u', false }, + .{ .numpad_insert, 57425, 'u', false }, + .{ .numpad_delete, 57426, 'u', false }, + .{ .numpad_begin, 57427, 'u', false }, - // TODO: media keys - - .{ .left_shift, 57441, 'u', true }, - .{ .right_shift, 57447, 'u', true }, - .{ .left_control, 57442, 'u', true }, - .{ .right_control, 57448, 'u', true }, - .{ .left_super, 57444, 'u', true }, - .{ .right_super, 57450, 'u', true }, - .{ .left_alt, 57443, 'u', true }, - .{ .right_alt, 57449, 'u', true }, + .{ .shift_left, 57441, 'u', true }, + .{ .shift_right, 57447, 'u', true }, + .{ .control_left, 57442, 'u', true }, + .{ .control_right, 57448, 'u', true }, + .{ .meta_left, 57444, 'u', true }, + .{ .meta_right, 57450, 'u', true }, + .{ .alt_left, 57443, 'u', true }, + .{ .alt_right, 57449, 'u', true }, }; test { diff --git a/src/inspector/key.zig b/src/inspector/key.zig index e28bd5d4a..dbccb47a8 100644 --- a/src/inspector/key.zig +++ b/src/inspector/key.zig @@ -56,7 +56,7 @@ pub const Event = struct { // Write our key. If we have an invalid key we attempt to write // the utf8 associated with it if we have it to handle non-ascii. try writer.writeAll(switch (self.event.key) { - .invalid => if (self.event.utf8.len > 0) self.event.utf8 else @tagName(.invalid), + .unidentified => if (self.event.utf8.len > 0) self.event.utf8 else @tagName(self.event.key), else => @tagName(self.event.key), }); @@ -117,13 +117,6 @@ pub const Event = struct { _ = cimgui.c.igTableSetColumnIndex(1); cimgui.c.igText("%s", @tagName(self.event.key).ptr); } - if (self.event.physical_key != self.event.key) { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Physical Key"); - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", @tagName(self.event.physical_key).ptr); - } if (!self.event.mods.empty()) { cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); _ = cimgui.c.igTableSetColumnIndex(0); @@ -227,9 +220,9 @@ test "event string" { const testing = std.testing; const alloc = testing.allocator; - var event = try Event.init(alloc, .{ .key = .a }); + var event = try Event.init(alloc, .{ .key = .key_a }); defer event.deinit(alloc); var buf: [1024]u8 = undefined; - try testing.expectEqualStrings("Press: a", try event.label(&buf)); + try testing.expectEqualStrings("Press: key_a", try event.label(&buf)); } diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 6aa6628ab..5ab9d3cd4 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -308,7 +308,7 @@ pub const VTHandler = struct { current_seq: usize = 1, /// Exclude certain actions by tag. - filter_exclude: ActionTagSet = ActionTagSet.initMany(&.{.print}), + filter_exclude: ActionTagSet = .initMany(&.{.print}), filter_text: *cimgui.c.ImGuiTextFilter, const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag); diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 6a4688dc7..985c6c9bd 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -188,5 +188,6 @@ test { _ = @import("terminal/main.zig"); _ = @import("terminfo/main.zig"); _ = @import("simd/main.zig"); + _ = @import("synthetic/main.zig"); _ = @import("unicode/main.zig"); } diff --git a/src/os/args.zig b/src/os/args.zig index 9f7401c94..a531a418b 100644 --- a/src/os/args.zig +++ b/src/os/args.zig @@ -12,7 +12,7 @@ const macos = @import("macos"); /// but handles macOS using NSProcessInfo instead of libc argc/argv. pub fn iterator(allocator: Allocator) ArgIterator.InitError!ArgIterator { //if (true) return try std.process.argsWithAllocator(allocator); - return ArgIterator.initWithAllocator(allocator); + return .initWithAllocator(allocator); } /// Duck-typed to std.process.ArgIterator diff --git a/src/os/cgroup.zig b/src/os/cgroup.zig index 5645e337a..4f13921c5 100644 --- a/src/os/cgroup.zig +++ b/src/os/cgroup.zig @@ -56,6 +56,25 @@ pub fn create( } } +/// Remove a cgroup. This will only succeed if the cgroup is empty +/// (has no processes). The cgroup path should be relative to the +/// cgroup root (e.g. "/user.slice/surfaces/abc123.scope"). +pub fn remove(cgroup: []const u8) !void { + assert(cgroup.len > 0); + assert(cgroup[0] == '/'); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try std.fmt.bufPrint(&buf, "/sys/fs/cgroup{s}", .{cgroup}); + std.fs.cwd().deleteDir(path) catch |err| switch (err) { + // If it doesn't exist, that's fine - maybe it was already cleaned up + error.FileNotFound => {}, + + // Any other error we failed to delete it so we want to notify + // the user. + else => return err, + }; +} + /// Move the given PID into the given cgroup. pub fn moveInto( cgroup: []const u8, diff --git a/src/os/dbus.zig b/src/os/dbus.zig new file mode 100644 index 000000000..99824db71 --- /dev/null +++ b/src/os/dbus.zig @@ -0,0 +1,21 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +/// Returns true if the program was launched by D-Bus activation. +/// +/// On Linux GTK, this returns true if the program was launched using D-Bus +/// activation. It will return false if Ghostty was launched any other way. +/// +/// For other platforms and app runtimes, this returns false. +pub fn launchedByDbusActivation() bool { + return switch (builtin.os.tag) { + // On Linux, D-Bus activation sets `DBUS_STARTER_ADDRESS` and + // `DBUS_STARTER_BUS_TYPE`. If these environment variables are present + // (no matter the value) we were launched by D-Bus activation. + .linux => std.posix.getenv("DBUS_STARTER_ADDRESS") != null and + std.posix.getenv("DBUS_STARTER_BUS_TYPE") != null, + + // No other system supports D-Bus so always return false. + else => false, + }; +} diff --git a/src/os/flatpak.zig b/src/os/flatpak.zig index 7b92a8ba9..7bd84bc27 100644 --- a/src/os/flatpak.zig +++ b/src/os/flatpak.zig @@ -112,6 +112,8 @@ pub const FlatpakHostCommand = struct { pub fn spawn(self: *FlatpakHostCommand, alloc: Allocator) !u32 { const thread = try std.Thread.spawn(.{}, threadMain, .{ self, alloc }); thread.setName("flatpak-host-command") catch {}; + // We don't track this thread, it will terminate on its own on command exit + thread.detach(); // Wait for the process to start or error. self.state_mutex.lock(); @@ -232,9 +234,10 @@ pub const FlatpakHostCommand = struct { }; // Get our bus connection. - var g_err: [*c]c.GError = null; + var g_err: ?*c.GError = null; + defer if (g_err) |ptr| c.g_error_free(ptr); const bus = c.g_bus_get_sync(c.G_BUS_TYPE_SESSION, null, &g_err) orelse { - log.warn("signal error getting bus: {s}", .{g_err.*.message}); + log.warn("signal error getting bus: {s}", .{g_err.?.*.message}); return Error.FlatpakSetupFail; }; defer c.g_object_unref(bus); @@ -258,7 +261,7 @@ pub const FlatpakHostCommand = struct { &g_err, ); if (g_err != null) { - log.warn("signal send error: {s}", .{g_err.*.message}); + log.warn("signal send error: {s}", .{g_err.?.*.message}); return; } defer c.g_variant_unref(reply); @@ -278,9 +281,10 @@ pub const FlatpakHostCommand = struct { // Get our bus connection. This has to remain active until we exit // the thread otherwise our signals won't be called. - var g_err: [*c]c.GError = null; + var g_err: ?*c.GError = null; + defer if (g_err) |ptr| c.g_error_free(ptr); const bus = c.g_bus_get_sync(c.G_BUS_TYPE_SESSION, null, &g_err) orelse { - log.warn("spawn error getting bus: {s}", .{g_err.*.message}); + log.warn("spawn error getting bus: {s}", .{g_err.?.*.message}); self.updateState(.{ .err = {} }); return; }; @@ -308,7 +312,8 @@ pub const FlatpakHostCommand = struct { bus: *c.GDBusConnection, loop: *c.GMainLoop, ) !void { - var err: [*c]c.GError = null; + var err: ?*c.GError = null; + defer if (err) |ptr| c.g_error_free(ptr); var arena_allocator = std.heap.ArenaAllocator.init(alloc); defer arena_allocator.deinit(); const arena = arena_allocator.allocator(); @@ -317,15 +322,15 @@ pub const FlatpakHostCommand = struct { const fd_list = c.g_unix_fd_list_new(); defer c.g_object_unref(fd_list); if (c.g_unix_fd_list_append(fd_list, self.stdin, &err) < 0) { - log.warn("error adding fd: {s}", .{err.*.message}); + log.warn("error adding fd: {s}", .{err.?.*.message}); return Error.FlatpakSetupFail; } if (c.g_unix_fd_list_append(fd_list, self.stdout, &err) < 0) { - log.warn("error adding fd: {s}", .{err.*.message}); + log.warn("error adding fd: {s}", .{err.?.*.message}); return Error.FlatpakSetupFail; } if (c.g_unix_fd_list_append(fd_list, self.stderr, &err) < 0) { - log.warn("error adding fd: {s}", .{err.*.message}); + log.warn("error adding fd: {s}", .{err.?.*.message}); return Error.FlatpakSetupFail; } @@ -405,7 +410,7 @@ pub const FlatpakHostCommand = struct { null, &err, ) orelse { - log.warn("Flatpak.HostCommand failed: {s}", .{err.*.message}); + log.warn("Flatpak.HostCommand failed: {s}", .{err.?.*.message}); return Error.FlatpakRPCFail; }; defer c.g_variant_unref(reply); diff --git a/src/os/locale.zig b/src/os/locale.zig index 17e4d163c..b391d690f 100644 --- a/src/os/locale.zig +++ b/src/os/locale.zig @@ -108,11 +108,8 @@ fn setLangFromCocoa() void { } // Get our preferred languages and set that to the LANGUAGE - // env var in case our language differs from our locale. We only - // do this when the app is launched from the desktop because then - // we're in an app bundle and we are expected to read from our - // Bundle's preferred languages. - if (internal_os.launchedFromDesktop()) language: { + // env var in case our language differs from our locale. + language: { var buf: [1024]u8 = undefined; const pref_ = preferredLanguageFromCocoa( &buf, diff --git a/src/os/main.zig b/src/os/main.zig index 36833f427..582ac75cd 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -2,6 +2,7 @@ //! system. These aren't restricted to syscalls or low-level operations, but //! also OS-specific features and conventions. +const dbus = @import("dbus.zig"); const desktop = @import("desktop.zig"); const env = @import("env.zig"); const file = @import("file.zig"); @@ -12,6 +13,7 @@ const mouse = @import("mouse.zig"); const openpkg = @import("open.zig"); const pipepkg = @import("pipe.zig"); const resourcesdir = @import("resourcesdir.zig"); +const systemd = @import("systemd.zig"); // Namespaces pub const args = @import("args.zig"); @@ -35,6 +37,8 @@ pub const getenv = env.getenv; pub const setenv = env.setenv; pub const unsetenv = env.unsetenv; pub const launchedFromDesktop = desktop.launchedFromDesktop; +pub const launchedByDbusActivation = dbus.launchedByDbusActivation; +pub const launchedBySystemd = systemd.launchedBySystemd; pub const desktopEnvironment = desktop.desktopEnvironment; pub const rlimit = file.rlimit; pub const fixMaxFiles = file.fixMaxFiles; diff --git a/src/os/systemd.zig b/src/os/systemd.zig new file mode 100644 index 000000000..9b67296d6 --- /dev/null +++ b/src/os/systemd.zig @@ -0,0 +1,65 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const log = std.log.scoped(.systemd); + +/// Returns true if the program was launched as a systemd service. +/// +/// On Linux, this returns true if the program was launched as a systemd +/// service. It will return false if Ghostty was launched any other way. +/// +/// For other platforms and app runtimes, this returns false. +pub fn launchedBySystemd() bool { + return switch (builtin.os.tag) { + .linux => linux: { + // On Linux, systemd sets the `INVOCATION_ID` (v232+) and the + // `JOURNAL_STREAM` (v231+) environment variables. If these + // environment variables are not present we were not launched by + // systemd. + if (std.posix.getenv("INVOCATION_ID") == null) break :linux false; + if (std.posix.getenv("JOURNAL_STREAM") == null) break :linux false; + + // If `INVOCATION_ID` and `JOURNAL_STREAM` are present, check to make sure + // that our parent process is actually `systemd`, not some other terminal + // emulator that doesn't clean up those environment variables. + const ppid = std.os.linux.getppid(); + if (ppid == 1) break :linux true; + + // If the parent PID is not 1 we need to check to see if we were launched by + // a user systemd daemon. Do that by checking the `/proc//comm` + // to see if it ends with `systemd`. + var comm_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const comm_path = std.fmt.bufPrint(&comm_path_buf, "/proc/{d}/comm", .{ppid}) catch { + log.err("unable to format comm path for pid {d}", .{ppid}); + break :linux false; + }; + const comm_file = std.fs.openFileAbsolute(comm_path, .{ .mode = .read_only }) catch { + log.err("unable to open '{s}' for reading", .{comm_path}); + break :linux false; + }; + defer comm_file.close(); + + // The maximum length of the command name is defined by + // `TASK_COMM_LEN` in the Linux kernel. This is usually 16 + // bytes at the time of writing (Jun 2025) so its set to that. + // Also, since we only care to compare to "systemd", anything + // longer can be assumed to not be systemd. + const TASK_COMM_LEN = 16; + var comm_data_buf: [TASK_COMM_LEN]u8 = undefined; + const comm_size = comm_file.readAll(&comm_data_buf) catch { + log.err("problems reading from '{s}'", .{comm_path}); + break :linux false; + }; + const comm_data = comm_data_buf[0..comm_size]; + + break :linux std.mem.eql( + u8, + std.mem.trimRight(u8, comm_data, "\n"), + "systemd", + ); + }, + + // No other system supports systemd so always return false. + else => false, + }; +} diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index ddc94b1ec..99dbc838e 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1872,6 +1872,8 @@ fn prepKittyGraphics( // points. This lets us determine offsets and containment of placements. const top = t.screen.pages.getTopLeft(.viewport); const bot = t.screen.pages.getBottomRight(.viewport).?; + const top_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; + const bot_y = t.screen.pages.pointFromPin(.screen, bot).?.screen.y; // Go through the placements and ensure the image is loaded on the GPU. var it = storage.placements.iterator(); @@ -1903,7 +1905,7 @@ fn prepKittyGraphics( continue; }; - try self.prepKittyPlacement(t, &top, &bot, &image, p); + try self.prepKittyPlacement(t, top_y, bot_y, &image, p); } // If we have virtual placements then we need to scan for placeholders. @@ -2009,8 +2011,8 @@ fn prepKittyVirtualPlacement( fn prepKittyPlacement( self: *Metal, t: *terminal.Terminal, - top: *const terminal.Pin, - bot: *const terminal.Pin, + top_y: u32, + bot_y: u32, image: *const terminal.kitty.graphics.Image, p: *const terminal.kitty.graphics.ImageStorage.Placement, ) !void { @@ -2018,78 +2020,47 @@ fn prepKittyPlacement( // a rect then its virtual or something so skip it. const rect = p.rect(image.*, t) orelse return; + // This is expensive but necessary. + const img_top_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + const img_bot_y = t.screen.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; + // If the selection isn't within our viewport then skip it. - if (bot.before(rect.top_left)) return; - if (rect.bottom_right.before(top.*)) return; - - // If the top left is outside the viewport we need to calc an offset - // so that we render (0, 0) with some offset for the texture. - const offset_y: u32 = if (rect.top_left.before(top.*)) offset_y: { - const vp_y = t.screen.pages.pointFromPin(.screen, top.*).?.screen.y; - const img_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; - const offset_cells = vp_y - img_y; - const offset_pixels = offset_cells * self.grid_metrics.cell_height; - break :offset_y @intCast(offset_pixels); - } else 0; - - // Get the grid size that respects aspect ratio - const grid_size = p.gridSize(image.*, t); - - // If we specify `rows` then our offset above is in viewport space - // and not in the coordinate space of the source image. Without `rows` - // that's one and the same. - const source_offset_y: u32 = if (grid_size.rows > 0) source_offset_y: { - // Determine the scale factor to apply for this row height. - const image_height: f64 = @floatFromInt(image.height); - const viewport_height: f64 = @floatFromInt(grid_size.rows * self.grid_metrics.cell_height); - const scale: f64 = image_height / viewport_height; - - // Apply the scale to the offset - const offset_y_f64: f64 = @floatFromInt(offset_y); - const source_offset_y_f64: f64 = offset_y_f64 * scale; - break :source_offset_y @intFromFloat(@round(source_offset_y_f64)); - } else offset_y; + if (img_top_y > bot_y) return; + if (img_bot_y < top_y) return; // We need to prep this image for upload if it isn't in the cache OR // it is in the cache but the transmit time doesn't match meaning this // image is different. try self.prepKittyImage(image); - // Convert our screen point to a viewport point - const viewport: terminal.point.Point = t.screen.pages.pointFromPin( - .viewport, - rect.top_left, - ) orelse .{ .viewport = .{} }; + // Calculate the dimensions of our image, taking in to + // account the rows / columns specified by the placement. + const dest_size = p.calculatedSize(image.*, t); // Calculate the source rectangle const source_x = @min(image.width, p.source_x); - const source_y = @min(image.height, p.source_y + source_offset_y); + const source_y = @min(image.height, p.source_y); const source_width = if (p.source_width > 0) @min(image.width - source_x, p.source_width) else image.width; const source_height = if (p.source_height > 0) - @min(image.height, p.source_height) + @min(image.height - source_y, p.source_height) else - image.height -| source_y; + image.height; - // Calculate the width/height of our image. - const dest_width = grid_size.cols * self.grid_metrics.cell_width; - const dest_height = if (grid_size.rows > 0) rows: { - // Clip to the viewport to handle scrolling. offset_y is already in - // viewport scale so we can subtract it directly. - break :rows (grid_size.rows * self.grid_metrics.cell_height) - offset_y; - } else source_height; + // Get the viewport-relative Y position of the placement. + const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y)); // Accumulate the placement - if (image.width > 0 and image.height > 0) { + if (dest_size.width > 0 and dest_size.height > 0) { try self.image_placements.append(self.alloc, .{ .image_id = image.id, .x = @intCast(rect.top_left.x), - .y = @intCast(viewport.viewport.y), + .y = y_pos, .z = p.z, - .width = dest_width, - .height = dest_height, + .width = dest_size.width, + .height = dest_size.height, .cell_offset_x = p.x_offset, .cell_offset_y = p.y_offset, .source_x = source_x, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index a3a2d8f7e..d0222a390 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -913,6 +913,8 @@ fn prepKittyGraphics( // points. This lets us determine offsets and containment of placements. const top = t.screen.pages.getTopLeft(.viewport); const bot = t.screen.pages.getBottomRight(.viewport).?; + const top_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; + const bot_y = t.screen.pages.pointFromPin(.screen, bot).?.screen.y; // Go through the placements and ensure the image is loaded on the GPU. var it = storage.placements.iterator(); @@ -944,7 +946,7 @@ fn prepKittyGraphics( continue; }; - try self.prepKittyPlacement(t, &top, &bot, &image, p); + try self.prepKittyPlacement(t, top_y, bot_y, &image, p); } // If we have virtual placements then we need to scan for placeholders. @@ -1050,8 +1052,8 @@ fn prepKittyVirtualPlacement( fn prepKittyPlacement( self: *OpenGL, t: *terminal.Terminal, - top: *const terminal.Pin, - bot: *const terminal.Pin, + top_y: u32, + bot_y: u32, image: *const terminal.kitty.graphics.Image, p: *const terminal.kitty.graphics.ImageStorage.Placement, ) !void { @@ -1059,78 +1061,47 @@ fn prepKittyPlacement( // a rect then its virtual or something so skip it. const rect = p.rect(image.*, t) orelse return; + // This is expensive but necessary. + const img_top_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + const img_bot_y = t.screen.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; + // If the selection isn't within our viewport then skip it. - if (bot.before(rect.top_left)) return; - if (rect.bottom_right.before(top.*)) return; - - // If the top left is outside the viewport we need to calc an offset - // so that we render (0, 0) with some offset for the texture. - const offset_y: u32 = if (rect.top_left.before(top.*)) offset_y: { - const vp_y = t.screen.pages.pointFromPin(.screen, top.*).?.screen.y; - const img_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; - const offset_cells = vp_y - img_y; - const offset_pixels = offset_cells * self.grid_metrics.cell_height; - break :offset_y @intCast(offset_pixels); - } else 0; - - // Get the grid size that respects aspect ratio - const grid_size = p.gridSize(image.*, t); - - // If we specify `rows` then our offset above is in viewport space - // and not in the coordinate space of the source image. Without `rows` - // that's one and the same. - const source_offset_y: u32 = if (grid_size.rows > 0) source_offset_y: { - // Determine the scale factor to apply for this row height. - const image_height: f64 = @floatFromInt(image.height); - const viewport_height: f64 = @floatFromInt(grid_size.rows * self.grid_metrics.cell_height); - const scale: f64 = image_height / viewport_height; - - // Apply the scale to the offset - const offset_y_f64: f64 = @floatFromInt(offset_y); - const source_offset_y_f64: f64 = offset_y_f64 * scale; - break :source_offset_y @intFromFloat(@round(source_offset_y_f64)); - } else offset_y; + if (img_top_y > bot_y) return; + if (img_bot_y < top_y) return; // We need to prep this image for upload if it isn't in the cache OR // it is in the cache but the transmit time doesn't match meaning this // image is different. try self.prepKittyImage(image); - // Convert our screen point to a viewport point - const viewport: terminal.point.Point = t.screen.pages.pointFromPin( - .viewport, - rect.top_left, - ) orelse .{ .viewport = .{} }; + // Calculate the dimensions of our image, taking in to + // account the rows / columns specified by the placement. + const dest_size = p.calculatedSize(image.*, t); // Calculate the source rectangle const source_x = @min(image.width, p.source_x); - const source_y = @min(image.height, p.source_y + source_offset_y); + const source_y = @min(image.height, p.source_y); const source_width = if (p.source_width > 0) @min(image.width - source_x, p.source_width) else image.width; const source_height = if (p.source_height > 0) - @min(image.height, p.source_height) + @min(image.height - source_y, p.source_height) else - image.height -| source_y; + image.height; - // Calculate the width/height of our image. - const dest_width = grid_size.cols * self.grid_metrics.cell_width; - const dest_height = if (grid_size.rows > 0) rows: { - // Clip to the viewport to handle scrolling. offset_y is already in - // viewport scale so we can subtract it directly. - break :rows (grid_size.rows * self.grid_metrics.cell_height) - offset_y; - } else source_height; + // Get the viewport-relative Y position of the placement. + const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y)); // Accumulate the placement - if (image.width > 0 and image.height > 0) { + if (dest_size.width > 0 and dest_size.height > 0) { try self.image_placements.append(self.alloc, .{ .image_id = image.id, .x = @intCast(rect.top_left.x), - .y = @intCast(viewport.viewport.y), + .y = y_pos, .z = p.z, - .width = dest_width, - .height = dest_height, + .width = dest_size.width, + .height = dest_size.height, .cell_offset_x = p.x_offset, .cell_offset_y = p.y_offset, .source_x = source_x, @@ -2511,8 +2482,8 @@ fn drawImages( // Setup our data try bind.vbo.setData(ImageProgram.Input{ - .grid_col = @intCast(p.x), - .grid_row = @intCast(p.y), + .grid_col = p.x, + .grid_row = p.y, .cell_offset_x = p.cell_offset_x, .cell_offset_y = p.cell_offset_y, .source_x = p.source_x, diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 46ef8609b..1e9c29b26 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -155,7 +155,7 @@ pub fn init( return .{ .alloc = alloc, - .config = DerivedConfig.init(config), + .config = .init(config), .loop = loop, .wakeup = wakeup_h, .stop = stop_h, diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index d8769d9e2..287b83450 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -62,7 +62,7 @@ pub fn style( } // Otherwise, we use whatever style the terminal wants. - return Style.fromTerminal(state.terminal.screen.cursor.cursor_style); + return .fromTerminal(state.terminal.screen.cursor.cursor_style); } test "cursor: default uses configured style" { diff --git a/src/renderer/link.zig b/src/renderer/link.zig index 994190ec8..410fb8632 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -179,7 +179,7 @@ pub const Set = struct { if (current) |*sel| { sel.endPtr().* = cell_pin; } else { - current = terminal.Selection.init( + current = .init( cell_pin, cell_pin, false, diff --git a/src/renderer/metal/api.zig b/src/renderer/metal/api.zig index 19db17ba4..46cb4f6bc 100644 --- a/src/renderer/metal/api.zig +++ b/src/renderer/metal/api.zig @@ -96,6 +96,7 @@ pub const MTLVertexStepFunction = enum(c_ulong) { pub const MTLPixelFormat = enum(c_ulong) { r8unorm = 10, rgba8unorm = 70, + rgba8unorm_srgb = 71, rgba8uint = 73, bgra8unorm = 80, bgra8unorm_srgb = 81, diff --git a/src/renderer/metal/cell.zig b/src/renderer/metal/cell.zig index 61b8887fd..e1bcb7b9f 100644 --- a/src/renderer/metal/cell.zig +++ b/src/renderer/metal/cell.zig @@ -44,7 +44,7 @@ fn ArrayListPool(comptime T: type) type { }; for (self.lists) |*list| { - list.* = try ArrayListT.initCapacity(alloc, initial_capacity); + list.* = try .initCapacity(alloc, initial_capacity); } return self; diff --git a/src/renderer/metal/image.zig b/src/renderer/metal/image.zig index 835fbd672..7d2599308 100644 --- a/src/renderer/metal/image.zig +++ b/src/renderer/metal/image.zig @@ -13,16 +13,16 @@ pub const Placement = struct { image_id: u32, /// The grid x/y where this placement is located. - x: u32, - y: u32, + x: i32, + y: i32, z: i32, /// The width/height of the placed image. width: u32, height: u32, - /// The offset in pixels from the top left of the cell. This is - /// clamped to the size of a cell. + /// The offset in pixels from the top left of the cell. + /// This is clamped to the size of a cell. cell_offset_x: u32, cell_offset_y: u32, @@ -441,7 +441,7 @@ pub const Image = union(enum) { }; // Set our properties - desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.rgba8uint)); + desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.rgba8unorm_srgb)); desc.setProperty("width", @as(c_ulong, @intCast(p.width))); desc.setProperty("height", @as(c_ulong, @intCast(p.height))); diff --git a/src/renderer/opengl/ImageProgram.zig b/src/renderer/opengl/ImageProgram.zig index e53891818..ff6794085 100644 --- a/src/renderer/opengl/ImageProgram.zig +++ b/src/renderer/opengl/ImageProgram.zig @@ -11,8 +11,8 @@ vbo: gl.Buffer, pub const Input = extern struct { /// vec2 grid_coord - grid_col: u16, - grid_row: u16, + grid_col: i32, + grid_row: i32, /// vec2 cell_offset cell_offset_x: u32 = 0, @@ -66,8 +66,8 @@ pub fn init() !ImageProgram { var vbobind = try vbo.bind(.array); defer vbobind.unbind(); var offset: usize = 0; - try vbobind.attributeAdvanced(0, 2, gl.c.GL_UNSIGNED_SHORT, false, @sizeOf(Input), offset); - offset += 2 * @sizeOf(u16); + try vbobind.attributeAdvanced(0, 2, gl.c.GL_INT, false, @sizeOf(Input), offset); + offset += 2 * @sizeOf(i32); try vbobind.attributeAdvanced(1, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Input), offset); offset += 2 * @sizeOf(u32); try vbobind.attributeAdvanced(2, 4, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Input), offset); diff --git a/src/renderer/opengl/image.zig b/src/renderer/opengl/image.zig index 85f59f1f3..26cd90736 100644 --- a/src/renderer/opengl/image.zig +++ b/src/renderer/opengl/image.zig @@ -11,8 +11,8 @@ pub const Placement = struct { image_id: u32, /// The grid x/y where this placement is located. - x: u32, - y: u32, + x: i32, + y: i32, z: i32, /// The width/height of the placed image. @@ -368,8 +368,8 @@ pub const Image = union(enum) { internal: gl.Texture.InternalFormat, format: gl.Texture.Format, } = switch (self.*) { - .pending_rgb, .replace_rgb => .{ .internal = .rgb, .format = .rgb }, - .pending_rgba, .replace_rgba => .{ .internal = .rgba, .format = .rgba }, + .pending_rgb, .replace_rgb => .{ .internal = .srgb, .format = .rgb }, + .pending_rgba, .replace_rgba => .{ .internal = .srgba, .format = .rgba }, else => unreachable, }; diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index e80ead9ad..5b3875221 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -621,9 +621,6 @@ vertex ImageVertexOut image_vertex( texture2d image [[texture(0)]], constant Uniforms& uniforms [[buffer(1)]] ) { - // The size of the image in pixels - float2 image_size = float2(image.get_width(), image.get_height()); - // Turn the image position into a vertex point depending on the // vertex ID. Since we use instanced drawing, we have 4 vertices // for each corner of the cell. We can use vertex ID to determine @@ -638,11 +635,12 @@ vertex ImageVertexOut image_vertex( corner.x = (vid == 0 || vid == 1) ? 1.0f : 0.0f; corner.y = (vid == 0 || vid == 3) ? 0.0f : 1.0f; - // The texture coordinates start at our source x/y, then add the width/height - // as enabled by our instance id, then normalize to [0, 1] + // The texture coordinates start at our source x/y + // and add the width/height depending on the corner. + // + // We don't need to normalize because we use pixel addressing for our sampler. float2 tex_coord = in.source_rect.xy; tex_coord += in.source_rect.zw * corner; - tex_coord /= image_size; ImageVertexOut out; @@ -659,22 +657,23 @@ vertex ImageVertexOut image_vertex( fragment float4 image_fragment( ImageVertexOut in [[stage_in]], - texture2d image [[texture(0)]], + texture2d image [[texture(0)]], constant Uniforms& uniforms [[buffer(1)]] ) { - constexpr sampler textureSampler(address::clamp_to_edge, filter::linear); - - // Ehhhhh our texture is in RGBA8Uint but our color attachment is - // BGRA8Unorm. So we need to convert it. We should really be converting - // our texture to BGRA8Unorm. - uint4 rgba = image.sample(textureSampler, in.tex_coord); - - return load_color( - uchar4(rgba), - // We assume all images are sRGB regardless of the configured colorspace - // TODO: Maybe support wide gamut images? - false, - uniforms.use_linear_blending + constexpr sampler textureSampler( + coord::pixel, + address::clamp_to_edge, + filter::linear ); + + float4 rgba = image.sample(textureSampler, in.tex_coord); + + if (!uniforms.use_linear_blending) { + rgba = unlinearize(rgba); + } + + rgba.rgb *= rgba.a; + + return rgba; } diff --git a/src/renderer/shaders/image.f.glsl b/src/renderer/shaders/image.f.glsl index e8c00b271..e4aa9ef8e 100644 --- a/src/renderer/shaders/image.f.glsl +++ b/src/renderer/shaders/image.f.glsl @@ -6,7 +6,24 @@ layout(location = 0) out vec4 out_FragColor; uniform sampler2D image; +// Converts a color from linear to sRGB gamma encoding. +vec4 unlinearize(vec4 linear) { + bvec3 cutoff = lessThan(linear.rgb, vec3(0.0031308)); + vec3 higher = pow(linear.rgb, vec3(1.0/2.4)) * vec3(1.055) - vec3(0.055); + vec3 lower = linear.rgb * vec3(12.92); + + return vec4(mix(higher, lower, cutoff), linear.a); +} + void main() { vec4 color = texture(image, tex_coord); + + // Our texture is stored with an sRGB internal format, + // which means that the values are linearized when we + // sample the texture, but for now we actually want to + // output the color with gamma compression, so we do + // that. + color = unlinearize(color); + out_FragColor = vec4(color.rgb * color.a, color.a); } diff --git a/src/renderer/size.zig b/src/renderer/size.zig index 83e921a26..b26c1581e 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -22,7 +22,7 @@ pub const Size = struct { /// taking the screen size, removing padding, and dividing by the cell /// dimensions. pub fn grid(self: Size) GridSize { - return GridSize.init(self.screen.subPadding(self.padding), self.cell); + return .init(self.screen.subPadding(self.padding), self.cell); } /// The size of the terminal. This is the same as the screen without @@ -39,7 +39,7 @@ pub const Size = struct { self.padding = explicit; // Now we can calculate the balanced padding - self.padding = Padding.balanced( + self.padding = .balanced( self.screen, self.grid(), self.cell, diff --git a/src/surface_mouse.zig b/src/surface_mouse.zig index ed1e36335..a9702a8fe 100644 --- a/src/surface_mouse.zig +++ b/src/surface_mouse.zig @@ -132,7 +132,7 @@ test "keyToMouseShape" { { // No specific key pressed const m: SurfaceMouse = .{ - .physical_key = .invalid, + .physical_key = .unidentified, .mouse_event = .none, .mouse_shape = .progress, .mods = .{}, @@ -148,7 +148,7 @@ test "keyToMouseShape" { // Over a link. NOTE: This tests that we don't touch the inbound state, // not necessarily if we're over a link. const m: SurfaceMouse = .{ - .physical_key = .left_shift, + .physical_key = .shift_left, .mouse_event = .none, .mouse_shape = .progress, .mods = .{}, @@ -163,7 +163,7 @@ test "keyToMouseShape" { { // Mouse is currently hidden const m: SurfaceMouse = .{ - .physical_key = .left_shift, + .physical_key = .shift_left, .mouse_event = .none, .mouse_shape = .progress, .mods = .{}, @@ -178,7 +178,7 @@ test "keyToMouseShape" { { // default, no mods (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_shift, + .physical_key = .shift_left, .mouse_event = .x10, .mouse_shape = .default, .mods = .{}, @@ -194,7 +194,7 @@ test "keyToMouseShape" { { // default -> crosshair (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_alt, + .physical_key = .alt_left, .mouse_event = .x10, .mouse_shape = .default, .mods = .{ .ctrl = true, .super = true, .alt = true, .shift = true }, @@ -210,7 +210,7 @@ test "keyToMouseShape" { { // default -> text (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_shift, + .physical_key = .shift_left, .mouse_event = .x10, .mouse_shape = .default, .mods = .{ .shift = true }, @@ -226,7 +226,7 @@ test "keyToMouseShape" { { // crosshair -> text (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_alt, + .physical_key = .alt_left, .mouse_event = .x10, .mouse_shape = .crosshair, .mods = .{ .shift = true }, @@ -242,7 +242,7 @@ test "keyToMouseShape" { { // crosshair -> default (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_alt, + .physical_key = .alt_left, .mouse_event = .x10, .mouse_shape = .crosshair, .mods = .{}, @@ -258,7 +258,7 @@ test "keyToMouseShape" { { // text -> crosshair (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_alt, + .physical_key = .alt_left, .mouse_event = .x10, .mouse_shape = .text, .mods = .{ .ctrl = true, .super = true, .alt = true, .shift = true }, @@ -274,7 +274,7 @@ test "keyToMouseShape" { { // text -> default (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_shift, + .physical_key = .shift_left, .mouse_event = .x10, .mouse_shape = .text, .mods = .{}, @@ -290,7 +290,7 @@ test "keyToMouseShape" { { // text, no mods (no mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_shift, + .physical_key = .shift_left, .mouse_event = .none, .mouse_shape = .text, .mods = .{}, @@ -306,7 +306,7 @@ test "keyToMouseShape" { { // text -> crosshair (no mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_alt, + .physical_key = .alt_left, .mouse_event = .none, .mouse_shape = .text, .mods = .{ .ctrl = true, .super = true, .alt = true }, @@ -322,7 +322,7 @@ test "keyToMouseShape" { { // crosshair -> text (no mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_alt, + .physical_key = .alt_left, .mouse_event = .none, .mouse_shape = .crosshair, .mods = .{}, diff --git a/src/synthetic/Bytes.zig b/src/synthetic/Bytes.zig new file mode 100644 index 000000000..8a8207ba9 --- /dev/null +++ b/src/synthetic/Bytes.zig @@ -0,0 +1,53 @@ +/// Generates bytes. +const Bytes = @This(); + +const std = @import("std"); +const Generator = @import("Generator.zig"); + +/// Random number generator. +rand: std.Random, + +/// The minimum and maximum length of the generated bytes. The maximum +/// length will be capped to the length of the buffer passed in if the +/// buffer length is smaller. +min_len: usize = 1, +max_len: usize = std.math.maxInt(usize), + +/// The possible bytes that can be generated. If a byte is duplicated +/// in the alphabet, it will be more likely to be generated. That's a +/// side effect of the generator, not an intended use case. +alphabet: ?[]const u8 = null, + +/// Predefined alphabets. +pub const Alphabet = struct { + pub const ascii = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;':\\\",./<>?`~"; +}; + +pub fn generator(self: *Bytes) Generator { + return .init(self, next); +} + +pub fn next(self: *Bytes, buf: []u8) Generator.Error![]const u8 { + const len = @min( + self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len), + buf.len, + ); + + const result = buf[0..len]; + self.rand.bytes(result); + if (self.alphabet) |alphabet| { + for (result) |*byte| byte.* = alphabet[byte.* % alphabet.len]; + } + + return result; +} + +test "bytes" { + const testing = std.testing; + var prng = std.Random.DefaultPrng.init(0); + var buf: [256]u8 = undefined; + var v: Bytes = .{ .rand = prng.random() }; + const gen = v.generator(); + const result = try gen.next(&buf); + try testing.expect(result.len > 0); +} diff --git a/src/synthetic/Generator.zig b/src/synthetic/Generator.zig new file mode 100644 index 000000000..7478a54c3 --- /dev/null +++ b/src/synthetic/Generator.zig @@ -0,0 +1,42 @@ +/// A common interface for all generators. +const Generator = @This(); + +const std = @import("std"); +const assert = std.debug.assert; + +/// For generators, this is the only error that is allowed to be +/// returned by the next function. +pub const Error = error{NoSpaceLeft}; + +/// The vtable for the generator. +ptr: *anyopaque, +nextFn: *const fn (ptr: *anyopaque, buf: []u8) Error![]const u8, + +/// Create a new generator from a pointer and a function pointer. +/// This usually is only called by generator implementations, not +/// generator users. +pub fn init( + pointer: anytype, + comptime nextFn: fn (ptr: @TypeOf(pointer), buf: []u8) Error![]const u8, +) Generator { + const Ptr = @TypeOf(pointer); + assert(@typeInfo(Ptr) == .pointer); // Must be a pointer + assert(@typeInfo(Ptr).pointer.size == .one); // Must be a single-item pointer + assert(@typeInfo(@typeInfo(Ptr).pointer.child) == .@"struct"); // Must point to a struct + const gen = struct { + fn next(ptr: *anyopaque, buf: []u8) Error![]const u8 { + const self: Ptr = @ptrCast(@alignCast(ptr)); + return try nextFn(self, buf); + } + }; + + return .{ + .ptr = pointer, + .nextFn = gen.next, + }; +} + +/// Get the next value from the generator. Returns the data written. +pub fn next(self: Generator, buf: []u8) Error![]const u8 { + return try self.nextFn(self.ptr, buf); +} diff --git a/src/synthetic/Osc.zig b/src/synthetic/Osc.zig new file mode 100644 index 000000000..e0a6b42a0 --- /dev/null +++ b/src/synthetic/Osc.zig @@ -0,0 +1,221 @@ +/// Generates random terminal OSC requests. +const Osc = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Generator = @import("Generator.zig"); +const Bytes = @import("Bytes.zig"); + +/// Valid OSC request kinds that can be generated. +pub const ValidKind = enum { + change_window_title, + prompt_start, + prompt_end, +}; + +/// Invalid OSC request kinds that can be generated. +pub const InvalidKind = enum { + /// Literally random bytes. Might even be valid, but probably not. + random, + + /// A good prefix, but ultimately invalid format. + good_prefix, +}; + +/// Random number generator. +rand: std.Random, + +/// Probability of a valid OSC sequence being generated. +p_valid: f64 = 1.0, + +/// Probabilities of specific valid or invalid OSC request kinds. +/// The probabilities are weighted relative to each other, so they +/// can sum greater than 1.0. A kind of weight 1.0 and a kind of +/// weight 2.0 will have a 2:1 chance of the latter being selected. +p_valid_kind: std.enums.EnumArray(ValidKind, f64) = .initFill(1.0), +p_invalid_kind: std.enums.EnumArray(InvalidKind, f64) = .initFill(1.0), + +/// The alphabet for random bytes (omitting 0x1B and 0x07). +const bytes_alphabet: []const u8 = alphabet: { + var alphabet: [256]u8 = undefined; + for (0..alphabet.len) |i| { + if (i == 0x1B or i == 0x07) { + alphabet[i] = @intCast(i + 1); + } else { + alphabet[i] = @intCast(i); + } + } + const result = alphabet; + break :alphabet &result; +}; + +pub fn generator(self: *Osc) Generator { + return .init(self, next); +} + +/// Get the next OSC request in bytes. The generated OSC request will +/// have the prefix `ESC ]` and the terminator `BEL` (0x07). +/// +/// This will generate both valid and invalid OSC requests (based on +/// the `p_valid` probability value). Invalid requests still have the +/// prefix and terminator, but the content in between is not a valid +/// OSC request. +/// +/// The buffer must be at least 3 bytes long to accommodate the +/// prefix and terminator. +pub fn next(self: *Osc, buf: []u8) Generator.Error![]const u8 { + if (buf.len < 3) return error.NoSpaceLeft; + const unwrapped = try self.nextUnwrapped(buf[2 .. buf.len - 1]); + buf[0] = 0x1B; // ESC + buf[1] = ']'; + buf[unwrapped.len + 2] = 0x07; // BEL + return buf[0 .. unwrapped.len + 3]; +} + +fn nextUnwrapped(self: *Osc, buf: []u8) Generator.Error![]const u8 { + return switch (self.chooseValidity()) { + .valid => valid: { + const Indexer = @TypeOf(self.p_valid_kind).Indexer; + const idx = self.rand.weightedIndex(f64, &self.p_valid_kind.values); + break :valid try self.nextUnwrappedValidExact( + buf, + Indexer.keyForIndex(idx), + ); + }, + + .invalid => invalid: { + const Indexer = @TypeOf(self.p_invalid_kind).Indexer; + const idx = self.rand.weightedIndex(f64, &self.p_invalid_kind.values); + break :invalid try self.nextUnwrappedInvalidExact( + buf, + Indexer.keyForIndex(idx), + ); + }, + }; +} + +fn nextUnwrappedValidExact(self: *const Osc, buf: []u8, k: ValidKind) Generator.Error![]const u8 { + var fbs = std.io.fixedBufferStream(buf); + switch (k) { + .change_window_title => { + try fbs.writer().writeAll("0;"); // Set window title + var bytes_gen = self.bytes(); + const title = try bytes_gen.next(fbs.buffer[fbs.pos..]); + try fbs.seekBy(@intCast(title.len)); + }, + + .prompt_start => { + try fbs.writer().writeAll("133;A"); // Start prompt + + // aid + if (self.rand.boolean()) { + var bytes_gen = self.bytes(); + bytes_gen.max_len = 16; + try fbs.writer().writeAll(";aid="); + const aid = try bytes_gen.next(fbs.buffer[fbs.pos..]); + try fbs.seekBy(@intCast(aid.len)); + } + + // redraw + if (self.rand.boolean()) { + try fbs.writer().writeAll(";redraw="); + if (self.rand.boolean()) { + try fbs.writer().writeAll("1"); + } else { + try fbs.writer().writeAll("0"); + } + } + }, + + .prompt_end => try fbs.writer().writeAll("133;B"), // End prompt + } + + return fbs.getWritten(); +} + +fn nextUnwrappedInvalidExact( + self: *const Osc, + buf: []u8, + k: InvalidKind, +) Generator.Error![]const u8 { + switch (k) { + .random => { + var bytes_gen = self.bytes(); + return try bytes_gen.next(buf); + }, + + .good_prefix => { + var fbs = std.io.fixedBufferStream(buf); + try fbs.writer().writeAll("133;"); + var bytes_gen = self.bytes(); + const data = try bytes_gen.next(fbs.buffer[fbs.pos..]); + try fbs.seekBy(@intCast(data.len)); + return fbs.getWritten(); + }, + } +} + +fn bytes(self: *const Osc) Bytes { + return .{ + .rand = self.rand, + .alphabet = bytes_alphabet, + }; +} + +/// Choose whether to generate a valid or invalid OSC request based +/// on the validity probability. +fn chooseValidity(self: *const Osc) Validity { + return if (self.rand.float(f64) > self.p_valid) + .invalid + else + .valid; +} + +const Validity = enum { valid, invalid }; + +/// A fixed seed we can use for our tests to avoid flakes. +const test_seed = 0xC0FFEEEEEEEEEEEE; + +test "OSC generator" { + var prng = std.Random.DefaultPrng.init(test_seed); + var buf: [4096]u8 = undefined; + var v: Osc = .{ .rand = prng.random() }; + const gen = v.generator(); + for (0..50) |_| _ = try gen.next(&buf); +} + +test "OSC generator valid" { + const testing = std.testing; + const terminal = @import("../terminal/main.zig"); + + var prng = std.Random.DefaultPrng.init(test_seed); + var buf: [256]u8 = undefined; + var gen: Osc = .{ + .rand = prng.random(), + .p_valid = 1.0, + }; + for (0..50) |_| { + const seq = try gen.next(&buf); + var parser: terminal.osc.Parser = .{}; + for (seq[2 .. seq.len - 1]) |c| parser.next(c); + try testing.expect(parser.end(null) != null); + } +} + +test "OSC generator invalid" { + const testing = std.testing; + const terminal = @import("../terminal/main.zig"); + + var prng = std.Random.DefaultPrng.init(test_seed); + var buf: [256]u8 = undefined; + var gen: Osc = .{ + .rand = prng.random(), + .p_valid = 0.0, + }; + for (0..50) |_| { + const seq = try gen.next(&buf); + var parser: terminal.osc.Parser = .{}; + for (seq[2 .. seq.len - 1]) |c| parser.next(c); + try testing.expect(parser.end(null) == null); + } +} diff --git a/src/synthetic/Utf8.zig b/src/synthetic/Utf8.zig new file mode 100644 index 000000000..c3ace6505 --- /dev/null +++ b/src/synthetic/Utf8.zig @@ -0,0 +1,103 @@ +/// Generates UTF-8. +/// +/// This doesn't yet generate multi-codepoint graphemes, but it +/// has the ability to generate a custom distribution of UTF-8 +/// encoding lengths (1, 2, 3, or 4 bytes). +const Utf8 = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Generator = @import("Generator.zig"); + +/// Possible UTF-8 encoding lengths. +pub const Utf8Len = enum(u3) { + one = 1, + two = 2, + three = 3, + four = 4, +}; + +/// Random number generator. +rand: std.Random, + +/// The minimum and maximum length of the generated bytes. The maximum +/// length will be capped to the length of the buffer passed in if the +/// buffer length is smaller. +min_len: usize = 1, +max_len: usize = std.math.maxInt(usize), + +/// Probability of a specific UTF-8 encoding length being generated. +/// The probabilities are weighted relative to each other, so they +/// can sum greater than 1.0. A length of weight 1.0 and a length +/// of weight 2.0 will have a 2:1 chance of the latter being +/// selected. +/// +/// If a UTF-8 encoding of a chosen length can't fit into the remaining +/// buffer, a smaller length will be chosen. For small buffers this may +/// skew the distribution of lengths. +p_length: std.enums.EnumArray(Utf8Len, f64) = .initFill(1.0), + +pub fn generator(self: *Utf8) Generator { + return .init(self, next); +} + +pub fn next(self: *Utf8, buf: []u8) Generator.Error![]const u8 { + const len = @min( + self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len), + buf.len, + ); + + const result = buf[0..len]; + var rem: usize = len; + while (rem > 0) { + // Pick a utf8 byte count to generate. + const utf8_len: Utf8Len = len: { + const Indexer = @TypeOf(self.p_length).Indexer; + const idx = self.rand.weightedIndex(f64, &self.p_length.values); + var utf8_len = Indexer.keyForIndex(idx); + assert(rem > 0); + while (@intFromEnum(utf8_len) > rem) { + // If the chosen length can't fit into the remaining buffer, + // choose a smaller length. + utf8_len = @enumFromInt(@intFromEnum(utf8_len) - 1); + } + break :len utf8_len; + }; + + // Generate a UTF-8 sequence that encodes to this length. + const cp: u21 = switch (utf8_len) { + .one => self.rand.intRangeAtMostBiased(u21, 0x00, 0x7F), + .two => self.rand.intRangeAtMostBiased(u21, 0x80, 0x7FF), + .three => self.rand.intRangeAtMostBiased(u21, 0x800, 0xFFFF), + .four => self.rand.intRangeAtMostBiased(u21, 0x10000, 0x10FFFF), + }; + + assert(std.unicode.utf8CodepointSequenceLength( + cp, + ) catch unreachable == @intFromEnum(utf8_len)); + rem -= std.unicode.utf8Encode( + cp, + result[result.len - rem ..], + ) catch |err| switch (err) { + // Impossible because our generation above is hardcoded to + // produce a valid range. If not, a bug. + error.CodepointTooLarge => unreachable, + + // Possible, in which case we redo the loop and encode nothing. + error.Utf8CannotEncodeSurrogateHalf => continue, + }; + } + + return result; +} + +test "utf8" { + const testing = std.testing; + var prng = std.Random.DefaultPrng.init(0); + var buf: [256]u8 = undefined; + var v: Utf8 = .{ .rand = prng.random() }; + const gen = v.generator(); + const result = try gen.next(&buf); + try testing.expect(result.len > 0); + try testing.expect(std.unicode.utf8ValidateSlice(result)); +} diff --git a/src/synthetic/main.zig b/src/synthetic/main.zig new file mode 100644 index 000000000..67cd47054 --- /dev/null +++ b/src/synthetic/main.zig @@ -0,0 +1,23 @@ +//! The synthetic package contains an abstraction for generating +//! synthetic data. The motivating use case for this package is to +//! generate synthetic data for benchmarking, but it may also expand +//! to other use cases such as fuzzing (e.g. to generate a corpus +//! rather than directly fuzzing). +//! +//! The generators in this package are typically not performant +//! enough to be streamed in real time. They should instead be +//! used to generate a large amount of data in a single go +//! and then streamed from there. +//! +//! The generators are aimed for terminal emulation, but the package +//! is not limited to that and we may want to extract this to a +//! standalone package one day. + +pub const Generator = @import("Generator.zig"); +pub const Bytes = @import("Bytes.zig"); +pub const Utf8 = @import("Utf8.zig"); +pub const Osc = @import("Osc.zig"); + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 95519fe99..9838bfb53 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -287,8 +287,8 @@ fn initPages( // Initialize the first set of pages to contain our viewport so that // the top of the first page is always the active area. node.* = .{ - .data = Page.initBuf( - OffsetBuf.init(page_buf), + .data = .initBuf( + .init(page_buf), Page.layout(cap), ), }; @@ -472,7 +472,7 @@ pub fn clone( }; // Setup our pools - break :alloc try MemoryPool.init( + break :alloc try .init( alloc, std.heap.page_allocator, page_count, @@ -908,16 +908,6 @@ const ReflowCursor = struct { const cell = &cells[x]; x += 1; - // std.log.warn("\nsrc_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={} wide={}", .{ - // src_y, - // x, - // self.y, - // self.x, - // self.page.size.cols, - // cell.content.codepoint, - // cell.wide, - // }); - // Copy cell contents. switch (cell.content_tag) { .codepoint, @@ -937,8 +927,15 @@ const ReflowCursor = struct { }; // Decrement the source position so that when we - // loop we'll process this source cell again. + // loop we'll process this source cell again, + // since we can't copy it into a spacer head. x -= 1; + + // Move to the next row (this sets pending wrap + // which will cause us to wrap on the next + // iteration). + self.cursorForward(); + continue; } else { self.page_cell.* = cell.*; } @@ -990,6 +987,17 @@ const ReflowCursor = struct { self.page_cell.hyperlink = false; self.page_cell.style_id = stylepkg.default_id; + // std.log.warn("\nsrc_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={X} wide={} page_cell_wide={}", .{ + // src_y, + // x, + // self.y, + // self.x, + // self.page.size.cols, + // cell.content.codepoint, + // cell.wide, + // self.page_cell.wide, + // }); + // Copy grapheme data. if (cell.content_tag == .codepoint_grapheme) { // Copy the graphemes @@ -1201,7 +1209,7 @@ const ReflowCursor = struct { node.data.size.rows = 1; list.pages.insertAfter(self.node, node); - self.* = ReflowCursor.init(node); + self.* = .init(node); self.new_rows = new_rows; } @@ -1817,7 +1825,7 @@ pub fn grow(self: *PageList) !?*List.Node { @memset(buf, 0); // Initialize our new page and reinsert it as the last - first.data = Page.initBuf(OffsetBuf.init(buf), layout); + first.data = .initBuf(.init(buf), layout); first.data.size.rows = 1; self.pages.insertAfter(last, first); @@ -1989,7 +1997,7 @@ fn createPageExt( // to undefined, 0xAA. if (comptime std.debug.runtime_safety) @memset(page_buf, 0); - page.* = .{ .data = Page.initBuf(OffsetBuf.init(page_buf), layout) }; + page.* = .{ .data = .initBuf(.init(page_buf), layout) }; page.data.size.rows = 0; if (total_size) |v| { @@ -3572,6 +3580,74 @@ pub const Pin = struct { return result; } + /// Move the pin left n columns, stopping at the start of the row. + pub fn leftClamp(self: Pin, n: size.CellCountInt) Pin { + var result = self; + result.x -|= n; + return result; + } + + /// Move the pin right n columns, stopping at the end of the row. + pub fn rightClamp(self: Pin, n: size.CellCountInt) Pin { + var result = self; + result.x = @min(self.x +| n, self.node.data.size.cols - 1); + return result; + } + + /// Move the pin left n cells, wrapping to the previous row as needed. + /// + /// If the offset goes beyond the top of the screen, returns null. + /// + /// TODO: Unit tests. + pub fn leftWrap(self: Pin, n: usize) ?Pin { + // NOTE: This assumes that all pages have the same width, which may + // be violated under certain circumstances by incomplete reflow. + const cols = self.node.data.size.cols; + const remaining_in_row = self.x; + + if (n <= remaining_in_row) return self.left(n); + + const extra_after_remaining = n - remaining_in_row; + + const rows_off = 1 + extra_after_remaining / cols; + + switch (self.upOverflow(rows_off)) { + .offset => |v| { + var result = v; + result.x = @intCast(cols - extra_after_remaining % cols); + return result; + }, + .overflow => return null, + } + } + + /// Move the pin right n cells, wrapping to the next row as needed. + /// + /// If the offset goes beyond the bottom of the screen, returns null. + /// + /// TODO: Unit tests. + pub fn rightWrap(self: Pin, n: usize) ?Pin { + // NOTE: This assumes that all pages have the same width, which may + // be violated under certain circumstances by incomplete reflow. + const cols = self.node.data.size.cols; + const remaining_in_row = cols - self.x - 1; + + if (n <= remaining_in_row) return self.right(n); + + const extra_after_remaining = n - remaining_in_row; + + const rows_off = 1 + extra_after_remaining / cols; + + switch (self.downOverflow(rows_off)) { + .offset => |v| { + var result = v; + result.x = @intCast(extra_after_remaining % cols - 1); + return result; + }, + .overflow => return null, + } + } + /// Move the pin down a certain number of rows, or return null if /// the pin goes beyond the end of the screen. pub fn down(self: Pin, n: usize) ?Pin { @@ -8307,6 +8383,125 @@ test "PageList resize reflow less cols to wrap a wide char" { } } +test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // We want to make the screen look like this: + // + // 👨‍👨‍👦‍👦👨‍👨‍👦‍👦 + + // First family emoji at (0, 0) + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0x1F468 }, // First codepoint of the grapheme + .wide = .wide, + }; + try page.setGraphemes(rac.row, rac.cell, &.{ + 0x200D, 0x1F468, + 0x200D, 0x1F466, + 0x200D, 0x1F466, + }); + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_tail, + }; + } + // Second family emoji at (2, 0) + { + const rac = page.getRowAndCell(2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0x1F468 }, // First codepoint of the grapheme + .wide = .wide, + }; + try page.setGraphemes(rac.row, rac.cell, &.{ + 0x200D, 0x1F468, + 0x200D, 0x1F466, + 0x200D, 0x1F466, + }); + } + { + const rac = page.getRowAndCell(3, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 3, .reflow = true }); + try testing.expectEqual(@as(usize, 3), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 0x1F468), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, 6), cps.len); + try testing.expectEqual(@as(u21, 0x200D), cps[0]); + try testing.expectEqual(@as(u21, 0x1F468), cps[1]); + try testing.expectEqual(@as(u21, 0x200D), cps[2]); + try testing.expectEqual(@as(u21, 0x1F466), cps[3]); + try testing.expectEqual(@as(u21, 0x200D), cps[4]); + try testing.expectEqual(@as(u21, 0x1F466), cps[5]); + + // Row should be wrapped + try testing.expect(rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide); + } + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 0x1F468), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, 6), cps.len); + try testing.expectEqual(@as(u21, 0x200D), cps[0]); + try testing.expectEqual(@as(u21, 0x1F468), cps[1]); + try testing.expectEqual(@as(u21, 0x200D), cps[2]); + try testing.expectEqual(@as(u21, 0x1F466), cps[3]); + try testing.expectEqual(@as(u21, 0x200D), cps[4]); + try testing.expectEqual(@as(u21, 0x1F466), cps[5]); + } + { + const rac = page.getRowAndCell(1, 1); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } +} + test "PageList resize reflow less cols copy kitty placeholder" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 4e74f04ba..ec3f322f6 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -217,7 +217,7 @@ intermediates_idx: u8 = 0, /// Param tracking, building params: [MAX_PARAMS]u16 = undefined, -params_sep: Action.CSI.SepList = Action.CSI.SepList.initEmpty(), +params_sep: Action.CSI.SepList = .initEmpty(), params_idx: u8 = 0, param_acc: u16 = 0, param_acc_idx: u8 = 0, @@ -395,7 +395,7 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { pub fn clear(self: *Parser) void { self.intermediates_idx = 0; self.params_idx = 0; - self.params_sep = Action.CSI.SepList.initEmpty(); + self.params_sep = .initEmpty(); self.param_acc = 0; self.param_acc_idx = 0; } @@ -877,7 +877,10 @@ test "osc: change window title (end in esc)" { // https://github.com/darrenstarr/VtNetCore/pull/14 // Saw this on HN, decided to add a test case because why not. test "osc: 112 incomplete sequence" { - var p = init(); + var p: Parser = init(); + defer p.deinit(); + p.osc_parser.alloc = std.testing.allocator; + _ = p.next(0x1B); _ = p.next(']'); _ = p.next('1'); @@ -892,8 +895,20 @@ test "osc: 112 incomplete sequence" { try testing.expect(a[2] == null); const cmd = a[0].?.osc_dispatch; - try testing.expect(cmd == .reset_color); - try testing.expectEqual(cmd.reset_color.kind, .cursor); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .reset_cursor); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + osc.Command.ColorOperation.Kind.cursor, + op.reset, + ); + } + try std.testing.expect(it.next() == null); } } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 9ab4b23e2..2688b03a7 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -171,7 +171,7 @@ pub const SavedCursor = struct { /// State required for all charset operations. pub const CharsetState = struct { /// The list of graphical charsets by slot - charsets: CharsetArray = CharsetArray.initFill(charsets.Charset.utf8), + charsets: CharsetArray = .initFill(charsets.Charset.utf8), /// GL is the slot to use when using a 7-bit printable char (up to 127) /// GR used for 8-bit printable chars. @@ -2433,7 +2433,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { return null; }; - return Selection.init(start, end, false); + return .init(start, end, false); } /// Return the selection for all contents on the screen. Surrounding @@ -2489,7 +2489,7 @@ pub fn selectAll(self: *Screen) ?Selection { return null; }; - return Selection.init(start, end, false); + return .init(start, end, false); } /// Select the nearest word to start point that is between start_pt and @@ -2624,7 +2624,7 @@ pub fn selectWord(self: *Screen, pin: Pin) ?Selection { break :start prev; }; - return Selection.init(start, end, false); + return .init(start, end, false); } /// Select the command output under the given point. The limits of the output @@ -2724,7 +2724,7 @@ pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { break :boundary it_prev; }; - return Selection.init(start, end, false); + return .init(start, end, false); } /// Returns the selection bounds for the prompt at the given point. If the @@ -2805,7 +2805,7 @@ pub fn selectPrompt(self: *Screen, pin: Pin) ?Selection { break :end it_prev; }; - return Selection.init(start, end, false); + return .init(start, end, false); } pub const LineIterator = struct { diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index a90595d20..267f223d5 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -228,7 +228,7 @@ pub fn order(self: Selection, s: *const Screen) Order { /// Note that only forward and reverse are useful desired orders for this /// function. All other orders act as if forward order was desired. pub fn ordered(self: Selection, s: *const Screen, desired: Order) Selection { - if (self.order(s) == desired) return Selection.init( + if (self.order(s) == desired) return .init( self.start(), self.end(), self.rectangle, @@ -237,9 +237,9 @@ pub fn ordered(self: Selection, s: *const Screen, desired: Order) Selection { const tl = self.topLeft(s); const br = self.bottomRight(s); return switch (desired) { - .forward => Selection.init(tl, br, self.rectangle), - .reverse => Selection.init(br, tl, self.rectangle), - else => Selection.init(tl, br, self.rectangle), + .forward => .init(tl, br, self.rectangle), + .reverse => .init(br, tl, self.rectangle), + else => .init(tl, br, self.rectangle), }; } diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig index 9892c13df..dde69d25e 100644 --- a/src/terminal/StringMap.zig +++ b/src/terminal/StringMap.zig @@ -80,7 +80,7 @@ pub const Match = struct { const end_idx: usize = @intCast(self.region.ends()[0] - 1); const start_pt = self.map.map[self.offset + start_idx]; const end_pt = self.map.map[self.offset + end_idx]; - return Selection.init(start_pt, end_pt, false); + return .init(start_pt, end_pt, false); } }; diff --git a/src/terminal/Tabstops.zig b/src/terminal/Tabstops.zig index 5a54fb28b..4ab5133d9 100644 --- a/src/terminal/Tabstops.zig +++ b/src/terminal/Tabstops.zig @@ -44,7 +44,7 @@ const masks = blk: { cols: usize = 0, /// Preallocated tab stops. -prealloc_stops: [prealloc_count]Unit = [1]Unit{0} ** prealloc_count, +prealloc_stops: [prealloc_count]Unit = @splat(0), /// Dynamically expanded stops above prealloc stops. dynamic_stops: []Unit = &[0]Unit{}, diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index efb9684eb..be7a58f9b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -79,7 +79,7 @@ default_palette: color.Palette = color.default, color_palette: struct { const Mask = std.StaticBitSet(@typeInfo(color.Palette).array.len); colors: color.Palette = color.default, - mask: Mask = Mask.initEmpty(), + mask: Mask = .initEmpty(), } = .{}, /// The previous printed character. This is used for the repeat previous @@ -210,9 +210,9 @@ pub fn init( .cols = cols, .rows = rows, .active_screen = .primary, - .screen = try Screen.init(alloc, cols, rows, opts.max_scrollback), - .secondary_screen = try Screen.init(alloc, cols, rows, 0), - .tabstops = try Tabstops.init(alloc, cols, TABSTOP_INTERVAL), + .screen = try .init(alloc, cols, rows, opts.max_scrollback), + .secondary_screen = try .init(alloc, cols, rows, 0), + .tabstops = try .init(alloc, cols, TABSTOP_INTERVAL), .scrolling_region = .{ .top = 0, .bottom = rows - 1, @@ -2329,7 +2329,7 @@ pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { try writer.writeByte('0'); const pen = self.screen.cursor.style; - var attrs = [_]u8{0} ** 8; + var attrs: [8]u8 = @splat(0); var i: usize = 0; if (pen.flags.bold) { @@ -2454,7 +2454,7 @@ pub fn resize( // Resize our tabstops if (self.cols != cols) { self.tabstops.deinit(alloc); - self.tabstops = try Tabstops.init(alloc, cols, 8); + self.tabstops = try .init(alloc, cols, 8); } // If we're making the screen smaller, dealloc the unused items. @@ -2515,39 +2515,37 @@ pub fn getScreen(self: *Terminal, t: ScreenType) *Screen { &self.secondary_screen; } -/// Options for switching to the alternate screen. -pub const AlternateScreenOptions = struct { - cursor_save: bool = false, - clear_on_enter: bool = false, - clear_on_exit: bool = false, -}; - -/// Switch to the alternate screen buffer. +/// Switch to the given screen type (alternate or primary). /// -/// The alternate screen buffer: -/// * has its own grid -/// * has its own cursor state (included saved cursor) -/// * does not support scrollback +/// This does NOT handle behaviors such as clearing the screen, +/// copying the cursor, etc. This should be handled by downstream +/// callers. /// -pub fn alternateScreen( - self: *Terminal, - options: AlternateScreenOptions, -) void { - //log.info("alt screen active={} options={} cursor={}", .{ self.active_screen, options, self.screen.cursor }); +/// After calling this function, the `self.screen` field will point +/// to the current screen, and the returned value will be the previous +/// screen. If the return value is null, then the screen was not +/// switched because it was already the active screen. +/// +/// Note: This is written in a generic way so that we can support +/// more than two screens in the future if needed. There isn't +/// currently a spec for this, but it is something I think might +/// be useful in the future. +pub fn switchScreen(self: *Terminal, t: ScreenType) ?*Screen { + // If we're already on the requested screen we do nothing. + if (self.active_screen == t) return null; - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - // for now, we ignore... - if (self.active_screen == .alternate) return; - - // If we requested cursor save, we save the cursor in the primary screen - if (options.cursor_save) self.saveCursor(); + // We always end hyperlink state when switching screens. + // We need to do this on the original screen. + self.screen.endHyperlink(); // Switch the screens const old = self.screen; self.screen = self.secondary_screen; self.secondary_screen = old; - self.active_screen = .alternate; + self.active_screen = t; + + // The new screen should not have any hyperlinks set + assert(self.screen.cursor.hyperlink_id == 0); // Bring our charset state with us self.screen.charset = old.charset; @@ -2555,62 +2553,122 @@ pub fn alternateScreen( // Clear our selection self.screen.clearSelection(); - // Mark kitty images as dirty so they redraw + // Mark kitty images as dirty so they redraw. Without this set + // the images will remain where they were (the dirty bit on + // the screen only tracks the terminal grid, not the images). self.screen.kitty_images.dirty = true; - // Mark our terminal as dirty + // Mark our terminal as dirty to redraw the grid. self.flags.dirty.clear = true; - // Bring our pen with us - self.screen.cursorCopy(old.cursor, .{ - .hyperlink = false, - }) catch |err| { - log.warn("cursor copy failed entering alt screen err={}", .{err}); - }; + return &self.secondary_screen; +} - if (options.clear_on_enter) { - self.eraseDisplay(.complete, false); +/// Switch screen via a mode switch (e.g. mode 47, 1047, 1049). +/// This is a much more opinionated operation than `switchScreen` +/// since it also handles the behaviors of the specific mode, +/// such as clearing the screen, saving/restoring the cursor, +/// etc. +/// +/// This should be used for legacy compatibility with VT protocols, +/// but more modern usage should use `switchScreen` instead and handle +/// details like clearing the screen, cursor saving, etc. manually. +pub fn switchScreenMode( + self: *Terminal, + mode: SwitchScreenMode, + enabled: bool, +) void { + // The behavior in this function is completely based on reading + // the xterm source, specifically "charproc.c" for + // `srm_ALTBUF`, `srm_OPT_ALTBUF`, and `srm_OPT_ALTBUF_CURSOR`. + // We shouldn't touch anything in here without adding a unit + // test AND verifying the behavior with xterm. + + switch (mode) { + .@"47" => {}, + + // If we're disabling 1047 and we're on alt screen then + // we clear the screen. + .@"1047" => if (!enabled and self.active_screen == .alternate) { + self.eraseDisplay(.complete, false); + }, + + // 1049 unconditionally saves the cursor on enabling, even + // if we're already on the alternate screen. + .@"1049" => if (enabled) self.saveCursor(), + } + + // Switch screens first to whatever we're going to. + const to: ScreenType = if (enabled) .alternate else .primary; + const old_ = self.switchScreen(to); + + switch (mode) { + // For these modes, we need to copy the cursor. We only copy + // the cursor if the screen actually changed, otherwise the + // cursor is already copied. The cursor is copied regardless + // of destination screen. + .@"47", .@"1047" => if (old_) |old| { + self.screen.cursorCopy(old.cursor, .{ + .hyperlink = false, + }) catch |err| { + log.warn( + "cursor copy failed entering alt screen err={}", + .{err}, + ); + }; + }, + + // Mode 1049 restores cursor on the primary screen when + // we disable it. + .@"1049" => if (enabled) { + assert(self.active_screen == .alternate); + self.eraseDisplay(.complete, false); + + // When we enter alt screen with 1049, we always copy the + // cursor from the primary screen (if we weren't already + // on it). + if (old_) |old| { + self.screen.cursorCopy(old.cursor, .{ + .hyperlink = false, + }) catch |err| { + log.warn( + "cursor copy failed entering alt screen err={}", + .{err}, + ); + }; + } + } else { + assert(self.active_screen == .primary); + self.restoreCursor() catch |err| { + log.warn( + "restore cursor on switch screen failed to={} err={}", + .{ to, err }, + ); + }; + }, } } -/// Switch back to the primary screen (reset alternate screen mode). -pub fn primaryScreen( - self: *Terminal, - options: AlternateScreenOptions, -) void { - //log.info("primary screen active={} options={}", .{ self.active_screen, options }); +/// Modal screen changes. These map to the literal terminal +/// modes to enable or disable alternate screen modes. They each +/// have subtle behaviors so we define them as an enum here. +pub const SwitchScreenMode = enum { + /// Legacy alternate screen mode. This goes to the alternate + /// screen or primary screen and only copies the cursor. The + /// screen is not erased. + @"47", - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - if (self.active_screen == .primary) return; + /// Alternate screen mode where the alternate screen is cleared + /// on exit. The primary screen is never cleared. The cursor is + /// copied. + @"1047", - if (options.clear_on_exit) self.eraseDisplay(.complete, false); - - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .primary; - - // Clear our selection - self.screen.clearSelection(); - - // Mark kitty images as dirty so they redraw - self.screen.kitty_images.dirty = true; - - // Mark our terminal as dirty - self.flags.dirty.clear = true; - - // We always end hyperlink state - self.screen.endHyperlink(); - - // Restore the cursor from the primary screen. This should not - // fail because we should not have to allocate memory since swapping - // screens does not create new cursors. - if (options.cursor_save) self.restoreCursor() catch |err| { - log.warn("restore cursor on primary screen failed err={}", .{err}); - }; -} + /// Save primary screen cursor, switch to alternate screen, + /// and clear the alternate screen on entry. On exit, + /// do not clear the screen, and restore the cursor on the + /// primary screen. + @"1049", +}; /// Return the current string value of the terminal. Newlines are /// encoded as "\n". This omits any formatting such as fg/bg. @@ -9203,37 +9261,6 @@ test "Terminal: saveCursor" { try testing.expect(t.modes.get(.origin)); } -test "Terminal: saveCursor with screen change" { - const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 3 }); - defer t.deinit(alloc); - - try t.setAttribute(.{ .bold = {} }); - t.setCursorPos(t.screen.cursor.y + 1, 3); - try testing.expect(t.screen.cursor.x == 2); - t.screen.charset.gr = .G3; - t.modes.set(.origin, true); - t.alternateScreen(.{ - .cursor_save = true, - .clear_on_enter = true, - }); - // make sure our cursor and charset have come with us - try testing.expect(t.screen.cursor.style.flags.bold); - try testing.expect(t.screen.cursor.x == 2); - try testing.expect(t.screen.charset.gr == .G3); - try testing.expect(t.modes.get(.origin)); - t.screen.charset.gr = .G0; - try t.setAttribute(.{ .reset_bold = {} }); - t.modes.set(.origin, false); - t.primaryScreen(.{ - .cursor_save = true, - .clear_on_enter = true, - }); - try testing.expect(t.screen.cursor.style.flags.bold); - try testing.expect(t.screen.charset.gr == .G3); - try testing.expect(t.modes.get(.origin)); -} - test "Terminal: saveCursor position" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 10, .rows = 5 }); @@ -10472,7 +10499,7 @@ test "Terminal: cursorIsAtPrompt alternate screen" { try testing.expect(t.cursorIsAtPrompt()); // Secondary screen is never a prompt - t.alternateScreen(.{}); + t.switchScreenMode(.@"1049", true); try testing.expect(!t.cursorIsAtPrompt()); t.markSemanticPrompt(.prompt); try testing.expect(!t.cursorIsAtPrompt()); @@ -10556,7 +10583,7 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" { var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - t.alternateScreen(.{}); + t.switchScreenMode(.@"1049", true); t.screen.kitty_keyboard.push(.{ .disambiguate = true, .report_events = false, @@ -10564,7 +10591,7 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" { .report_all = true, .report_associated = true, }); - t.primaryScreen(.{}); + t.switchScreenMode(.@"1049", false); t.fullReset(); try testing.expectEqual(0, t.secondary_screen.kitty_keyboard.current().int()); @@ -10869,3 +10896,236 @@ test "Terminal: DECCOLM resets scroll region" { try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); try testing.expectEqual(@as(usize, 79), t.scrolling_region.right); } + +test "Terminal: mode 47 alt screen plain" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Print on primary screen + try t.printString("1A"); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"47", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Print on alt screen. This should be off center because + // we copy the cursor over from the primary screen + try t.printString("2B"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } + + // Go back to primary + t.switchScreenMode(.@"47", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Primary screen should still have the original content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A", str); + } + + // Go back to alt screen with mode 47 + t.switchScreenMode(.@"47", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should retain content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } +} + +test "Terminal: mode 47 copies cursor both directions" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Color our cursor red + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"47", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Verify that our style is set + { + try testing.expect(t.screen.cursor.style_id != style.default_id); + const page = &t.screen.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + } + + // Set a new style + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } }); + + // Go back to primary + t.switchScreenMode(.@"47", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Verify that our style is still set + { + try testing.expect(t.screen.cursor.style_id != style.default_id); + const page = &t.screen.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + } +} + +test "Terminal: mode 1047 alt screen plain" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Print on primary screen + try t.printString("1A"); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"1047", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Print on alt screen. This should be off center because + // we copy the cursor over from the primary screen + try t.printString("2B"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } + + // Go back to primary + t.switchScreenMode(.@"1047", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Primary screen should still have the original content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A", str); + } + + // Go back to alt screen with mode 1047 + t.switchScreenMode(.@"1047", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: mode 1047 copies cursor both directions" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Color our cursor red + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"1047", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Verify that our style is set + { + try testing.expect(t.screen.cursor.style_id != style.default_id); + const page = &t.screen.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + } + + // Set a new style + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } }); + + // Go back to primary + t.switchScreenMode(.@"1047", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Verify that our style is still set + { + try testing.expect(t.screen.cursor.style_id != style.default_id); + const page = &t.screen.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + } +} + +test "Terminal: mode 1049 alt screen plain" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Print on primary screen + try t.printString("1A"); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"1049", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Print on alt screen. This should be off center because + // we copy the cursor over from the primary screen + try t.printString("2B"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } + + // Go back to primary + t.switchScreenMode(.@"1049", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Primary screen should still have the original content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A", str); + } + + // Write, our cursor should be restored back. + try t.printString("C"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1AC", str); + } + + // Go back to alt screen with mode 1049 + t.switchScreenMode(.@"1049", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index f96d39831..68d968768 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -403,7 +403,7 @@ test "BitmapAllocator alloc sequentially" { const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); defer alloc.free(buf); - var bm = Alloc.init(OffsetBuf.init(buf), layout); + var bm = Alloc.init(.init(buf), layout); const ptr = try bm.alloc(u8, buf, 1); ptr[0] = 'A'; @@ -429,7 +429,7 @@ test "BitmapAllocator alloc non-byte" { const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); defer alloc.free(buf); - var bm = Alloc.init(OffsetBuf.init(buf), layout); + var bm = Alloc.init(.init(buf), layout); const ptr = try bm.alloc(u21, buf, 1); ptr[0] = 'A'; @@ -453,7 +453,7 @@ test "BitmapAllocator alloc non-byte multi-chunk" { const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); defer alloc.free(buf); - var bm = Alloc.init(OffsetBuf.init(buf), layout); + var bm = Alloc.init(.init(buf), layout); const ptr = try bm.alloc(u21, buf, 6); try testing.expectEqual(@as(usize, 6), ptr.len); for (ptr) |*v| v.* = 'A'; @@ -478,7 +478,7 @@ test "BitmapAllocator alloc large" { const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); defer alloc.free(buf); - var bm = Alloc.init(OffsetBuf.init(buf), layout); + var bm = Alloc.init(.init(buf), layout); const ptr = try bm.alloc(u8, buf, 129); ptr[0] = 'A'; bm.free(buf, ptr); diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index 0cc17a747..9a16be3b2 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -893,7 +893,7 @@ test "HashMap basic usage" { const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); const count = 5; var i: u32 = 0; @@ -927,7 +927,7 @@ test "HashMap ensureTotalCapacity" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); const initial_capacity = map.capacity(); try testing.expect(initial_capacity >= 20); @@ -947,7 +947,7 @@ test "HashMap ensureUnusedCapacity with tombstones" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: i32 = 0; while (i < 100) : (i += 1) { @@ -965,7 +965,7 @@ test "HashMap clearRetainingCapacity" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); map.clearRetainingCapacity(); @@ -996,7 +996,7 @@ test "HashMap ensureTotalCapacity with existing elements" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); try map.put(0, 0); try expectEqual(map.count(), 1); @@ -1015,7 +1015,7 @@ test "HashMap remove" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1053,7 +1053,7 @@ test "HashMap reverse removes" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1081,7 +1081,7 @@ test "HashMap multiple removes on same metadata" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1124,7 +1124,7 @@ test "HashMap put and remove loop in random order" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var keys = std.ArrayList(u32).init(alloc); defer keys.deinit(); @@ -1162,7 +1162,7 @@ test "HashMap put" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1193,7 +1193,7 @@ test "HashMap put full load" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); for (0..cap) |i| try map.put(i, i); for (0..cap) |i| try expectEqual(map.get(i).?, i); @@ -1209,7 +1209,7 @@ test "HashMap putAssumeCapacity" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 20) : (i += 1) { @@ -1244,7 +1244,7 @@ test "HashMap repeat putAssumeCapacity/remove" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); const limit = cap; @@ -1280,7 +1280,7 @@ test "HashMap getOrPut" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 10) : (i += 1) { @@ -1309,7 +1309,7 @@ test "HashMap basic hash map usage" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); try testing.expect((try map.fetchPut(1, 11)) == null); try testing.expect((try map.fetchPut(2, 22)) == null); @@ -1360,7 +1360,7 @@ test "HashMap ensureUnusedCapacity" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); try map.ensureUnusedCapacity(32); try testing.expectError(error.OutOfMemory, map.ensureUnusedCapacity(cap + 1)); @@ -1374,7 +1374,7 @@ test "HashMap removeByPtr" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: i32 = undefined; i = 0; @@ -1405,7 +1405,7 @@ test "HashMap removeByPtr 0 sized key" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); try map.put(0, 0); @@ -1429,7 +1429,7 @@ test "HashMap repeat fetchRemove" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); map.putAssumeCapacity(0, {}); map.putAssumeCapacity(1, {}); @@ -1457,7 +1457,7 @@ test "OffsetHashMap basic usage" { const layout = OffsetMap.layout(cap); const buf = try alloc.alignedAlloc(u8, OffsetMap.base_align, layout.total_size); defer alloc.free(buf); - var offset_map = OffsetMap.init(OffsetBuf.init(buf), layout); + var offset_map = OffsetMap.init(.init(buf), layout); var map = offset_map.map(buf.ptr); const count = 5; @@ -1492,7 +1492,7 @@ test "OffsetHashMap remake map" { const layout = OffsetMap.layout(cap); const buf = try alloc.alignedAlloc(u8, OffsetMap.base_align, layout.total_size); defer alloc.free(buf); - var offset_map = OffsetMap.init(OffsetBuf.init(buf), layout); + var offset_map = OffsetMap.init(.init(buf), layout); { var map = offset_map.map(buf.ptr); diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index 61ba33a4d..adc6edafe 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -155,17 +155,17 @@ pub const Parser = struct { break :action c; }; const control: Command.Control = switch (action) { - 'q' => .{ .query = try Transmission.parse(self.kv) }, - 't' => .{ .transmit = try Transmission.parse(self.kv) }, + 'q' => .{ .query = try .parse(self.kv) }, + 't' => .{ .transmit = try .parse(self.kv) }, 'T' => .{ .transmit_and_display = .{ - .transmission = try Transmission.parse(self.kv), - .display = try Display.parse(self.kv), + .transmission = try .parse(self.kv), + .display = try .parse(self.kv), } }, - 'p' => .{ .display = try Display.parse(self.kv) }, - 'd' => .{ .delete = try Delete.parse(self.kv) }, - 'f' => .{ .transmit_animation_frame = try AnimationFrameLoading.parse(self.kv) }, - 'a' => .{ .control_animation = try AnimationControl.parse(self.kv) }, - 'c' => .{ .compose_animation = try AnimationFrameComposition.parse(self.kv) }, + 'p' => .{ .display = try .parse(self.kv) }, + 'd' => .{ .delete = try .parse(self.kv) }, + 'f' => .{ .transmit_animation_frame = try .parse(self.kv) }, + 'a' => .{ .control_animation = try .parse(self.kv) }, + 'c' => .{ .compose_animation = try .parse(self.kv) }, else => return error.InvalidFormat, }; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 25c819b10..f917c104a 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -324,7 +324,7 @@ fn loadAndAddImage( } break :loading loading.*; - } else try LoadingImage.init(alloc, cmd); + } else try .init(alloc, cmd); // We only want to deinit on error. If we're chunking, then we don't // want to deinit at all. If we're not chunking, then we'll deinit diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 06769dc3c..0c3022e4a 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -658,6 +658,86 @@ pub const ImageStorage = struct { } } + /// Calculates the size of this placement's image in pixels, + /// taking in to account the specified rows and columns. + pub fn calculatedSize( + self: Placement, + image: Image, + t: *const terminal.Terminal, + ) struct { + width: u32, + height: u32, + } { + // Height / width of the image in px. + const width = if (self.source_width > 0) self.source_width else image.width; + const height = if (self.source_height > 0) self.source_height else image.height; + + // If we don't have any specified cols or rows then the placement + // should be the native size of the image, and doesn't need to be + // re-scaled. + if (self.columns == 0 and self.rows == 0) return .{ + .width = width, + .height = height, + }; + + // We calculate the size of a cell so that we can multiply + // it by the specified cols/rows to get the correct px size. + // + // We assume that the width is divided evenly by the column + // count and the height by the row count, because it should be. + const cell_width: u32 = t.width_px / t.cols; + const cell_height: u32 = t.height_px / t.rows; + + const width_f64: f64 = @floatFromInt(width); + const height_f64: f64 = @floatFromInt(height); + + // If we have a specified cols AND rows then we calculate + // the width and height from them directly, we don't need + // to adjust for aspect ratio. + if (self.columns > 0 and self.rows > 0) { + const calc_width = cell_width * self.columns; + const calc_height = cell_height * self.rows; + + return .{ + .width = calc_width, + .height = calc_height, + }; + } + + // Either the columns or the rows were specified, but not both, + // so we need to calculate the other one based on the aspect ratio. + + // If only the columns were specified, we determine + // the height of the image based on the aspect ratio. + if (self.columns > 0) { + const aspect = height_f64 / width_f64; + const calc_width: u32 = cell_width * self.columns; + const calc_height: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(calc_width)) * aspect, + )); + + return .{ + .width = calc_width, + .height = calc_height, + }; + } + + // Otherwise, only the rows were specified, so we + // determine the width based on the aspect ratio. + { + const aspect = width_f64 / height_f64; + const calc_height: u32 = cell_height * self.rows; + const calc_width: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(calc_height)) * aspect, + )); + + return .{ + .width = calc_width, + .height = calc_height, + }; + } + } + /// Returns the size in grid cells that this placement takes up. pub fn gridSize( self: Placement, @@ -667,60 +747,29 @@ pub const ImageStorage = struct { cols: u32, rows: u32, } { + // If we have a specified columns and rows then this is trivial. if (self.columns > 0 and self.rows > 0) return .{ .cols = self.columns, .rows = self.rows, }; - // Calculate our cell size. - const terminal_width_f64: f64 = @floatFromInt(t.width_px); - const terminal_height_f64: f64 = @floatFromInt(t.height_px); - const grid_columns_f64: f64 = @floatFromInt(t.cols); - const grid_rows_f64: f64 = @floatFromInt(t.rows); - const cell_width_f64 = terminal_width_f64 / grid_columns_f64; - const cell_height_f64 = terminal_height_f64 / grid_rows_f64; - - // Our image width - const width_px = if (self.source_width > 0) self.source_width else image.width; - const height_px = if (self.source_height > 0) self.source_height else image.height; - - // Calculate our image size in grid cells - const width_f64: f64 = @floatFromInt(width_px); - const height_f64: f64 = @floatFromInt(height_px); - - // If only columns is specified, calculate rows based on aspect ratio - if (self.columns > 0 and self.rows == 0) { - const cols_f64: f64 = @floatFromInt(self.columns); - const cols_px = cols_f64 * cell_width_f64; - const aspect_ratio = height_f64 / width_f64; - const rows_px = cols_px * aspect_ratio; - const rows_cells = rows_px / cell_height_f64; - return .{ - .cols = self.columns, - .rows = @intFromFloat(@ceil(rows_cells)), - }; - } - - // If only rows is specified, calculate columns based on aspect ratio - if (self.rows > 0 and self.columns == 0) { - const rows_f64: f64 = @floatFromInt(self.rows); - const rows_px = rows_f64 * cell_height_f64; - const aspect_ratio = width_f64 / height_f64; - const cols_px = rows_px * aspect_ratio; - const cols_cells = cols_px / cell_width_f64; - return .{ - .cols = @intFromFloat(@ceil(cols_cells)), - .rows = self.rows, - }; - } - - const width_cells: u32 = @intFromFloat(@ceil(width_f64 / cell_width_f64)); - const height_cells: u32 = @intFromFloat(@ceil(height_f64 / cell_height_f64)); - + // Otherwise we calculate the pixel size, divide by + // cell size, and round up to the nearest integer. + const calc_size = self.calculatedSize(image, t); return .{ - .cols = width_cells, - .rows = height_cells, + .cols = std.math.divCeil( + u32, + calc_size.width + self.x_offset, + t.width_px / t.cols, + ) catch 0, + .rows = std.math.divCeil( + u32, + calc_size.height + self.y_offset, + t.height_px / t.rows, + ) catch 0, }; + // NOTE: Above `divCeil`s can only fail if the cell size is 0, + // in such a case it seems safe to return 0 for this. } /// Returns a selection of the entire rectangle this placement @@ -1269,36 +1318,42 @@ test "storage: aspect ratio calculation when only columns or rows specified" { var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); defer t.deinit(alloc); - t.width_px = 100; - t.height_px = 100; + t.width_px = 1000; // 10 px per col + t.height_px = 2000; // 20 px per row // Case 1: Only columns specified { - const image = Image{ .id = 1, .width = 4, .height = 2 }; + const image = Image{ .id = 1, .width = 16, .height = 9 }; var placement = ImageStorage.Placement{ .location = .{ .virtual = {} }, - .columns = 6, + .columns = 10, .rows = 0, }; - const grid_size = placement.gridSize(image, &t); - // 6 columns * (2/4) = 3 rows - try testing.expectEqual(@as(u32, 6), grid_size.cols); - try testing.expectEqual(@as(u32, 3), grid_size.rows); + // Image is 16x9, set to a width of 10 columns, at 10px per column + // that's 100px width. 100px * (9 / 16) = 56.25, which should round + // to a height of 56px. + + const calc_size = placement.calculatedSize(image, &t); + try testing.expectEqual(@as(u32, 100), calc_size.width); + try testing.expectEqual(@as(u32, 56), calc_size.height); } // Case 2: Only rows specified { - const image = Image{ .id = 2, .width = 2, .height = 4 }; + const image = Image{ .id = 2, .width = 16, .height = 9 }; var placement = ImageStorage.Placement{ .location = .{ .virtual = {} }, .columns = 0, - .rows = 6, + .rows = 5, }; - const grid_size = placement.gridSize(image, &t); - // 6 rows * (2/4) = 3 columns - try testing.expectEqual(@as(u32, 3), grid_size.cols); - try testing.expectEqual(@as(u32, 6), grid_size.rows); + // Image is 16x9, set to a height of 5 rows, at 20px per row that's + // 100px height. 100px * (16 / 9) = 177.77..., which should round to + // a width of 178px. + + const calc_size = placement.calculatedSize(image, &t); + try testing.expectEqual(@as(u32, 178), calc_size.width); + try testing.expectEqual(@as(u32, 100), calc_size.height); } } diff --git a/src/terminal/kitty/key.zig b/src/terminal/kitty/key.zig index a04bd181a..0883c90f2 100644 --- a/src/terminal/kitty/key.zig +++ b/src/terminal/kitty/key.zig @@ -8,7 +8,7 @@ const std = @import("std"); pub const FlagStack = struct { const len = 8; - flags: [len]Flags = .{Flags{}} ** len, + flags: [len]Flags = @splat(.{}), idx: u3 = 0, /// Return the current stack value @@ -51,7 +51,7 @@ pub const FlagStack = struct { // could send a huge number of pop commands to waste cpu. if (n >= self.flags.len) { self.idx = 0; - self.flags = .{Flags{}} ** len; + self.flags = @splat(.{}); return; } @@ -83,6 +83,15 @@ pub const Flags = packed struct(u5) { report_all: bool = false, report_associated: bool = false, + /// Sets all modes on. + pub const @"true": Flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }; + pub fn int(self: Flags) u5 { return @bitCast(self); } diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index 60ecc7698..9a74db73c 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -206,6 +206,7 @@ const entries: []const ModeEntry = &.{ .{ .name = "cursor_visible", .value = 25, .default = true }, .{ .name = "enable_mode_3", .value = 40 }, .{ .name = "reverse_wrap", .value = 45 }, + .{ .name = "alt_screen_legacy", .value = 47 }, .{ .name = "keypad_keys", .value = 66 }, .{ .name = "enable_left_and_right_margin", .value = 69 }, .{ .name = "mouse_event_normal", .value = 1000 }, @@ -222,6 +223,7 @@ const entries: []const ModeEntry = &.{ .{ .name = "alt_sends_escape", .value = 1039 }, .{ .name = "reverse_wrap_extended", .value = 1045 }, .{ .name = "alt_screen", .value = 1047 }, + .{ .name = "save_cursor", .value = 1048 }, .{ .name = "alt_screen_save_cursor_clear_enter", .value = 1049 }, .{ .name = "bracketed_paste", .value = 2004 }, .{ .name = "synchronized_output", .value = 2026 }, diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index faf376d13..d0b59e834 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -6,6 +6,7 @@ const osc = @This(); const std = @import("std"); +const builtin = @import("builtin"); const mem = std.mem; const assert = std.debug.assert; const Allocator = mem.Allocator; @@ -108,37 +109,21 @@ pub const Command = union(enum) { value: []const u8, }, - /// OSC 4, OSC 10, and OSC 11 color report. - report_color: struct { - /// OSC 4 requests a palette color, OSC 10 requests the foreground - /// color, OSC 11 the background color. - kind: ColorKind, - - /// We must reply with the same string terminator (ST) as used in the - /// request. + /// OSC color operations to set, reset, or report color settings. Some OSCs + /// allow multiple operations to be specified in a single OSC so we need a + /// list-like datastructure to manage them. We use std.SegmentedList because + /// it minimizes the number of allocations and copies because a large + /// majority of the time there will be only one operation per OSC. + /// + /// Currently, these OSCs are handled by `color_operation`: + /// + /// 4, 10, 11, 12, 104, 110, 111, 112 + color_operation: struct { + source: ColorOperation.Source, + operations: ColorOperation.List = .{}, terminator: Terminator = .st, }, - /// Modify the foreground (OSC 10) or background color (OSC 11), or a palette color (OSC 4) - set_color: struct { - /// OSC 4 sets a palette color, OSC 10 sets the foreground color, OSC 11 - /// the background color. - kind: ColorKind, - - /// The color spec as a string - value: []const u8, - }, - - /// Reset a palette color (OSC 104) or the foreground (OSC 110), background - /// (OSC 111), or cursor (OSC 112) color. - reset_color: struct { - kind: ColorKind, - - /// OSC 104 can have parameters indicating which palette colors to - /// reset. - value: []const u8, - }, - /// Kitty color protocol, OSC 21 /// https://sw.kovidgoyal.net/kitty/color-stack/#id1 kitty_color_protocol: kitty.color.OSC, @@ -181,20 +166,44 @@ pub const Command = union(enum) { /// Wait input (OSC 9;5) wait_input: void, - pub const ColorKind = union(enum) { - palette: u8, - foreground, - background, - cursor, + pub const ColorOperation = union(enum) { + pub const Source = enum(u16) { + // these numbers are based on the OSC operation code + // see https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands + get_set_palette = 4, + get_set_foreground = 10, + get_set_background = 11, + get_set_cursor = 12, + reset_palette = 104, + reset_foreground = 110, + reset_background = 111, + reset_cursor = 112, - pub fn code(self: ColorKind) []const u8 { - return switch (self) { - .palette => "4", - .foreground => "10", - .background => "11", - .cursor => "12", - }; - } + pub fn format( + self: Source, + comptime _: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + try std.fmt.formatInt(@intFromEnum(self), 10, .lower, options, writer); + } + }; + + pub const List = std.SegmentedList(ColorOperation, 2); + + pub const Kind = union(enum) { + palette: u8, + foreground, + background, + cursor, + }; + + set: struct { + kind: Kind, + color: RGB, + }, + reset: Kind, + report: Kind, }; pub const ProgressState = enum { @@ -204,6 +213,15 @@ pub const Command = union(enum) { indeterminate, pause, }; + + comptime { + assert(@sizeOf(Command) == switch (@sizeOf(usize)) { + 4 => 44, + 8 => 64, + else => unreachable, + }); + // @compileLog(@sizeOf(Command)); + } }; /// The terminator used to end an OSC command. For OSC commands that demand @@ -233,6 +251,15 @@ pub const Terminator = enum { .bel => "\x07", }; } + + pub fn format( + self: Terminator, + comptime _: []const u8, + _: std.fmt.FormatOptions, + writer: anytype, + ) !void { + try writer.writeAll(self.string()); + } }; pub const Parser = struct { @@ -287,6 +314,7 @@ pub const Parser = struct { @"0", @"1", @"10", + @"104", @"11", @"12", @"13", @@ -303,15 +331,6 @@ pub const Parser = struct { @"8", @"9", - // OSC 10 is used to query or set the current foreground color. - query_fg_color, - - // OSC 11 is used to query or set the current background color. - query_bg_color, - - // OSC 12 is used to query or set the current cursor color. - query_cursor_color, - // We're in a semantic prompt OSC command but we aren't sure // what the command is yet, i.e. `133;` semantic_prompt, @@ -326,17 +345,26 @@ pub const Parser = struct { clipboard_kind_end, // Get/set color palette index - color_palette_index, - color_palette_index_end, + osc_4_index, + osc_4_color, + + // Get/set foreground color + osc_10, + + // Get/set background color + osc_11, + + // Get/set cursor color + osc_12, + + // Reset color palette index + osc_104, // Hyperlinks hyperlink_param_key, hyperlink_param_value, hyperlink_uri, - // Reset color palette index - reset_color_palette_index, - // rxvt extension. Only used for OSC 777 and only the value "notify" is // supported rxvt_extension, @@ -422,6 +450,10 @@ pub const Parser = struct { v.list.deinit(); self.command = default; }, + .color_operation => |*v| { + v.operations.deinit(self.alloc.?); + self.command = default; + }, else => {}, } } @@ -501,41 +533,123 @@ pub const Parser = struct { }, .@"10" => switch (c) { - ';' => self.state = .query_fg_color, - '4' => { - self.command = .{ .reset_color = .{ - .kind = .{ .palette = 0 }, - .value = "", - } }; + ';' => osc_10: { + if (self.alloc == null) { + log.warn("OSC 10 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_10; + } + self.command = .{ + .color_operation = .{ + .source = .get_set_foreground, + }, + }; + self.state = .osc_10; + self.buf_start = self.buf_idx; + self.complete = true; + }, + '4' => self.state = .@"104", + else => self.state = .invalid, + }, - self.state = .reset_color_palette_index; + .osc_10, .osc_11, .osc_12 => switch (c) { + ';' => self.parseOSC101112(false), + else => {}, + }, + + .@"104" => switch (c) { + ';' => osc_104: { + if (self.alloc == null) { + log.warn("OSC 104 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_104; + } + self.command = .{ + .color_operation = .{ + .source = .reset_palette, + }, + }; + self.state = .osc_104; + self.buf_start = self.buf_idx; self.complete = true; }, else => self.state = .invalid, }, + .osc_104 => switch (c) { + ';' => self.parseOSC104(false), + else => {}, + }, + .@"11" => switch (c) { - ';' => self.state = .query_bg_color, - '0' => { - self.command = .{ .reset_color = .{ .kind = .foreground, .value = undefined } }; + ';' => osc_11: { + if (self.alloc == null) { + log.warn("OSC 11 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_11; + } + self.command = .{ + .color_operation = .{ + .source = .get_set_background, + }, + }; + self.state = .osc_11; + self.buf_start = self.buf_idx; self.complete = true; - self.state = .invalid; }, - '1' => { - self.command = .{ .reset_color = .{ .kind = .background, .value = undefined } }; + '0'...'2' => blk: { + if (self.alloc == null) { + log.warn("OSC 11{c} requires an allocator, but none was provided", .{c}); + self.state = .invalid; + break :blk; + } + + const alloc = self.alloc orelse return; + + self.command = .{ + .color_operation = .{ + .source = switch (c) { + '0' => .reset_foreground, + '1' => .reset_background, + '2' => .reset_cursor, + else => unreachable, + }, + }, + }; + const op = self.command.color_operation.operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .reset = switch (c) { + '0' => .foreground, + '1' => .background, + '2' => .cursor, + else => unreachable, + }, + }; + self.state = .swallow; self.complete = true; - self.state = .invalid; - }, - '2' => { - self.command = .{ .reset_color = .{ .kind = .cursor, .value = undefined } }; - self.complete = true; - self.state = .invalid; }, else => self.state = .invalid, }, .@"12" => switch (c) { - ';' => self.state = .query_cursor_color, + ';' => osc_12: { + if (self.alloc == null) { + log.warn("OSC 12 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_12; + } + self.command = .{ + .color_operation = .{ + .source = .get_set_cursor, + }, + }; + self.state = .osc_12; + self.buf_start = self.buf_idx; + self.complete = true; + }, else => self.state = .invalid, }, @@ -620,64 +734,35 @@ pub const Parser = struct { }, .@"4" => switch (c) { - ';' => { - self.state = .color_palette_index; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .color_palette_index => switch (c) { - '0'...'9' => {}, - ';' => blk: { - const str = self.buf[self.buf_start .. self.buf_idx - 1]; - if (str.len == 0) { + ';' => osc_4: { + if (self.alloc == null) { + log.info("OSC 4 requires an allocator, but none was provided", .{}); self.state = .invalid; - break :blk; + break :osc_4; } - - if (std.fmt.parseUnsigned(u8, str, 10)) |num| { - self.state = .color_palette_index_end; - self.temp_state = .{ .num = num }; - } else |err| switch (err) { - error.Overflow => self.state = .invalid, - error.InvalidCharacter => unreachable, - } - }, - else => self.state = .invalid, - }, - - .color_palette_index_end => switch (c) { - '?' => { - self.command = .{ .report_color = .{ - .kind = .{ .palette = @intCast(self.temp_state.num) }, - } }; - + self.command = .{ + .color_operation = .{ + .source = .get_set_palette, + }, + }; + self.state = .osc_4_index; + self.buf_start = self.buf_idx; self.complete = true; }, - else => { - self.command = .{ .set_color = .{ - .kind = .{ .palette = @intCast(self.temp_state.num) }, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, + else => self.state = .invalid, }, - .reset_color_palette_index => switch (c) { + .osc_4_index => switch (c) { + ';' => self.state = .osc_4_color, + else => {}, + }, + + .osc_4_color => switch (c) { ';' => { - self.state = .string; - self.temp_state = .{ .str = &self.command.reset_color.value }; - self.buf_start = self.buf_idx; - self.complete = false; - }, - else => { - self.state = .invalid; - self.complete = false; + self.parseOSC4(false); + self.state = .osc_4_index; }, + else => {}, }, .@"5" => switch (c) { @@ -968,60 +1053,6 @@ pub const Parser = struct { }, }, - .query_fg_color => switch (c) { - '?' => { - self.command = .{ .report_color = .{ .kind = .foreground } }; - self.complete = true; - self.state = .invalid; - }, - else => { - self.command = .{ .set_color = .{ - .kind = .foreground, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, - }, - - .query_bg_color => switch (c) { - '?' => { - self.command = .{ .report_color = .{ .kind = .background } }; - self.complete = true; - self.state = .invalid; - }, - else => { - self.command = .{ .set_color = .{ - .kind = .background, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, - }, - - .query_cursor_color => switch (c) { - '?' => { - self.command = .{ .report_color = .{ .kind = .cursor } }; - self.complete = true; - self.state = .invalid; - }, - else => { - self.command = .{ .set_color = .{ - .kind = .cursor, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, - }, - .semantic_prompt => switch (c) { 'A' => { self.state = .semantic_option_start; @@ -1326,13 +1357,183 @@ pub const Parser = struct { self.temp_state.str.* = list.items; } + fn parseOSC4(self: *Parser, final: bool) void { + assert(self.state == .osc_4_color); + assert(self.command == .color_operation); + assert(self.command.color_operation.source == .get_set_palette); + + const alloc = self.alloc orelse return; + const operations = &self.command.color_operation.operations; + + const str = self.buf[self.buf_start .. self.buf_idx - (1 - @intFromBool(final))]; + self.buf_start = 0; + self.buf_idx = 0; + + var it = std.mem.splitScalar(u8, str, ';'); + const index_str = it.next() orelse { + log.warn("OSC 4 is missing palette index", .{}); + return; + }; + const spec_str = it.next() orelse { + log.warn("OSC 4 is missing color spec", .{}); + return; + }; + const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) { + error.Overflow, error.InvalidCharacter => { + log.warn("invalid color palette index in OSC 4: {s} {}", .{ index_str, err }); + return; + }, + }; + if (std.mem.eql(u8, spec_str, "?")) { + const op = operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .report = .{ .palette = index }, + }; + } else { + const color = RGB.parse(spec_str) catch |err| { + log.warn("invalid color specification in OSC 4: '{s}' {}", .{ spec_str, err }); + return; + }; + const op = operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .set = .{ + .kind = .{ + .palette = index, + }, + .color = color, + }, + }; + } + } + + fn parseOSC101112(self: *Parser, final: bool) void { + assert(switch (self.state) { + .osc_10, .osc_11, .osc_12 => true, + else => false, + }); + assert(self.command == .color_operation); + assert(self.command.color_operation.source == switch (self.state) { + .osc_10 => Command.ColorOperation.Source.get_set_foreground, + .osc_11 => Command.ColorOperation.Source.get_set_background, + .osc_12 => Command.ColorOperation.Source.get_set_cursor, + else => unreachable, + }); + + const spec_str = self.buf[self.buf_start .. self.buf_idx - (1 - @intFromBool(final))]; + + if (self.command.color_operation.operations.count() > 0) { + // don't emit the warning if the string is empty + if (spec_str.len == 0) return; + + log.warn("OSC 1{s} can only accept 1 color", .{switch (self.state) { + .osc_10 => "0", + .osc_11 => "1", + .osc_12 => "2", + else => unreachable, + }}); + return; + } + + if (spec_str.len == 0) { + log.warn("OSC 1{s} requires an argument", .{switch (self.state) { + .osc_10 => "0", + .osc_11 => "1", + .osc_12 => "2", + else => unreachable, + }}); + return; + } + + const alloc = self.alloc orelse return; + const operations = &self.command.color_operation.operations; + + if (std.mem.eql(u8, spec_str, "?")) { + const op = operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .report = switch (self.state) { + .osc_10 => .foreground, + .osc_11 => .background, + .osc_12 => .cursor, + else => unreachable, + }, + }; + } else { + const color = RGB.parse(spec_str) catch |err| { + log.warn("invalid color specification in OSC 1{s}: {s} {}", .{ + switch (self.state) { + .osc_10 => "0", + .osc_11 => "1", + .osc_12 => "2", + else => unreachable, + }, + spec_str, + err, + }); + return; + }; + const op = operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .set = .{ + .kind = switch (self.state) { + .osc_10 => .foreground, + .osc_11 => .background, + .osc_12 => .cursor, + else => unreachable, + }, + .color = color, + }, + }; + } + } + + fn parseOSC104(self: *Parser, final: bool) void { + assert(self.state == .osc_104); + assert(self.command == .color_operation); + assert(self.command.color_operation.source == .reset_palette); + + const alloc = self.alloc orelse return; + + const index_str = self.buf[self.buf_start .. self.buf_idx - (1 - @intFromBool(final))]; + self.buf_start = 0; + self.buf_idx = 0; + + const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) { + error.Overflow, error.InvalidCharacter => { + log.warn("invalid color palette index in OSC 104: {s} {}", .{ index_str, err }); + return; + }, + }; + const op = self.command.color_operation.operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .reset = .{ .palette = index }, + }; + } + /// End the sequence and return the command, if any. If the return value /// is null, then no valid command was found. The optional terminator_ch /// is the final character in the OSC sequence. This is used to determine /// the response terminator. pub fn end(self: *Parser, terminator_ch: ?u8) ?Command { if (!self.complete) { - log.warn("invalid OSC command: {s}", .{self.buf[0..self.buf_idx]}); + if (comptime !builtin.is_test) log.warn( + "invalid OSC command: {s}", + .{self.buf[0..self.buf_idx]}, + ); return null; } @@ -1346,12 +1547,15 @@ pub const Parser = struct { .allocable_string => self.endAllocableString(), .kitty_color_protocol_key => self.endKittyColorProtocolOption(.key_only, true), .kitty_color_protocol_value => self.endKittyColorProtocolOption(.key_and_value, true), + .osc_4_color => self.parseOSC4(true), + .osc_10, .osc_11, .osc_12 => self.parseOSC101112(true), + .osc_104 => self.parseOSC104(true), else => {}, } switch (self.command) { - .report_color => |*c| c.terminator = Terminator.init(terminator_ch), - .kitty_color_protocol => |*c| c.terminator = Terminator.init(terminator_ch), + .kitty_color_protocol => |*c| c.terminator = .init(terminator_ch), + .color_operation => |*c| c.terminator = .init(terminator_ch), else => {}, } @@ -1560,17 +1764,109 @@ test "OSC: end_of_input" { try testing.expect(cmd == .end_of_input); } -test "OSC: reset cursor color" { +test "OSC: OSC110: reset foreground color" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "110"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .reset_foreground); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind.foreground, + op.reset, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC111: reset background color" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "111"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .reset_background); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind.background, + op.reset, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC112: reset cursor color" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "112"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; - try testing.expect(cmd == .reset_color); - try testing.expectEqual(cmd.reset_color.kind, .cursor); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .reset_cursor); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind.cursor, + op.reset, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC112: reset cursor color with semicolon" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "112;"; + for (input) |ch| p.next(ch); + log.warn("finish: {s}", .{@tagName(p.state)}); + + const cmd = p.end(0x07).?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .reset_cursor); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind.cursor, + op.reset, + ); + } + try testing.expect(it.next() == null); } test "OSC: get/set clipboard" { @@ -1603,9 +1899,8 @@ test "OSC: get/set clipboard (optional parameter)" { test "OSC: get/set clipboard with allocator" { const testing = std.testing; - const alloc = testing.allocator; - var p: Parser = .{ .alloc = alloc }; + var p: Parser = .{ .alloc = testing.allocator }; defer p.deinit(); const input = "52;s;?"; @@ -1667,90 +1962,746 @@ test "OSC: longer than buffer" { try testing.expect(p.complete == false); } -test "OSC: report default foreground color" { +test "OSC: OSC10: report foreground color" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "10;?"; for (input) |ch| p.next(ch); // This corresponds to ST = ESC followed by \ const cmd = p.end('\x1b').?; - try testing.expect(cmd == .report_color); - try testing.expectEqual(cmd.report_color.kind, .foreground); - try testing.expectEqual(cmd.report_color.terminator, .st); + + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .get_set_foreground); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind.foreground, + op.report, + ); + } + try testing.expect(it.next() == null); } -test "OSC: set foreground color" { +test "OSC: OSC10: set foreground color" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "10;rgbi:0.0/0.5/1.0"; for (input) |ch| p.next(ch); const cmd = p.end('\x07').?; - try testing.expect(cmd == .set_color); - try testing.expectEqual(cmd.set_color.kind, .foreground); - try testing.expectEqualStrings(cmd.set_color.value, "rgbi:0.0/0.5/1.0"); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .get_set_foreground); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind.foreground, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0x00, .g = 0x7f, .b = 0xff }, + op.set.color, + ); + } + try testing.expect(it.next() == null); } -test "OSC: report default background color" { +test "OSC: OSC11: report background color" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "11;?"; for (input) |ch| p.next(ch); // This corresponds to ST = BEL character const cmd = p.end('\x07').?; - try testing.expect(cmd == .report_color); - try testing.expectEqual(cmd.report_color.kind, .background); - try testing.expectEqual(cmd.report_color.terminator, .bel); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .get_set_background); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind.background, + op.report, + ); + } + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(it.next() == null); } -test "OSC: set background color" { +test "OSC: OSC11: set background color" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "11;rgb:f/ff/ffff"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .set_color); - try testing.expectEqual(cmd.set_color.kind, .background); - try testing.expectEqualStrings(cmd.set_color.value, "rgb:f/ff/ffff"); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .get_set_background); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind.background, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xff, .g = 0xff, .b = 0xff }, + op.set.color, + ); + } + try testing.expect(it.next() == null); } -test "OSC: get palette color" { +test "OSC: OSC12: report cursor color" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "12;?"; + for (input) |ch| p.next(ch); + + // This corresponds to ST = BEL character + const cmd = p.end('\x07').?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .get_set_cursor); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind.cursor, + op.report, + ); + } + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(it.next() == null); +} + +test "OSC: OSC12: set cursor color" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "12;rgb:f/ff/ffff"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .get_set_cursor); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind.cursor, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xff, .g = 0xff, .b = 0xff }, + op.set.color, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: get palette color 1" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;1;?"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .report_color); - try testing.expectEqual(Command.ColorKind{ .palette = 1 }, cmd.report_color.kind); - try testing.expectEqual(cmd.report_color.terminator, .st); + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.report, + ); + try testing.expectEqual(cmd.color_operation.terminator, .st); + } + try testing.expect(it.next() == null); } -test "OSC: set palette color" { +test "OSC: OSC4: get palette color 2" { const testing = std.testing; - var p: Parser = .{}; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;1;?;2;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 2); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 2 }, + op.report, + ); + } + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: set palette color 1" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;17;rgb:aa/bb/cc"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .set_color); - try testing.expectEqual(Command.ColorKind{ .palette = 17 }, cmd.set_color.kind); - try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc"); + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 17 }, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + op.set.color, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: set palette color 2" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;17;rgb:aa/bb/cc;1;rgb:00/11/22"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 2); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 17 }, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + op.set.color, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0x00, .g = 0x11, .b = 0x22 }, + op.set.color, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: get with invalid index 1" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;1111;?;1;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: get with invalid index 2" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;5;?;1111;?;1;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 2); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 5 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +// Inspired by Microsoft Edit +test "OSC: OSC4: multiple get 8a" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 8); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 0 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 2 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 3 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 4 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 5 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 6 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 7 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +// Inspired by Microsoft Edit +test "OSC: OSC4: multiple get 8b" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 8); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 8 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 9 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 10 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 11 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 12 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 13 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 14 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 15 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: set with invalid index" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;256;#ffffff;1;#aabbcc"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 1 }, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + op.set.color, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: mix get/set palette color" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;17;rgb:aa/bb/cc;254;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 2); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .set); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 17 }, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + op.set.color, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 254 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: incomplete color/spec 1" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;17"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 0); + var it = cmd.color_operation.operations.constIterator(0); + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: incomplete color/spec 2" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;17;?;42"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 17 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC104: reset palette color 1" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "104;17"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .reset_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 17 }, + op.reset, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC104: reset palette color 2" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "104;17;111"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .reset_palette); + try testing.expectEqual(2, cmd.color_operation.operations.count()); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 17 }, + op.reset, + ); + } + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 111 }, + op.reset, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC104: invalid palette index" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "104;ffff;111"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .reset_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 111 }, + op.reset, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC104: empty palette index" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "104;;111"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .reset_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .reset); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 111 }, + op.reset, + ); + } + try std.testing.expect(it.next() == null); } test "OSC: conemu sleep" { diff --git a/src/terminal/page.zig b/src/terminal/page.zig index acb757592..fea16c28b 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -241,23 +241,23 @@ pub const Page = struct { l.styles_layout, .{}, ), - .string_alloc = StringAlloc.init( + .string_alloc = .init( buf.add(l.string_alloc_start), l.string_alloc_layout, ), - .grapheme_alloc = GraphemeAlloc.init( + .grapheme_alloc = .init( buf.add(l.grapheme_alloc_start), l.grapheme_alloc_layout, ), - .grapheme_map = GraphemeMap.init( + .grapheme_map = .init( buf.add(l.grapheme_map_start), l.grapheme_map_layout, ), - .hyperlink_map = hyperlink.Map.init( + .hyperlink_map = .init( buf.add(l.hyperlink_map_start), l.hyperlink_map_layout, ), - .hyperlink_set = hyperlink.Set.init( + .hyperlink_set = .init( buf.add(l.hyperlink_set_start), l.hyperlink_set_layout, .{}, @@ -280,7 +280,7 @@ pub const Page = struct { // We zero the page memory as u64 instead of u8 because // we can and it's empirically quite a bit faster. @memset(@as([*]u64, @ptrCast(self.memory))[0 .. self.memory.len / 8], 0); - self.* = initBuf(OffsetBuf.init(self.memory), layout(self.capacity)); + self.* = initBuf(.init(self.memory), layout(self.capacity)); } pub const IntegrityError = error{ @@ -1316,7 +1316,12 @@ pub const Page = struct { /// Set the graphemes for the given cell. This asserts that the cell /// has no graphemes set, and only contains a single codepoint. - pub fn setGraphemes(self: *Page, row: *Row, cell: *Cell, cps: []u21) GraphemeError!void { + pub fn setGraphemes( + self: *Page, + row: *Row, + cell: *Cell, + cps: []const u21, + ) GraphemeError!void { defer self.assertIntegrity(); assert(cell.codepoint() > 0); @@ -2260,7 +2265,7 @@ test "Page appendGrapheme small" { defer page.deinit(); const rac = page.getRowAndCell(0, 0); - rac.cell.* = Cell.init(0x09); + rac.cell.* = .init(0x09); // One try page.appendGrapheme(rac.row, rac.cell, 0x0A); @@ -2289,7 +2294,7 @@ test "Page appendGrapheme larger than chunk" { defer page.deinit(); const rac = page.getRowAndCell(0, 0); - rac.cell.* = Cell.init(0x09); + rac.cell.* = .init(0x09); const count = grapheme_chunk_len * 10; for (0..count) |i| { @@ -2312,11 +2317,11 @@ test "Page clearGrapheme not all cells" { defer page.deinit(); const rac = page.getRowAndCell(0, 0); - rac.cell.* = Cell.init(0x09); + rac.cell.* = .init(0x09); try page.appendGrapheme(rac.row, rac.cell, 0x0A); const rac2 = page.getRowAndCell(1, 0); - rac2.cell.* = Cell.init(0x09); + rac2.cell.* = .init(0x09); try page.appendGrapheme(rac2.row, rac2.cell, 0x0A); // Clear it diff --git a/src/terminal/point.zig b/src/terminal/point.zig index 12b71014b..f2544f90c 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -3,10 +3,12 @@ const Allocator = std.mem.Allocator; const assert = std.debug.assert; const size = @import("size.zig"); -/// The possible reference locations for a point. When someone says "(42, 80)" in the context of a terminal, that could mean multiple -/// things: it is in the current visible viewport? the current active -/// area of the screen where the cursor is? the entire scrollback history? -/// etc. This tag is used to differentiate those cases. +/// The possible reference locations for a point. When someone says "(42, 80)" +/// in the context of a terminal, that could mean multiple things: it is in the +/// current visible viewport? the current active area of the screen where the +/// cursor is? the entire scrollback history? etc. +/// +/// This tag is used to differentiate those cases. pub const Tag = enum { /// Top-left is part of the active area where a running program can /// jump the cursor and make changes. The active area is the "editable" diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index 8023461f3..153e331a6 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -115,7 +115,7 @@ pub fn RefCountedSet( /// input. We handle this gracefully by returning an error /// anywhere where we're about to insert if there's any /// item with a PSL in the last slot of the stats array. - psl_stats: [32]Id = [_]Id{0} ** 32, + psl_stats: [32]Id = @splat(0), /// The backing store of items items: Offset(Item), @@ -663,7 +663,7 @@ pub fn RefCountedSet( const table = self.table.ptr(base); const items = self.items.ptr(base); - var psl_stats: [32]Id = [_]Id{0} ** 32; + var psl_stats: [32]Id = @splat(0); for (items[0..self.layout.cap], 0..) |item, id| { if (item.meta.bucket < std.math.maxInt(Id)) { @@ -676,7 +676,7 @@ pub fn RefCountedSet( assert(std.mem.eql(Id, &psl_stats, &self.psl_stats)); - psl_stats = [_]Id{0} ** 32; + psl_stats = @splat(0); for (table[0..self.layout.table_cap], 0..) |id, bucket| { const item = items[id]; diff --git a/src/terminal/search.zig b/src/terminal/search.zig index 56b181c48..2f87f894b 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -365,7 +365,7 @@ const SlidingWindow = struct { } self.assertIntegrity(); - return Selection.init(tl, br, false); + return .init(tl, br, false); } /// Convert a data index into a pin. @@ -417,7 +417,7 @@ const SlidingWindow = struct { // Initialize our metadata for the node. var meta: Meta = .{ .node = node, - .cell_map = Page.CellMap.init(alloc), + .cell_map = .init(alloc), }; errdefer meta.deinit(); diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index 2bc32c5f9..e4b85fbdd 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -98,7 +98,7 @@ pub const Attribute = union(enum) { /// Parser parses the attributes from a list of SGR parameters. pub const Parser = struct { params: []const u16, - params_sep: SepList = SepList.initEmpty(), + params_sep: SepList = .initEmpty(), idx: usize = 0, /// Next returns the next attribute or null if there are no more attributes. @@ -376,7 +376,7 @@ fn testParse(params: []const u16) Attribute { } fn testParseColon(params: []const u16) Attribute { - var p: Parser = .{ .params = params, .params_sep = SepList.initFull() }; + var p: Parser = .{ .params = params, .params_sep = .initFull() }; return p.next().?; } diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 76fa6c129..fd30720b3 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1555,23 +1555,9 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, - .report_color => |v| { - if (@hasDecl(T, "reportColor")) { - try self.handler.reportColor(v.kind, v.terminator); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .set_color => |v| { - if (@hasDecl(T, "setColor")) { - try self.handler.setColor(v.kind, v.value); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .reset_color => |v| { - if (@hasDecl(T, "resetColor")) { - try self.handler.resetColor(v.kind, v.value); + .color_operation => |v| { + if (@hasDecl(T, "handleColorOperation")) { + try self.handler.handleColorOperation(v.source, &v.operations, v.terminator); return; } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 7f176561b..f35a4e1f7 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -87,10 +87,9 @@ pub const Style = struct { /// True if the style is equal to another style. pub fn eql(self: Style, other: Style) bool { - const packed_self = PackedStyle.fromStyle(self); - const packed_other = PackedStyle.fromStyle(other); - // TODO: in Zig 0.14, equating packed structs is allowed. Remove this work around. - return @as(u128, @bitCast(packed_self)) == @as(u128, @bitCast(packed_other)); + // We convert the styles to packed structs and compare as integers + // because this is much faster than comparing each field separately. + return PackedStyle.fromStyle(self) == PackedStyle.fromStyle(other); } /// Returns the bg color for a cell with this style given the cell @@ -303,9 +302,9 @@ pub const Style = struct { .underline = std.meta.activeTag(style.underline_color), }, .data = .{ - .fg = Data.fromColor(style.fg_color), - .bg = Data.fromColor(style.bg_color), - .underline = Data.fromColor(style.underline_color), + .fg = .fromColor(style.fg_color), + .bg = .fromColor(style.bg_color), + .underline = .fromColor(style.underline_color), }, .flags = style.flags, }; @@ -350,7 +349,7 @@ test "Set basic usage" { const style: Style = .{ .flags = .{ .bold = true } }; const style2: Style = .{ .flags = .{ .italic = true } }; - var set = Set.init(OffsetBuf.init(buf), layout, .{}); + var set = Set.init(.init(buf), layout, .{}); // Add style const id = try set.add(buf, style); diff --git a/src/terminal/x11_color.zig b/src/terminal/x11_color.zig index 88bc30f09..977cd4538 100644 --- a/src/terminal/x11_color.zig +++ b/src/terminal/x11_color.zig @@ -33,7 +33,7 @@ fn colorMap() !ColorMap { } assert(i == len); - return ColorMap.initComptime(kvs); + return .initComptime(kvs); } /// This is the rgb.txt file from the X11 project. This was last sourced diff --git a/src/terminfo/Source.zig b/src/terminfo/Source.zig index 8ffd9cabb..7692e6f54 100644 --- a/src/terminfo/Source.zig +++ b/src/terminfo/Source.zig @@ -74,7 +74,7 @@ pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([]const u8) { // We have all of our capabilities plus To, TN, and RGB which aren't // in the capabilities list but are query-able. const len = self.capabilities.len + 3; - var kvs: [len]KV = .{.{ "", "" }} ** len; + var kvs: [len]KV = @splat(.{ "", "" }); // We first build all of our entries with raw K=V pairs. kvs[0] = .{ "TN", self.names[0] }; diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 23c626879..317ad13b4 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -418,25 +418,27 @@ fn processExitCommon(td: *termio.Termio.ThreadData, exit_code: u32) void { return; } + // We output a message so that the user knows whats going on and + // doesn't think their terminal just froze. We show this unconditionally + // on close even if `wait_after_command` is false and the surface closes + // immediately because if a user does an `undo` to restore a closed + // surface then they will see this message and know the process has + // completed. + terminal: { + td.renderer_state.mutex.lock(); + defer td.renderer_state.mutex.unlock(); + const t = td.renderer_state.terminal; + t.carriageReturn(); + t.linefeed() catch break :terminal; + t.printString("Process exited. Press any key to close the terminal.") catch + break :terminal; + t.modes.set(.cursor_visible, false); + } + // If we're purposely waiting then we just return since the process // exited flag is set to true. This allows the terminal window to remain // open. - if (execdata.wait_after_command) { - // We output a message so that the user knows whats going on and - // doesn't think their terminal just froze. - terminal: { - td.renderer_state.mutex.lock(); - defer td.renderer_state.mutex.unlock(); - const t = td.renderer_state.terminal; - t.carriageReturn(); - t.linefeed() catch break :terminal; - t.printString("Process exited. Press any key to close the terminal.") catch - break :terminal; - t.modes.set(.cursor_visible, false); - } - - return; - } + if (execdata.wait_after_command) return; // Notify our surface we want to close _ = td.surface_mailbox.push(.{ diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 299c7cd45..2069a8ff2 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -582,36 +582,33 @@ pub const StreamHandler = struct { self.terminal.scrolling_region.right = self.terminal.cols - 1; }, + .alt_screen_legacy => { + self.terminal.switchScreenMode(.@"47", enabled); + try self.queueRender(); + }, + .alt_screen => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = false, - .clear_on_enter = false, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens + self.terminal.switchScreenMode(.@"1047", enabled); try self.queueRender(); }, .alt_screen_save_cursor_clear_enter => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = true, - .clear_on_enter = true, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens + self.terminal.switchScreenMode(.@"1049", enabled); try self.queueRender(); }, + // Mode 1048 is xterm's conditional save cursor depending + // on if alt screen is enabled or not (at the terminal emulator + // level). Alt screen is always enabled for us so this just + // does a save/restore cursor. + .save_cursor => { + if (enabled) { + self.terminal.saveCursor(); + } else { + try self.terminal.restoreCursor(); + } + }, + // Force resize back to the window size .enable_mode_3 => { const grid_size = self.size.grid(); @@ -1185,200 +1182,185 @@ pub const StreamHandler = struct { } } - /// Implements OSC 4, OSC 10, and OSC 11, which reports palette color, - /// default foreground color, and background color respectively. - pub fn reportColor( + pub fn handleColorOperation( self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, + source: terminal.osc.Command.ColorOperation.Source, + operations: *const terminal.osc.Command.ColorOperation.List, terminator: terminal.osc.Terminator, ) !void { - if (self.osc_color_report_format == .none) return; + // return early if there is nothing to do + if (operations.count() == 0) return; - const color = switch (kind) { - .palette => |i| self.terminal.color_palette.colors[i], - .foreground => self.foreground_color orelse self.default_foreground_color, - .background => self.background_color orelse self.default_background_color, - .cursor => self.cursor_color orelse - self.default_cursor_color orelse - self.foreground_color orelse - self.default_foreground_color, - }; + var buffer: [1024]u8 = undefined; + var fba: std.heap.FixedBufferAllocator = .init(&buffer); + const alloc = fba.allocator(); - var msg: termio.Message = .{ .write_small = .{} }; - const resp = switch (self.osc_color_report_format) { - .@"16-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - i, - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), - }, + var response: std.ArrayListUnmanaged(u8) = .empty; + const writer = response.writer(alloc); - .@"8-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - i, - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - }, - .none => unreachable, // early return above - }; - msg.write_small.len = @intCast(resp.len); - self.messageWriter(msg); - } + var report: bool = false; - pub fn setColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - const color = try terminal.color.RGB.parse(value); + try writer.print("\x1b]{}", .{source}); - switch (kind) { - .palette => |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = color; - self.terminal.color_palette.mask.set(i); - }, - .foreground => { - self.foreground_color = color; - _ = self.renderer_mailbox.push(.{ - .foreground_color = color, - }, .{ .forever = {} }); - }, - .background => { - self.background_color = color; - _ = self.renderer_mailbox.push(.{ - .background_color = color, - }, .{ .forever = {} }); - }, - .cursor => { - self.cursor_color = color; - _ = self.renderer_mailbox.push(.{ - .cursor_color = color, - }, .{ .forever = {} }); - }, - } + var it = operations.constIterator(0); - // Notify the surface of the color change - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = kind, - .color = color, - } }); - } - - pub fn resetColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - switch (kind) { - .palette => { - const mask = &self.terminal.color_palette.mask; - if (value.len == 0) { - // Find all bit positions in the mask which are set and - // reset those indices to the default palette - var it = mask.iterator(.{}); - while (it.next()) |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); - - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .{ .palette = @intCast(i) }, - .color = self.terminal.color_palette.colors[i], - } }); + while (it.next()) |op| { + switch (op.*) { + .set => |set| { + switch (set.kind) { + .palette => |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = set.color; + self.terminal.color_palette.mask.set(i); + }, + .foreground => { + self.foreground_color = set.color; + _ = self.renderer_mailbox.push(.{ + .foreground_color = set.color, + }, .{ .forever = {} }); + }, + .background => { + self.background_color = set.color; + _ = self.renderer_mailbox.push(.{ + .background_color = set.color, + }, .{ .forever = {} }); + }, + .cursor => { + self.cursor_color = set.color; + _ = self.renderer_mailbox.push(.{ + .cursor_color = set.color, + }, .{ .forever = {} }); + }, } - } else { - var it = std.mem.tokenizeScalar(u8, value, ';'); - while (it.next()) |param| { - // Skip invalid parameters - const i = std.fmt.parseUnsigned(u8, param, 10) catch continue; - if (mask.isSet(i)) { + + // Notify the surface of the color change + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = set.kind, + .color = set.color, + } }); + }, + + .reset => |kind| { + switch (kind) { + .palette => |i| { + const mask = &self.terminal.color_palette.mask; self.terminal.flags.dirty.palette = true; self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; mask.unset(i); + self.surfaceMessageWriter(.{ + .color_change = .{ + .kind = .{ .palette = @intCast(i) }, + .color = self.terminal.color_palette.colors[i], + }, + }); + }, + .foreground => { + self.foreground_color = null; + _ = self.renderer_mailbox.push(.{ + .foreground_color = self.foreground_color, + }, .{ .forever = {} }); + self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .{ .palette = @intCast(i) }, - .color = self.terminal.color_palette.colors[i], + .kind = .foreground, + .color = self.default_foreground_color, } }); - } + }, + .background => { + self.background_color = null; + _ = self.renderer_mailbox.push(.{ + .background_color = self.background_color, + }, .{ .forever = {} }); + + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = .background, + .color = self.default_background_color, + } }); + }, + .cursor => { + self.cursor_color = null; + + _ = self.renderer_mailbox.push(.{ + .cursor_color = self.cursor_color, + }, .{ .forever = {} }); + + if (self.default_cursor_color) |color| { + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = .cursor, + .color = color, + } }); + } + }, } - } - }, - .foreground => { - self.foreground_color = null; - _ = self.renderer_mailbox.push(.{ - .foreground_color = self.foreground_color, - }, .{ .forever = {} }); + }, - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .foreground, - .color = self.default_foreground_color, - } }); - }, - .background => { - self.background_color = null; - _ = self.renderer_mailbox.push(.{ - .background_color = self.background_color, - }, .{ .forever = {} }); + .report => |kind| report: { + if (self.osc_color_report_format == .none) break :report; - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .background, - .color = self.default_background_color, - } }); - }, - .cursor => { - self.cursor_color = null; + report = true; - _ = self.renderer_mailbox.push(.{ - .cursor_color = self.cursor_color, - }, .{ .forever = {} }); + const color = switch (kind) { + .palette => |i| self.terminal.color_palette.colors[i], + .foreground => self.foreground_color orelse self.default_foreground_color, + .background => self.background_color orelse self.default_background_color, + .cursor => self.cursor_color orelse + self.default_cursor_color orelse + self.foreground_color orelse + self.default_foreground_color, + }; - if (self.default_cursor_color) |color| { - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .cursor, - .color = color, - } }); - } - }, + switch (self.osc_color_report_format) { + .@"16-bit" => switch (kind) { + .palette => |i| try writer.print( + ";{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}", + .{ + i, + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + }, + ), + else => try writer.print( + ";rgb:{x:0>4}/{x:0>4}/{x:0>4}", + .{ + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + }, + ), + }, + + .@"8-bit" => switch (kind) { + .palette => |i| try writer.print( + ";{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}", + .{ + i, + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + }, + ), + else => try writer.print( + ";rgb:{x:0>2}/{x:0>2}/{x:0>2}", + .{ + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + }, + ), + }, + + .none => unreachable, + } + }, + } + } + if (report) { + // If any of the operations were reports, finalize the report + // string and send it to the terminal. + try writer.writeAll(terminator.string()); + const msg = try termio.Message.writeReq(self.alloc, response.items); + self.messageWriter(msg); } } diff --git a/src/unicode/props.zig b/src/unicode/props.zig index 8c7621b79..99c57aa0a 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -125,7 +125,7 @@ pub fn get(cp: u21) Properties { return .{ .width = @intCast(@min(2, @max(0, zg_width))), - .grapheme_boundary_class = GraphemeBoundaryClass.init(cp), + .grapheme_boundary_class = .init(cp), }; } diff --git a/typos.toml b/typos.toml index 4f4bf7ee7..fafc38858 100644 --- a/typos.toml +++ b/typos.toml @@ -49,6 +49,8 @@ grey = "gray" greyscale = "grayscale" DECID = "DECID" flate = "flate" +typ = "typ" +kend = "kend" [type.po] extend-glob = ["*.po"]