diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 2dd6a13e7..522847c88 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -34,7 +34,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index d920b92f1..6fa813c31 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -8,7 +8,7 @@ jobs: runs-on: namespace-profile-ghostty-sm needs: [build-macos-debug] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install sentry-cli run: | @@ -29,7 +29,7 @@ jobs: runs-on: namespace-profile-ghostty-sm needs: [build-macos] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install sentry-cli run: | @@ -51,7 +51,7 @@ jobs: timeout-minutes: 90 steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -205,7 +205,7 @@ jobs: timeout-minutes: 90 steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Important so that build number generation works fetch-depth: 0 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index c0a051753..9c92d45a9 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -56,7 +56,7 @@ jobs: fi - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -80,7 +80,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -128,7 +128,7 @@ jobs: GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2 with: @@ -299,7 +299,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Download macOS Artifacts uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 616ee84fe..58e114f1b 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -19,7 +19,7 @@ jobs: runs-on: namespace-profile-ghostty-sm needs: [build-macos] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Tip Tag run: | git config user.name "github-actions[bot]" @@ -31,7 +31,7 @@ jobs: runs-on: namespace-profile-ghostty-sm needs: [build-macos-debug-slow] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install sentry-cli run: | @@ -52,7 +52,7 @@ jobs: runs-on: namespace-profile-ghostty-sm needs: [build-macos-debug-fast] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install sentry-cli run: | @@ -73,7 +73,7 @@ jobs: runs-on: namespace-profile-ghostty-sm needs: [build-macos] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install sentry-cli run: | @@ -105,7 +105,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -158,7 +158,7 @@ jobs: timeout-minutes: 90 steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -378,7 +378,7 @@ jobs: timeout-minutes: 90 steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -558,7 +558,7 @@ jobs: timeout-minutes: 90 steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Important so that build number generation works fetch-depth: 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75db53a4d..c00816b38 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,7 @@ jobs: - translations - blueprint-compiler - test-pkg-linux - - test-debian-12 + - test-debian-13 - zig-fmt steps: - id: status @@ -67,7 +67,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -98,7 +98,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -134,7 +134,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -163,7 +163,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -196,7 +196,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -240,7 +240,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -276,7 +276,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # Install Nix and use that to run our tests so our environment matches exactly. - uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2 @@ -319,7 +319,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -359,7 +359,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # Install Nix and use that to run our tests so our environment matches exactly. - uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2 @@ -437,7 +437,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # This could be from a script if we wanted to but inlining here for now # in one place. @@ -506,7 +506,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -551,7 +551,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -600,7 +600,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -648,7 +648,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -675,7 +675,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # Install Nix and use that to run our tests so our environment matches exactly. - uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2 @@ -704,7 +704,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -732,7 +732,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -759,7 +759,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -786,7 +786,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -813,7 +813,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -840,7 +840,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -874,7 +874,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -901,7 +901,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -935,7 +935,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -957,8 +957,8 @@ jobs: run: | nix develop -c sh -c "cd pkg/${{ matrix.pkg }} ; zig build test" - test-debian-12: - name: Test build on Debian 12 + test-debian-13: + name: Test build on Debian 13 runs-on: namespace-profile-ghostty-sm needs: [test, build-dist] steps: @@ -984,7 +984,7 @@ jobs: context: dist file: dist/src/build/docker/debian/Dockerfile build-args: | - DISTRO_VERSION=12 + DISTRO_VERSION=13 flatpak-check-zig-cache: if: github.repository == 'ghostty-org/ghostty' @@ -994,7 +994,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -1030,7 +1030,7 @@ jobs: runs-on: ${{ matrix.variant.runner }} needs: [flatpak-check-zig-cache, test] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: flatpak/flatpak-github-actions/flatpak-builder@10a3c29f0162516f0f68006be14c92f34bd4fa6c # v6.5 with: bundle: com.mitchellh.ghostty diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index d614814ad..e1ee92168 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -17,7 +17,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 diff --git a/build.zig.zon b/build.zig.zon index 4110effd4..9698f81c7 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -20,8 +20,8 @@ }, .z2d = .{ // vancluever/z2d - .url = "https://github.com/vancluever/z2d/archive/8bbd035f4101f02b1d27947def0d7da3215df7fe.tar.gz", - .hash = "z2d-0.7.0-j5P_Hg_DDACq-2H2Zh7rAq6_TXWdQzv7JAUfnrdeDosg", + .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.7.1.tar.gz", + .hash = "z2d-0.7.1-j5P_HhFQDQC6T5srG235r_nQpCrNwI15Gu2zqVrtUxN5", .lazy = true, }, .zig_objc = .{ diff --git a/build.zig.zon.json b/build.zig.zon.json index 24f1053ba..2ed4f63ce 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -129,10 +129,10 @@ "url": "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz", "hash": "sha256-nkzSCr6W5sTG7enDBXEIhgEm574uLD41UVR2wlC+HBM=" }, - "z2d-0.7.0-j5P_Hg_DDACq-2H2Zh7rAq6_TXWdQzv7JAUfnrdeDosg": { + "z2d-0.7.1-j5P_HhFQDQC6T5srG235r_nQpCrNwI15Gu2zqVrtUxN5": { "name": "z2d", - "url": "https://github.com/vancluever/z2d/archive/8bbd035f4101f02b1d27947def0d7da3215df7fe.tar.gz", - "hash": "sha256-wfadegeixcbgxRzRtf6M2H34CTuvDM22XVIhuufFBG4=" + "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.7.1.tar.gz", + "hash": "sha256-6ZqgrO/bcjgnuQcfq89VYptUUodsErM8Fz6nwBZhTJs=" }, "zf-0.10.3-OIRy8aiIAACLrBllz0zjxaH0aOe5oNm3KtEMyCntST-9": { "name": "zf", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 380bafaeb..b1b19be37 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -290,11 +290,11 @@ in }; } { - name = "z2d-0.7.0-j5P_Hg_DDACq-2H2Zh7rAq6_TXWdQzv7JAUfnrdeDosg"; + name = "z2d-0.7.1-j5P_HhFQDQC6T5srG235r_nQpCrNwI15Gu2zqVrtUxN5"; path = fetchZigArtifact { name = "z2d"; - url = "https://github.com/vancluever/z2d/archive/8bbd035f4101f02b1d27947def0d7da3215df7fe.tar.gz"; - hash = "sha256-wfadegeixcbgxRzRtf6M2H34CTuvDM22XVIhuufFBG4="; + url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.7.1.tar.gz"; + hash = "sha256-6ZqgrO/bcjgnuQcfq89VYptUUodsErM8Fz6nwBZhTJs="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 14bb0e8df..849c679fc 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -32,4 +32,4 @@ https://github.com/mbadolato/iTerm2-Color-Schemes/archive/3cbeca99efa10beba24b0e https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz -https://github.com/vancluever/z2d/archive/8bbd035f4101f02b1d27947def0d7da3215df7fe.tar.gz +https://github.com/vancluever/z2d/archive/refs/tags/v0.7.1.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index d50371f5f..a67ccef59 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -157,9 +157,9 @@ }, { "type": "archive", - "url": "https://github.com/vancluever/z2d/archive/8bbd035f4101f02b1d27947def0d7da3215df7fe.tar.gz", - "dest": "vendor/p/z2d-0.7.0-j5P_Hg_DDACq-2H2Zh7rAq6_TXWdQzv7JAUfnrdeDosg", - "sha256": "c1f69d7a07a2c5c6e0c51cd1b5fe8cd87df8093baf0ccdb65d5221bae7c5046e" + "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.7.1.tar.gz", + "dest": "vendor/p/z2d-0.7.1-j5P_HhFQDQC6T5srG235r_nQpCrNwI15Gu2zqVrtUxN5", + "sha256": "e99aa0acefdb723827b9071fabcf55629b5452876c12b33c173ea7c016614c9b" }, { "type": "archive", diff --git a/include/ghostty.h b/include/ghostty.h index c422c3584..082711836 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -757,6 +757,7 @@ typedef enum { GHOSTTY_ACTION_OPEN_URL, GHOSTTY_ACTION_SHOW_CHILD_EXITED, GHOSTTY_ACTION_PROGRESS_REPORT, + GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, } ghostty_action_tag_e; typedef union { diff --git a/src/Surface.zig b/src/Surface.zig index 1ab0fc59e..866505717 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4804,6 +4804,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .show_on_screen_keyboard => return try self.rt_app.performAction( + .{ .surface = self }, + .show_on_screen_keyboard, + {}, + ), + .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { diff --git a/src/apprt.zig b/src/apprt.zig index 706287302..6c1f040ea 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -60,20 +60,22 @@ pub const Runtime = enum { /// This is only useful if you're only interested in the lib only (macOS). none, - /// GTK-backed. Rich windowed application. GTK is dynamically linked. - gtk, - - /// GTK4. The "-ng" variant is a rewrite of the GTK backend using - /// GTK-native technologies such as full GObject classes, Blueprint - /// files, etc. + /// GTK4. Rich windowed application. This uses a full GObject-based + /// approach to building the application. @"gtk-ng", + /// GTK-backed. Rich windowed application. GTK is dynamically linked. + /// WARNING: Deprecated. This will be removed very soon. All bug fixes + /// and features should go into the gtk-ng backend. + gtk, + pub fn default(target: std.Target) Runtime { - // The Linux default is GTK because it is full featured. - if (target.os.tag == .linux) return .gtk; + // The Linux default is GTK because it is a full featured application. + if (target.os.tag == .linux) return .@"gtk-ng"; // Otherwise, we do NONE so we don't create an exe and we - // create libghostty. + // create libghostty. On macOS, Xcode is used to build the app + // that links to libghostty. return .none; } }; diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 0f2b68087..d2d444c3a 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -291,6 +291,9 @@ pub const Action = union(Key) { /// Show a native GUI notification about the progress of some TUI operation. progress_report: terminal.osc.Command.ProgressReport, + /// Show the on-screen keyboard. + show_on_screen_keyboard, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -345,6 +348,7 @@ pub const Action = union(Key) { open_url, show_child_exited, progress_report, + show_on_screen_keyboard, }; /// Sync with: ghostty_action_u diff --git a/src/apprt/gtk-ng.zig b/src/apprt/gtk-ng.zig index de9255fe9..fe1bac023 100644 --- a/src/apprt/gtk-ng.zig +++ b/src/apprt/gtk-ng.zig @@ -11,4 +11,5 @@ pub const WeakRef = @import("gtk-ng/weak_ref.zig").WeakRef; test { @import("std").testing.refAllDecls(@This()); + _ = @import("gtk-ng/ext.zig"); } diff --git a/src/apprt/gtk-ng/App.zig b/src/apprt/gtk-ng/App.zig index bc6c11102..4d2006fbb 100644 --- a/src/apprt/gtk-ng/App.zig +++ b/src/apprt/gtk-ng/App.zig @@ -99,7 +99,6 @@ pub fn performIpc( } /// Redraw the inspector for the given surface. -pub fn redrawInspector(self: *App, surface: *Surface) void { - _ = self; - _ = surface; +pub fn redrawInspector(_: *App, surface: *Surface) void { + surface.redrawInspector(); } diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk-ng/Surface.zig index d1a5cbec3..ac82f941b 100644 --- a/src/apprt/gtk-ng/Surface.zig +++ b/src/apprt/gtk-ng/Surface.zig @@ -32,7 +32,8 @@ pub fn rtApp(self: *Self) *ApprtApp { } pub fn close(self: *Self, process_active: bool) void { - self.surface.close(.{ .surface = process_active }); + _ = process_active; + self.surface.close(); } pub fn cgroup(self: *Self) ?[]const u8 { @@ -95,3 +96,8 @@ pub fn setClipboardString( pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { return try self.surface.defaultTermioEnv(); } + +/// Redraw the inspector for our surface. +pub fn redrawInspector(self: *Self) void { + self.surface.redrawInspector(); +} diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index 0818a98f6..3cd385483 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -39,10 +39,14 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 2, .name = "config-errors-dialog" }, .{ .major = 1, .minor = 2, .name = "debug-warning" }, .{ .major = 1, .minor = 3, .name = "debug-warning" }, + .{ .major = 1, .minor = 5, .name = "imgui-widget" }, + .{ .major = 1, .minor = 5, .name = "inspector-widget" }, + .{ .major = 1, .minor = 5, .name = "inspector-window" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, .{ .major = 1, .minor = 5, .name = "split-tree" }, .{ .major = 1, .minor = 5, .name = "split-tree-split" }, .{ .major = 1, .minor = 2, .name = "surface" }, + .{ .major = 1, .minor = 5, .name = "surface-title-dialog" }, .{ .major = 1, .minor = 3, .name = "surface-child-exited" }, .{ .major = 1, .minor = 5, .name = "tab" }, .{ .major = 1, .minor = 5, .name = "window" }, diff --git a/src/apprt/gtk-ng/class.zig b/src/apprt/gtk-ng/class.zig index 82762b542..68879d19c 100644 --- a/src/apprt/gtk-ng/class.zig +++ b/src/apprt/gtk-ng/class.zig @@ -53,6 +53,23 @@ pub fn Common( } }).private else {}; + /// A helper that creates a property that reads and writes a + /// private field with only shallow copies. This is good for primitives + /// such as bools, numbers, etc. + pub fn privateShallowFieldAccessor( + comptime name: []const u8, + ) gobject.ext.Accessor( + Self, + @FieldType(Private.?, name), + ) { + return gobject.ext.privateFieldAccessor( + Self, + Private.?, + &Private.?.offset, + name, + ); + } + /// A helper that can be used to create a property that reads and /// writes a private boxed gobject field type. /// diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 4a14434fa..bfecab3e1 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -542,8 +542,8 @@ pub const Application = extern struct { value: apprt.Action.Value(action), ) !bool { switch (action) { - .close_tab => Action.close(target, .tab), - .close_window => Action.close(target, .window), + .close_tab => return Action.closeTab(target), + .close_window => return Action.closeWindow(target), .config_change => try Action.configChange( self, @@ -561,6 +561,8 @@ pub const Application = extern struct { .initial_size => return Action.initialSize(target, value), + .inspector => return Action.controlInspector(target, value), + .mouse_over_link => Action.mouseOverLink(target, value), .mouse_shape => Action.mouseShape(target, value), .mouse_visibility => Action.mouseVisibility(target, value), @@ -589,6 +591,8 @@ pub const Application = extern struct { .progress_report => return Action.progressReport(target, value), + .prompt_title => return Action.promptTitle(target), + .quit => self.quit(), .quit_timer => try Action.quitTimer(self, value), @@ -597,6 +601,8 @@ pub const Application = extern struct { .render => Action.render(target), + .resize_split => return Action.resizeSplit(target, value), + .ring_bell => Action.ringBell(target), .set_title => Action.setTitle(target, value), @@ -613,17 +619,8 @@ pub const Application = extern struct { .toggle_tab_overview => return Action.toggleTabOverview(target), .toggle_window_decorations => return Action.toggleWindowDecorations(target), .toggle_command_palette => return Action.toggleCommandPalette(target), - - // Unimplemented but todo on gtk-ng branch - .prompt_title, - .inspector, - // TODO: splits - .resize_split, - .toggle_split_zoom, - => { - log.warn("unimplemented action={}", .{action}); - return false; - }, + .toggle_split_zoom => return Action.toggleSplitZoom(target), + .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), // Unimplemented .secure_input, @@ -885,10 +882,10 @@ pub const Application = extern struct { self.syncActionAccelerator("win.reset", .{ .reset = {} }); self.syncActionAccelerator("win.clear", .{ .clear_screen = {} }); self.syncActionAccelerator("win.prompt-title", .{ .prompt_surface_title = {} }); - self.syncActionAccelerator("split-tree.new-left", .{ .new_split = .left }); - self.syncActionAccelerator("split-tree.new-right", .{ .new_split = .right }); - self.syncActionAccelerator("split-tree.new-up", .{ .new_split = .up }); - self.syncActionAccelerator("split-tree.new-down", .{ .new_split = .down }); + self.syncActionAccelerator("split-tree.new-split::left", .{ .new_split = .left }); + self.syncActionAccelerator("split-tree.new-split::right", .{ .new_split = .right }); + self.syncActionAccelerator("split-tree.new-split::up", .{ .new_split = .up }); + self.syncActionAccelerator("split-tree.new-split::down", .{ .new_split = .down }); } fn syncActionAccelerator( @@ -1094,6 +1091,11 @@ pub const Application = extern struct { self, .{ .detail = "dark" }, ); + + // Do an initial color scheme sync. This is idempotent and does nothing + // if our current theme matches what libghostty has so its safe to + // call. + handleStyleManagerDark(style, undefined, self); } /// Setup signal handlers @@ -1115,38 +1117,16 @@ pub const Application = extern struct { const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); - // The set of actions. Each action has (in order): - // [0] The action name - // [1] The callback function - // [2] The glib.VariantType of the parameter - // - // For action names: - // https://docs.gtk.org/gio/type_func.Action.name_is_valid.html - const actions = .{ - .{ "new-window", actionNewWindow, null }, - .{ "new-window-command", actionNewWindow, as_variant_type }, - .{ "open-config", actionOpenConfig, null }, - .{ "present-surface", actionPresentSurface, t_variant_type }, - .{ "quit", actionQuit, null }, - .{ "reload-config", actionReloadConfig, null }, + const actions = [_]ext.actions.Action(Self){ + .init("new-window", actionNewWindow, null), + .init("new-window-command", actionNewWindow, as_variant_type), + .init("open-config", actionOpenConfig, null), + .init("present-surface", actionPresentSurface, t_variant_type), + .init("quit", actionQuit, null), + .init("reload-config", actionReloadConfig, null), }; - const action_map = self.as(gio.ActionMap); - inline for (actions) |entry| { - const action = gio.SimpleAction.new( - entry[0], - entry[2], - ); - defer action.unref(); - _ = gio.SimpleAction.signals.activate.connect( - action, - *Self, - entry[1], - self, - .{}, - ); - action_map.addAction(action.as(gio.Action)); - } + ext.actions.add(Self, self, &actions); } /// Setup our global shortcuts. @@ -1329,14 +1309,25 @@ pub const Application = extern struct { _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { - _ = self; - - const color_scheme: apprt.ColorScheme = if (style.getDark() == 0) + const scheme: apprt.ColorScheme = if (style.getDark() == 0) .light else .dark; + log.debug("style manager changed scheme={}", .{scheme}); - log.debug("style manager changed scheme={}", .{color_scheme}); + const priv = self.private(); + const core_app = priv.core_app; + core_app.colorSchemeEvent(self.rt(), scheme) catch |err| { + log.warn("error updating app color scheme err={}", .{err}); + }; + for (core_app.surfaces.items) |surface| { + surface.core().colorSchemeCallback(scheme) catch |err| { + log.warn( + "unable to tell surface about color scheme change err={}", + .{err}, + ); + }; + } } fn handleReloadConfig( @@ -1585,13 +1576,23 @@ pub const Application = extern struct { /// All apprt action handlers const Action = struct { - pub fn close( - target: apprt.Target, - scope: Surface.CloseScope, - ) void { + pub fn closeTab(target: apprt.Target) bool { switch (target) { - .app => {}, - .surface => |v| v.rt_surface.surface.close(scope), + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + return surface.as(gtk.Widget).activateAction("tab.close", null) != 0; + }, + } + } + + pub fn closeWindow(target: apprt.Target) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + return surface.as(gtk.Widget).activateAction("win.close", null) != 0; + }, } } @@ -1813,12 +1814,12 @@ const Action = struct { .surface => |core| { const surface = core.rt_surface.surface; - return surface.as(gtk.Widget).activateAction(switch (direction) { - .right => "split-tree.new-right", - .left => "split-tree.new-left", - .down => "split-tree.new-down", - .up => "split-tree.new-up", - }, null) != 0; + + return surface.as(gtk.Widget).activateAction( + "split-tree.new-split", + "&s", + @tagName(direction).ptr, + ) != 0; }, } } @@ -1955,6 +1956,16 @@ const Action = struct { }; } + pub fn promptTitle(target: apprt.Target) bool { + switch (target) { + .app => return false, + .surface => |v| { + v.rt_surface.surface.promptTitle(); + return true; + }, + } + } + /// Reload the configuration for the application and propagate it /// across the entire application and all terminals. pub fn reloadConfig( @@ -2003,10 +2014,47 @@ const Action = struct { } } + pub fn resizeSplit( + target: apprt.Target, + value: apprt.action.ResizeSplit, + ) bool { + switch (target) { + .app => { + log.warn("resize_split to app is unexpected", .{}); + return false; + }, + .surface => |core| { + const surface = core.rt_surface.surface; + const tree = ext.getAncestor( + SplitTree, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a split tree, ignoring goto_split", .{}); + return false; + }; + + return tree.resize( + switch (value.direction) { + .up => .up, + .down => .down, + .left => .left, + .right => .right, + }, + value.amount, + ) catch |err| switch (err) { + error.OutOfMemory => { + log.warn("unable to resize split, out of memory", .{}); + return false; + }, + }; + }, + } + } + pub fn ringBell(target: apprt.Target) void { switch (target) { .app => {}, - .surface => |v| v.rt_surface.surface.ringBell(), + .surface => |v| v.rt_surface.surface.setBellRinging(true), } } @@ -2083,6 +2131,36 @@ const Action = struct { return true; } + pub fn toggleSplitZoom(target: apprt.Target) bool { + switch (target) { + .app => { + log.warn("toggle_split_zoom to app is unexpected", .{}); + return false; + }, + + .surface => |core| { + // TODO: pass surface ID when we have that + const surface = core.rt_surface.surface; + return surface.as(gtk.Widget).activateAction("split-tree.zoom", null) != 0; + }, + } + } + + pub fn showOnScreenKeyboard(target: apprt.Target) bool { + switch (target) { + .app => { + log.warn("show_on_screen_keyboard to app is unexpected", .{}); + return false; + }, + // NOTE: Even though `activateOsk` takes a gdk.Event, it's currently + // unused by all implementations of `activateOsk` as of GTK 4.18. + // The commit that introduced the method (ce6aa73c) clarifies that + // the event *may* be used by other IM backends, but for Linux desktop + // environments this doesn't matter. + .surface => |v| return v.rt_surface.surface.showOnScreenKeyboard(null), + } + } + fn getQuickTerminalWindow() ?*Window { // Find a quick terminal window. const list = gtk.Window.listToplevels(); @@ -2156,6 +2234,15 @@ const Action = struct { }, } } + + pub fn controlInspector(target: apprt.Target, value: apprt.Action.Value(.inspector)) bool { + switch (target) { + .app => return false, + .surface => |surface| { + return surface.rt_surface.gobj().controlInspector(value); + }, + } + } }; /// This sets various GTK-related environment variables as necessary diff --git a/src/apprt/gtk-ng/class/imgui_widget.zig b/src/apprt/gtk-ng/class/imgui_widget.zig new file mode 100644 index 000000000..1522f2bc1 --- /dev/null +++ b/src/apprt/gtk-ng/class/imgui_widget.zig @@ -0,0 +1,478 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const cimgui = @import("cimgui"); +const gl = @import("opengl"); +const adw = @import("adw"); +const gdk = @import("gdk"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const input = @import("../../../input.zig"); +const gresource = @import("../build/gresource.zig"); + +const key = @import("../key.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_imgui_widget); + +/// A widget for embedding a Dear ImGui application. +/// +/// It'd be a lot cleaner to use inheritance here but zig-gobject doesn't +/// currently have a way to define virtual methods, so we have to use +/// composition and signals instead. +pub const ImguiWidget = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyImguiWidget", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct {}; + + pub const signals = struct { + /// Emitted when the child widget should render. During the callback, + /// the Imgui context is valid. + pub const render = struct { + pub const name = "render"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted when first realized to allow the embedded ImGui application + /// to initialize itself. When this is called, the ImGui context + /// is properly set. + /// + /// This might be called multiple times, but each time it is + /// called a new Imgui context will be created. + pub const setup = struct { + pub const name = "setup"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + }; + + const Private = struct { + /// GL area where we display the Dear ImGui application. + gl_area: *gtk.GLArea, + + /// GTK input method context + im_context: *gtk.IMMulticontext, + + /// Dear ImGui context. We create a context per widget so that we can + /// have multiple active imgui views in the same application. + ig_context: ?*cimgui.c.ImGuiContext = null, + + /// Our previous instant used to calculate delta time for animations. + instant: ?std.time.Instant = null, + + pub var offset: c_int = 0; + }; + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + fn dispose(self: *Self) callconv(.c) void { + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Public methods + + /// This should be called anytime the underlying data for the UI changes + /// so that the UI can be refreshed. + pub fn queueRender(self: *ImguiWidget) void { + const priv = self.private(); + priv.gl_area.queueRender(); + } + + //--------------------------------------------------------------- + // Private Methods + + /// Set our imgui context to be current, or return an error. This must be + /// called before any Dear ImGui API calls so that they're made against + /// the proper context. + fn setCurrentContext(self: *Self) error{ContextNotInitialized}!void { + const priv = self.private(); + const ig_context = priv.ig_context orelse { + log.warn("Dear ImGui context not initialized", .{}); + return error.ContextNotInitialized; + }; + cimgui.c.igSetCurrentContext(ig_context); + } + + /// Initialize the frame. Expects that the context is already current. + fn newFrame(self: *Self) void { + // If we can't determine the time since the last frame we default to + // 1/60th of a second. + const default_delta_time = 1 / 60; + + const priv = self.private(); + + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + + // Determine our delta time + const now = std.time.Instant.now() catch unreachable; + io.DeltaTime = if (priv.instant) |prev| delta: { + const since_ns = now.since(prev); + const since_s: f32 = @floatFromInt(since_ns / std.time.ns_per_s); + break :delta @max(0.00001, since_s); + } else default_delta_time; + + priv.instant = now; + } + + /// Handle key press/release events. + fn keyEvent( + self: *ImguiWidget, + action: input.Action, + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + _: c_uint, + gtk_mods: gdk.ModifierType, + ) bool { + self.queueRender(); + + self.setCurrentContext() catch return false; + + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + + const mods = key.translateMods(gtk_mods); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftShift, mods.shift); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftCtrl, mods.ctrl); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftAlt, mods.alt); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftSuper, mods.super); + + // If our keyval has a key, then we send that key event + if (key.keyFromKeyval(keyval)) |inputkey| { + if (inputkey.imguiKey()) |imgui_key| { + cimgui.c.ImGuiIO_AddKeyEvent(io, imgui_key, action == .press); + } + } + + // Try to process the event as text + if (ec_key.as(gtk.EventController).getCurrentEvent()) |event| { + const priv = self.private(); + _ = priv.im_context.as(gtk.IMContext).filterKeypress(event); + } + + return true; + } + + /// Translate a GTK mouse button to a Dear ImGui mouse button. + fn translateMouseButton(button: c_uint) ?c_int { + return switch (button) { + 1 => cimgui.c.ImGuiMouseButton_Left, + 2 => cimgui.c.ImGuiMouseButton_Middle, + 3 => cimgui.c.ImGuiMouseButton_Right, + else => null, + }; + } + + /// Get the scale factor that the display is operating at. + fn getScaleFactor(self: *Self) f64 { + const priv = self.private(); + return @floatFromInt(priv.gl_area.as(gtk.Widget).getScaleFactor()); + } + + //--------------------------------------------------------------- + // Properties + + //--------------------------------------------------------------- + // Signal Handlers + + fn glAreaRealize(_: *gtk.GLArea, self: *Self) callconv(.c) void { + const priv = self.private(); + assert(priv.ig_context == null); + + priv.gl_area.makeCurrent(); + if (priv.gl_area.getError()) |err| { + log.warn("GLArea for Dear ImGui widget failed to realize: {s}", .{err.f_message orelse "(unknown)"}); + return; + } + + priv.ig_context = cimgui.c.igCreateContext(null) orelse { + log.warn("unable to initialize Dear ImGui context", .{}); + return; + }; + self.setCurrentContext() catch return; + + // Setup some basic config + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + io.BackendPlatformName = "ghostty_gtk"; + + // Realize means that our OpenGL context is ready, so we can now + // initialize the ImgUI OpenGL backend for our context. + _ = cimgui.ImGui_ImplOpenGL3_Init(null); + + // Setup our app + signals.setup.impl.emit( + self, + null, + .{}, + null, + ); + } + + /// Handle a request to unrealize the GLArea + fn glAreaUnrealize(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void { + assert(self.private().ig_context != null); + self.setCurrentContext() catch return; + cimgui.ImGui_ImplOpenGL3_Shutdown(); + } + + /// Handle a request to resize the GLArea + fn glAreaResize(area: *gtk.GLArea, width: c_int, height: c_int, self: *Self) callconv(.c) void { + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const scale_factor = area.as(gtk.Widget).getScaleFactor(); + + // Our display size is always unscaled. We'll do the scaling in the + // style instead. This creates crisper looking fonts. + io.DisplaySize = .{ .x = @floatFromInt(width), .y = @floatFromInt(height) }; + io.DisplayFramebufferScale = .{ .x = 1, .y = 1 }; + + // Setup a new style and scale it appropriately. + const style = cimgui.c.ImGuiStyle_ImGuiStyle(); + defer cimgui.c.ImGuiStyle_destroy(style); + cimgui.c.ImGuiStyle_ScaleAllSizes(style, @floatFromInt(scale_factor)); + const active_style = cimgui.c.igGetStyle(); + active_style.* = style.*; + } + + /// Handle a request to render the contents of our GLArea + fn glAreaRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Self) callconv(.c) c_int { + self.setCurrentContext() catch return @intFromBool(false); + + // Setup our frame. We render twice because some ImGui behaviors + // take multiple renders to process. I don't know how to make this + // more efficient. + for (0..2) |_| { + cimgui.ImGui_ImplOpenGL3_NewFrame(); + self.newFrame(); + cimgui.c.igNewFrame(); + + // Use the callback to draw the UI. + signals.render.impl.emit( + self, + null, + .{}, + null, + ); + + // Render + cimgui.c.igRender(); + } + + // OpenGL final render + gl.clearColor(0x28 / 0xFF, 0x2C / 0xFF, 0x34 / 0xFF, 1.0); + gl.clear(gl.c.GL_COLOR_BUFFER_BIT); + cimgui.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.igGetDrawData()); + + return @intFromBool(true); + } + + fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGuiIO_AddFocusEvent(io, true); + self.queueRender(); + } + + fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGuiIO_AddFocusEvent(io, false); + self.queueRender(); + } + + fn ecKeyPressed( + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + keycode: c_uint, + gtk_mods: gdk.ModifierType, + self: *ImguiWidget, + ) callconv(.c) c_int { + return @intFromBool(self.keyEvent( + .press, + ec_key, + keyval, + keycode, + gtk_mods, + )); + } + + fn ecKeyReleased( + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + keycode: c_uint, + gtk_mods: gdk.ModifierType, + self: *ImguiWidget, + ) callconv(.c) void { + _ = self.keyEvent( + .release, + ec_key, + keyval, + keycode, + gtk_mods, + ); + } + + fn ecMousePressed( + gesture: *gtk.GestureClick, + _: c_int, + _: f64, + _: f64, + self: *ImguiWidget, + ) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton(); + if (translateMouseButton(gdk_button)) |button| { + cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, true); + } + } + + fn ecMouseReleased( + gesture: *gtk.GestureClick, + _: c_int, + _: f64, + _: f64, + self: *ImguiWidget, + ) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton(); + if (translateMouseButton(gdk_button)) |button| { + cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, false); + } + } + + fn ecMouseMotion( + _: *gtk.EventControllerMotion, + x: f64, + y: f64, + self: *ImguiWidget, + ) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const scale_factor = self.getScaleFactor(); + cimgui.c.ImGuiIO_AddMousePosEvent( + io, + @floatCast(x * scale_factor), + @floatCast(y * scale_factor), + ); + } + + fn ecMouseScroll( + _: *gtk.EventControllerScroll, + x: f64, + y: f64, + self: *ImguiWidget, + ) callconv(.c) c_int { + self.queueRender(); + self.setCurrentContext() catch return @intFromBool(false); + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGuiIO_AddMouseWheelEvent( + io, + @floatCast(x), + @floatCast(-y), + ); + return @intFromBool(true); + } + + fn imCommit( + _: *gtk.IMMulticontext, + bytes: [*:0]u8, + self: *ImguiWidget, + ) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const refSink = C.refSink; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "imgui-widget", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("gl_area", .{}); + class.bindTemplateChildPrivate("im_context", .{}); + + // Template Callbacks + class.bindTemplateCallback("realize", &glAreaRealize); + class.bindTemplateCallback("unrealize", &glAreaUnrealize); + class.bindTemplateCallback("resize", &glAreaResize); + class.bindTemplateCallback("render", &glAreaRender); + class.bindTemplateCallback("focus_enter", &ecFocusEnter); + class.bindTemplateCallback("focus_leave", &ecFocusLeave); + class.bindTemplateCallback("key_pressed", &ecKeyPressed); + class.bindTemplateCallback("key_released", &ecKeyReleased); + class.bindTemplateCallback("mouse_pressed", &ecMousePressed); + class.bindTemplateCallback("mouse_released", &ecMouseReleased); + class.bindTemplateCallback("mouse_motion", &ecMouseMotion); + class.bindTemplateCallback("scroll", &ecMouseScroll); + class.bindTemplateCallback("im_commit", &imCommit); + + // Signals + signals.render.impl.register(.{}); + signals.setup.impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk-ng/class/inspector_widget.zig b/src/apprt/gtk-ng/class/inspector_widget.zig new file mode 100644 index 000000000..f71970a88 --- /dev/null +++ b/src/apprt/gtk-ng/class/inspector_widget.zig @@ -0,0 +1,255 @@ +const std = @import("std"); + +const adw = @import("adw"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const Inspector = @import("../../../inspector/Inspector.zig"); + +const Common = @import("../class.zig").Common; +const Surface = @import("surface.zig").Surface; +const ImguiWidget = @import("imgui_widget.zig").ImguiWidget; + +const log = std.log.scoped(.gtk_ghostty_inspector_widget); + +/// Widget for displaying the Ghostty inspector. +pub const InspectorWidget = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyInspectorWidget", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const surface = struct { + pub const name = "surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = .{ + .getter = getSurfaceValue, + .setter = setSurfaceValue, + }, + }, + ); + }; + }; + + pub const signals = struct {}; + + const Private = struct { + /// The surface that we are attached to. This is NOT referenced. + /// We attach a weak notify to the object. + surface: ?*Surface = null, + + /// The embedded Dear ImGui widget. + imgui_widget: *ImguiWidget, + + pub var offset: c_int = 0; + }; + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + fn dispose(self: *Self) callconv(.c) void { + // Clear our surface so it deactivates the inspector. + self.setSurface(null); + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Public methods + + /// Queue a render of the Dear ImGui widget. + pub fn queueRender(self: *Self) void { + const priv = self.private(); + priv.imgui_widget.queueRender(); + } + + //--------------------------------------------------------------- + // Properties + + fn getSurfaceValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + self.private().surface, + ); + } + + fn setSurfaceValue(self: *Self, value: *const gobject.Value) void { + self.setSurface(gobject.ext.Value.get( + value, + ?*Surface, + )); + } + + pub fn getSurface(self: *Self) ?*Surface { + return self.private().surface; + } + + pub fn setSurface(self: *Self, surface_: ?*Surface) void { + const priv = self.private(); + + // Do nothing if we're not changing the value. + if (surface_ == priv.surface) return; + + // Setup our notification to happen at the end because we're + // changing values no matter what. + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); + + // Deactivate the inspector on the old surface if it exists + // and set our value to null. + if (priv.surface) |old| old: { + priv.surface = null; + + // Remove our weak ref + old.as(gobject.Object).weakUnref( + surfaceWeakNotify, + self, + ); + + // Deactivate the inspector + const core_surface = old.core() orelse break :old; + core_surface.deactivateInspector(); + } + + // Activate the inspector on the new surface. + const surface = surface_ orelse return; + const core_surface = surface.core() orelse return; + core_surface.activateInspector() catch |err| { + log.warn("failed to activate inspector err={}", .{err}); + return; + }; + + // We use a weak reference on surface to determine if the surface + // was closed while our inspector was active. + surface.as(gobject.Object).weakRef( + surfaceWeakNotify, + self, + ); + + // Store our surface. We don't need to ref this because we setup + // the weak notify above. + priv.surface = surface; + + self.queueRender(); + } + + //--------------------------------------------------------------- + // Signal Handlers + + fn surfaceWeakNotify( + ud: ?*anyopaque, + surface: *gobject.Object, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud orelse return)); + const priv = self.private(); + + // The weak notify docs call out that we can specifically use the + // pointer values for comparison, but the objects themselves are unsafe. + if (@intFromPtr(priv.surface) != @intFromPtr(surface)) return; + + // According to weak notify docs, "surface" is in the "dispose" state. + // Our surface doesn't clear the core surface until the "finalize" + // state so we should be able to safely access it here. We need to + // be really careful though. + const old = priv.surface orelse return; + const core_surface = old.core() orelse return; + core_surface.deactivateInspector(); + priv.surface = null; + self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); + + // Note: in the future we should probably show some content on our + // window to note that the surface went away in case our embedding + // widget doesn't close itself. As I type this, our window closes + // immediately when the surface goes away so you don't see this, but + // for completeness sake we should clean this up. + } + + fn imguiRender( + _: *ImguiWidget, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + const surface = priv.surface orelse return; + const core_surface = surface.core() orelse return; + const inspector = core_surface.inspector orelse return; + inspector.render(); + } + + fn imguiSetup( + _: *ImguiWidget, + _: *Self, + ) callconv(.c) void { + Inspector.setup(); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const refSink = C.refSink; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gobject.ext.ensureType(ImguiWidget); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "inspector-widget", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("imgui_widget", .{}); + + // Template callbacks + class.bindTemplateCallback("imgui_render", &imguiRender); + class.bindTemplateCallback("imgui_setup", &imguiSetup); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.surface.impl, + }); + + // Signals + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk-ng/class/inspector_window.zig b/src/apprt/gtk-ng/class/inspector_window.zig new file mode 100644 index 000000000..701718229 --- /dev/null +++ b/src/apprt/gtk-ng/class/inspector_window.zig @@ -0,0 +1,220 @@ +const std = @import("std"); +const build_config = @import("../../../build_config.zig"); + +const adw = @import("adw"); +const gdk = @import("gdk"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); + +const key = @import("../key.zig"); +const Common = @import("../class.zig").Common; +const Application = @import("application.zig").Application; +const Surface = @import("surface.zig").Surface; +const DebugWarning = @import("debug_warning.zig").DebugWarning; +const InspectorWidget = @import("inspector_widget.zig").InspectorWidget; +const WeakRef = @import("../weak_ref.zig").WeakRef; + +const log = std.log.scoped(.gtk_ghostty_inspector_window); + +/// Window for displaying the Ghostty inspector. +pub const InspectorWindow = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.ApplicationWindow; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyInspectorWindow", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const surface = struct { + pub const name = "surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = .{ + .getter = getSurfaceValue, + .setter = setSurfaceValue, + }, + }, + ); + }; + + pub const debug = struct { + pub const name = "debug"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = build_config.is_debug, + .accessor = gobject.ext.typedAccessor(Self, bool, .{ + .getter = struct { + pub fn getter(_: *Self) bool { + return build_config.is_debug; + } + }.getter, + }), + }, + ); + }; + }; + + pub const signals = struct {}; + + const Private = struct { + /// The surface that we are attached to + surface: WeakRef(Surface) = .empty, + + /// The embedded inspector widget. + inspector_widget: *InspectorWidget, + + pub var offset: c_int = 0; + }; + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Add our dev CSS class if we're in debug mode. + if (comptime build_config.is_debug) { + self.as(gtk.Widget).addCssClass("devel"); + } + + // Set our window icon. We can't set this in the blueprint file + // because its dependent on the build config. + self.as(gtk.Window).setIconName(build_config.bundle_id); + } + + fn dispose(self: *Self) callconv(.c) void { + // You MUST clear all weak refs in dispose, otherwise it causes + // memory corruption on dispose on the TARGET (weak referenced) + // object. The only way we caught this is via Valgrind. Its not a leak, + // its an invalid memory read. In practice, I found this sometimes + // caused hanging! + self.setSurface(null); + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Public methods + + pub fn new(surface: *Surface) *Self { + return gobject.ext.newInstance(Self, .{ + .surface = surface, + }); + } + + /// Present the window. + pub fn present(self: *Self) void { + self.as(gtk.Window).present(); + } + + /// Queue a render of the embedded widget. + pub fn queueRender(self: *Self) void { + const priv = self.private(); + priv.inspector_widget.queueRender(); + } + + //--------------------------------------------------------------- + // Properties + + fn setSurface(self: *Self, newvalue: ?*Surface) void { + const priv = self.private(); + priv.surface.set(newvalue); + } + + fn getSurfaceValue(self: *Self, value: *gobject.Value) void { + // Important: get() refs, so we take to not increase ref twice + gobject.ext.Value.take( + value, + self.private().surface.get(), + ); + } + + fn setSurfaceValue(self: *Self, value: *const gobject.Value) void { + self.setSurface(gobject.ext.Value.get( + value, + ?*Surface, + )); + } + + //--------------------------------------------------------------- + // Signal Handlers + + fn propInspectorSurface( + inspector: *InspectorWidget, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // If the inspector's surface went away, we destroy the window. + // The inspector has a weak notify on the surface so it knows + // if it goes nil. + if (inspector.getSurface() == null) { + self.as(gtk.Window).destroy(); + } + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const refSink = C.refSink; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gobject.ext.ensureType(DebugWarning); + gobject.ext.ensureType(InspectorWidget); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "inspector-window", + }), + ); + + // Template Bindings + class.bindTemplateChildPrivate("inspector_widget", .{}); + + // Template callbacks + class.bindTemplateCallback("notify_inspector_surface", &propInspectorSurface); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.surface.impl, + properties.debug.impl, + }); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 5eb0a5472..3b6dcb4a9 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -79,6 +79,25 @@ pub const SplitTree = extern struct { ); }; + pub const @"is-zoomed" = struct { + pub const name = "is-zoomed"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getIsZoomed, + }, + ), + }, + ); + }; + pub const tree = struct { pub const name = "tree"; const impl = gobject.ext.defineProperty( @@ -141,57 +160,26 @@ pub const SplitTree = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); // Initialize our actions - self.initActions(); + self.initActionMap(); // Initialize some basic state const priv = self.private(); priv.pending_close = null; } - fn initActions(self: *Self) void { - // The set of actions. Each action has (in order): - // [0] The action name - // [1] The callback function - // [2] The glib.VariantType of the parameter - // - // For action names: - // https://docs.gtk.org/gio/type_func.Action.name_is_valid.html - const actions = .{ + fn initActionMap(self: *Self) void { + const s_variant_type = glib.ext.VariantType.newFor([:0]const u8); + defer s_variant_type.free(); + + const actions = [_]ext.actions.Action(Self){ // All of these will eventually take a target surface parameter. // For now all our targets originate from the focused surface. - .{ "new-left", actionNewLeft, null }, - .{ "new-right", actionNewRight, null }, - .{ "new-up", actionNewUp, null }, - .{ "new-down", actionNewDown, null }, - - .{ "equalize", actionEqualize, null }, + .init("new-split", actionNewSplit, s_variant_type), + .init("equalize", actionEqualize, null), + .init("zoom", actionZoom, null), }; - // We need to collect our actions into a group since we're just - // a plain widget that doesn't implement ActionGroup directly. - const group = gio.SimpleActionGroup.new(); - errdefer group.unref(); - const map = group.as(gio.ActionMap); - inline for (actions) |entry| { - const action = gio.SimpleAction.new( - entry[0], - entry[2], - ); - defer action.unref(); - _ = gio.SimpleAction.signals.activate.connect( - action, - *Self, - entry[1], - self, - .{}, - ); - map.addAction(action.as(gio.Action)); - } - - self.as(gtk.Widget).insertActionGroup( - "split-tree", - group.as(gio.ActionGroup), - ); + ext.actions.addAsGroup(Self, self, "split-tree", &actions); } /// Create a new split in the given direction from the currently @@ -241,7 +229,7 @@ pub const SplitTree = extern struct { // The handle we create the split relative to. Today this is the active // surface but this might be the handle of the given parent if we want. - const handle = self.getActiveSurfaceHandle() orelse 0; + const handle = self.getActiveSurfaceHandle() orelse .root; // Create our split! var new_tree = try old_tree.split( @@ -261,6 +249,51 @@ pub const SplitTree = extern struct { self.setTree(&new_tree); } + pub fn resize( + self: *Self, + direction: Surface.Tree.Split.Direction, + amount: u16, + ) Allocator.Error!bool { + // Avoid useless work + if (amount == 0) return false; + + const old_tree = self.getTree() orelse return false; + const active = self.getActiveSurfaceHandle() orelse return false; + + // Get all our dimensions we're going to need to turn our + // amount into a percentage. + const priv = self.private(); + const width = priv.tree_bin.as(gtk.Widget).getWidth(); + const height = priv.tree_bin.as(gtk.Widget).getHeight(); + if (width == 0 or height == 0) return false; + const width_f64: f64 = @floatFromInt(width); + const height_f64: f64 = @floatFromInt(height); + const amount_f64: f64 = @floatFromInt(amount); + + // Get our ratio and use positive/neg for directions. + const ratio: f64 = switch (direction) { + .right => amount_f64 / width_f64, + .left => -(amount_f64 / width_f64), + .down => amount_f64 / height_f64, + .up => -(amount_f64 / height_f64), + }; + + const layout: Surface.Tree.Split.Layout = switch (direction) { + .left, .right => .horizontal, + .up, .down => .vertical, + }; + + var new_tree = try old_tree.resize( + Application.default().allocator(), + active, + layout, + @floatCast(ratio), + ); + defer new_tree.deinit(); + self.setTree(&new_tree); + return true; + } + /// Move focus from the currently focused surface to the given /// direction. Returns true if focus switched to a new surface. pub fn goto(self: *Self, to: Surface.Tree.Goto) bool { @@ -283,7 +316,7 @@ pub const SplitTree = extern struct { if (active == target) return false; // Get the surface at the target location and grab focus. - const surface = tree.nodes[target].leaf; + const surface = tree.nodes[target.idx()].leaf; surface.grabFocus(); return true; @@ -338,12 +371,28 @@ pub const SplitTree = extern struct { //--------------------------------------------------------------- // Properties + /// Returns true if this split tree needs confirmation before quitting based + /// on the various Ghostty configurations. + pub fn getNeedsConfirmQuit(self: *Self) bool { + const tree = self.getTree() orelse return false; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view.core()) |core| { + if (core.needsConfirmQuit()) { + return true; + } + } + } + + return false; + } + /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. pub fn getActiveSurface(self: *Self) ?*Surface { const tree = self.getTree() orelse return null; const handle = self.getActiveSurfaceHandle() orelse return null; - return tree.nodes[handle].leaf; + return tree.nodes[handle.idx()].leaf; } fn getActiveSurfaceHandle(self: *Self) ?Surface.Tree.Node.Handle { @@ -353,6 +402,20 @@ pub const SplitTree = extern struct { if (entry.view.getFocused()) return entry.handle; } + // If none are currently focused, the most previously focused + // surface (if it exists) is our active surface. This lets things + // like apprt actions and bell ringing continue to work in the + // background. + if (self.private().last_focused.get()) |v| { + defer v.unref(); + + // We need to find the handle of the last focused surface. + it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view == v) return entry.handle; + } + } + return null; } @@ -384,6 +447,11 @@ pub const SplitTree = extern struct { return !tree.isEmpty(); } + pub fn getIsZoomed(self: *Self) bool { + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + return tree.zoomed != null; + } + /// Get the tree data model that we're showing in this widget. This /// does not clone the tree. pub fn getTree(self: *Self) ?*Surface.Tree { @@ -483,56 +551,30 @@ pub const SplitTree = extern struct { //--------------------------------------------------------------- // Signal handlers - pub fn actionNewLeft( + pub fn actionNewSplit( _: *gio.SimpleAction, - parameter_: ?*glib.Variant, + args_: ?*glib.Variant, self: *Self, ) callconv(.c) void { - _ = parameter_; - self.newSplit( - .left, - self.getActiveSurface(), - ) catch |err| { - log.warn("new split failed error={}", .{err}); + const args = args_ orelse { + log.warn("split-tree.new-split called without a parameter", .{}); + return; }; - } - pub fn actionNewRight( - _: *gio.SimpleAction, - parameter_: ?*glib.Variant, - self: *Self, - ) callconv(.c) void { - _ = parameter_; - self.newSplit( - .right, - self.getActiveSurface(), - ) catch |err| { - log.warn("new split failed error={}", .{err}); + var dir: ?[*:0]const u8 = null; + args.get("&s", &dir); + + const direction = std.meta.stringToEnum( + Surface.Tree.Split.Direction, + std.mem.span(dir) orelse return, + ) orelse { + // Need to be defensive here since actions can be triggered externally. + log.warn("invalid split direction for split-tree.new-split: {s}", .{dir.?}); + return; }; - } - pub fn actionNewUp( - _: *gio.SimpleAction, - parameter_: ?*glib.Variant, - self: *Self, - ) callconv(.c) void { - _ = parameter_; self.newSplit( - .up, - self.getActiveSurface(), - ) catch |err| { - log.warn("new split failed error={}", .{err}); - }; - } - - pub fn actionNewDown( - _: *gio.SimpleAction, - parameter_: ?*glib.Variant, - self: *Self, - ) callconv(.c) void { - _ = parameter_; - self.newSplit( - .down, + direction, self.getActiveSurface(), ) catch |err| { log.warn("new split failed error={}", .{err}); @@ -555,20 +597,27 @@ pub const SplitTree = extern struct { self.setTree(&new_tree); } - fn surfaceCloseRequest( - surface: *Surface, - scope: *const Surface.CloseScope, + pub fn actionZoom( + _: *gio.SimpleAction, + _: ?*glib.Variant, self: *Self, ) callconv(.c) void { - switch (scope.*) { - // Handled upstream... this will probably go away for widget - // actions eventually. - .window, .tab => return, - - // Remove the surface from the tree. - .surface => {}, + const tree = self.getTree() orelse return; + if (tree.zoomed != null) { + tree.zoomed = null; + } else { + const active = self.getActiveSurfaceHandle() orelse return; + if (tree.zoomed == active) return; + tree.zoom(active); } + self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec); + } + + fn surfaceCloseRequest( + surface: *Surface, + self: *Self, + ) callconv(.c) void { const core = surface.core() orelse return; // Reset our pending close state @@ -634,7 +683,7 @@ pub const SplitTree = extern struct { // Note: we don't need to ref this or anything because its // guaranteed to remain in the new tree since its not part // of the handle we're removing. - break :next_focus old_tree.nodes[next_handle].leaf; + break :next_focus old_tree.nodes[next_handle.idx()].leaf; }; // Remove it from the tree. @@ -736,6 +785,7 @@ pub const SplitTree = extern struct { // Dependent properties self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); + self.as(gobject.Object).notifyByPspec(properties.@"is-zoomed".impl.param_spec); } fn onRebuild(ud: ?*anyopaque) callconv(.c) c_int { @@ -752,7 +802,10 @@ pub const SplitTree = extern struct { // Rebuild our tree const tree: *const Surface.Tree = self.private().tree orelse &.empty; if (!tree.isEmpty()) { - priv.tree_bin.setChild(self.buildTree(tree, 0)); + priv.tree_bin.setChild(self.buildTree( + tree, + tree.zoomed orelse .root, + )); } // If we have a last focused surface, we need to refocus it, because @@ -778,7 +831,7 @@ pub const SplitTree = extern struct { tree: *const Surface.Tree, current: Surface.Tree.Node.Handle, ) *gtk.Widget { - return switch (tree.nodes[current]) { + return switch (tree.nodes[current.idx()]) { .leaf => |v| v.as(gtk.Widget), .split => |s| SplitTreeSplit.new( current, @@ -818,6 +871,7 @@ pub const SplitTree = extern struct { gobject.ext.registerProperties(class, &.{ properties.@"active-surface".impl, properties.@"has-surfaces".impl, + properties.@"is-zoomed".impl, properties.tree.impl, }); @@ -937,7 +991,7 @@ const SplitTreeSplit = extern struct { self.as(gtk.Widget), ) orelse return 0; const tree = split_tree.getTree() orelse return 0; - const split: *const Surface.Tree.Split = &tree.nodes[priv.handle].split; + const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split; // Current, min, and max positions as pixels. const pos = paned.getPosition(); diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 8487b24b0..580436bd3 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -27,7 +27,10 @@ const Config = @import("config.zig").Config; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited; const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; +const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog; const Window = @import("window.zig").Window; +const WeakRef = @import("../weak_ref.zig").WeakRef; +const InspectorWindow = @import("inspector_window.zig").InspectorWindow; const log = std.log.scoped(.gtk_ghostty_surface); @@ -47,6 +50,19 @@ pub const Surface = extern struct { pub const Tree = datastruct.SplitTree(Self); pub const properties = struct { + pub const @"bell-ringing" = struct { + pub const name = "bell-ringing"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = C.privateShallowFieldAccessor("bell_ringing"), + }, + ); + }; + pub const config = struct { pub const name = "config"; const impl = gobject.ext.defineProperty( @@ -173,8 +189,6 @@ pub const Surface = extern struct { pub const @"mouse-hover-url" = struct { pub const name = "mouse-hover-url"; - pub const get = impl.get; - pub const set = impl.set; const impl = gobject.ext.defineProperty( name, Self, @@ -188,8 +202,6 @@ pub const Surface = extern struct { pub const pwd = struct { pub const name = "pwd"; - pub const get = impl.get; - pub const set = impl.set; const impl = gobject.ext.defineProperty( name, Self, @@ -203,8 +215,6 @@ pub const Surface = extern struct { pub const title = struct { pub const name = "title"; - pub const get = impl.get; - pub const set = impl.set; const impl = gobject.ext.defineProperty( name, Self, @@ -216,6 +226,19 @@ pub const Surface = extern struct { ); }; + pub const @"title-override" = struct { + pub const name = "title-override"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("title_override"), + }, + ); + }; + pub const zoom = struct { pub const name = "zoom"; const impl = gobject.ext.defineProperty( @@ -249,21 +272,6 @@ pub const Surface = extern struct { pub const @"close-request" = struct { pub const name = "close-request"; pub const connect = impl.connect; - const impl = gobject.ext.defineSignal( - name, - Self, - &.{*const CloseScope}, - void, - ); - }; - - /// The bell is rung. - /// - /// The surface view handles the audio bell feature but none of the - /// others so it is up to the embedding widget to react to this. - pub const bell = struct { - pub const name = "bell"; - pub const connect = impl.connect; const impl = gobject.ext.defineSignal( name, Self, @@ -403,6 +411,9 @@ pub const Surface = extern struct { /// The title of this surface, if any has been set. title: ?[:0]const u8 = null, + /// The manually overridden title of this surface from `promptTitle`. + title_override: ?[:0]const u8 = null, + /// The current focus state of the terminal based on the /// focus events. focused: bool = true, @@ -456,6 +467,14 @@ pub const Surface = extern struct { // Progress bar progress_bar_timer: ?c_uint = null, + // True while the bell is ringing. This will be set to false (after + // true) under various scenarios, but can also manually be set to + // false by a parent widget. + bell_ringing: bool = false, + + /// A weak reference to an inspector window. + inspector: ?*InspectorWindow = null, + // Template binds child_exited_overlay: *ChildExited, context_menu: *gtk.PopoverMenu, @@ -504,10 +523,18 @@ pub const Surface = extern struct { priv.font_size_request = font_size_ptr; self.as(gobject.Object).notifyByPspec(properties.@"font-size-request".impl.param_spec); - // Setup our pwd - if (parent.rt_surface.surface.getPwd()) |pwd| { - priv.pwd = glib.ext.dupeZ(u8, pwd); - self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec); + // Remainder needs a config. If there is no config we just assume + // we aren't inheriting any of these values. + if (priv.config) |config_obj| { + const config = config_obj.get(); + + // Setup our pwd if configured to inherit + if (config.@"window-inherit-working-directory") { + if (parent.rt_surface.surface.getPwd()) |pwd| { + priv.pwd = glib.ext.dupeZ(u8, pwd); + self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec); + } + } } } @@ -520,16 +547,20 @@ pub const Surface = extern struct { priv.gl_area.queueRender(); } - /// Ring the bell. - pub fn ringBell(self: *Self) void { - // TODO: Audio feature + /// Callback used to determine whether border should be shown around the + /// surface. + fn closureShouldBorderBeShown( + _: *Self, + config_: ?*Config, + bell_ringing_: c_int, + ) callconv(.c) c_int { + const config = if (config_) |v| v.get() else { + log.warn("config unavailable for computing whether border should be shown , likely bug", .{}); + return @intFromBool(false); + }; - signals.bell.impl.emit( - self, - null, - .{}, - null, - ); + const bell_ringing = bell_ringing_ != 0; + return @intFromBool(config.@"bell-features".border and bell_ringing); } pub fn toggleFullscreen(self: *Self) void { @@ -555,6 +586,41 @@ pub const Surface = extern struct { return self.as(gtk.Widget).activateAction("win.toggle-command-palette", null) != 0; } + pub fn controlInspector( + self: *Self, + value: apprt.Action.Value(.inspector), + ) bool { + // Let's see if we have an inspector already. + const priv = self.private(); + if (priv.inspector) |inspector| switch (value) { + .show => {}, + // Our weak ref will set our private value to null + .toggle, .hide => inspector.as(gtk.Window).destroy(), + } else switch (value) { + .toggle, .show => { + const inspector = InspectorWindow.new(self); + inspector.present(); + inspector.as(gobject.Object).weakRef(inspectorWeakNotify, self); + priv.inspector = inspector; + }, + + .hide => {}, + } + + return true; + } + + /// Redraw our inspector, if there is one associated with this surface. + pub fn redrawInspector(self: *Self) void { + const priv = self.private(); + if (priv.inspector) |v| v.queueRender(); + } + + pub fn showOnScreenKeyboard(self: *Self, event: ?*gdk.Event) bool { + const priv = self.private(); + return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0; + } + /// Set the current progress report state. pub fn setProgressReport( self: *Self, @@ -691,7 +757,7 @@ pub const Surface = extern struct { keycode: c_uint, gtk_mods: gdk.ModifierType, ) bool { - log.warn("keyEvent action={}", .{action}); + //log.warn("keyEvent action={}", .{action}); const event = ec_key.as(gtk.EventController).getCurrentEvent() orelse return false; const key_event = gobject.ext.cast(gdk.KeyEvent, event) orelse return false; const priv = self.private(); @@ -881,6 +947,10 @@ pub const Surface = extern struct { surface.preeditCallback(null) catch {}; } + // Bell stops ringing when any key is pressed that is used by + // the core in any way. + self.setBellRinging(false); + return true; }, } @@ -888,6 +958,26 @@ pub const Surface = extern struct { return false; } + /// Prompt for a manual title change for the surface. + pub fn promptTitle(self: *Self) void { + const priv = self.private(); + const dialog = gobject.ext.newInstance( + TitleDialog, + .{ + .@"initial-value" = priv.title_override orelse priv.title, + }, + ); + _ = TitleDialog.signals.set.connect( + dialog, + *Self, + titleDialogSet, + self, + .{}, + ); + + dialog.present(self.as(gtk.Widget)); + } + /// Scale x/y by the GDK device scale. fn scaledCoordinates( self: *Self, @@ -965,11 +1055,11 @@ pub const Surface = extern struct { //--------------------------------------------------------------- // Libghostty Callbacks - pub fn close(self: *Self, scope: CloseScope) void { + pub fn close(self: *Self) void { signals.@"close-request".impl.emit( self, null, - .{&scope}, + .{}, null, ); } @@ -1150,6 +1240,9 @@ pub const Surface = extern struct { fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); + // Initialize our actions + self.initActionMap(); + const priv = self.private(); // Initialize some private fields so they aren't undefined @@ -1190,18 +1283,28 @@ pub const Surface = extern struct { renderer.OpenGL.MIN_VERSION_MAJOR, renderer.OpenGL.MIN_VERSION_MINOR, ); - gl_area.as(gtk.Widget).setCursorFromName("text"); + self.as(gtk.Widget).setCursorFromName("text"); // Initialize our config self.propConfig(undefined, null); } + fn initActionMap(self: *Self) void { + const actions = [_]ext.actions.Action(Self){ + .init("prompt-title", actionPromptTitle, null), + }; + + ext.actions.addAsGroup(Self, self, "surface", &actions); + } + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); + if (priv.config) |v| { v.unref(); priv.config = null; } + if (priv.progress_bar_timer) |timer| { if (glib.Source.remove(timer) == 0) { log.warn("unable to remove progress bar timer", .{}); @@ -1228,6 +1331,10 @@ pub const Surface = extern struct { // searching for this surface. Application.default().core().deleteSurface(self.rt()); + // NOTE: We must deinit the surface in the finalize call and NOT + // the dispose call because the inspector widget relies on this + // behavior with a weakRef to properly deactivate. + // Deinit the surface v.deinit(); const alloc = Application.default().allocator(); @@ -1259,6 +1366,10 @@ pub const Surface = extern struct { glib.free(@constCast(@ptrCast(v))); priv.title = null; } + if (priv.title_override) |v| { + glib.free(@constCast(@ptrCast(v))); + priv.title_override = null; + } self.clearCgroup(); gobject.Object.virtual_methods.finalize.call( @@ -1275,7 +1386,9 @@ pub const Surface = extern struct { return self.private().title; } - /// Set the title for this surface, copies the value. + /// Set the title for this surface, copies the value. This should always + /// be the title as set by the terminal program, not any manually set + /// title. For manually set titles see `setTitleOverride`. pub fn setTitle(self: *Self, title: ?[:0]const u8) void { const priv = self.private(); if (priv.title) |v| glib.free(@constCast(@ptrCast(v))); @@ -1284,6 +1397,16 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.title.impl.param_spec); } + /// Overridden title. This will be generally be shown over the title + /// unless this is unset (null). + pub fn setTitleOverride(self: *Self, title: ?[:0]const u8) void { + const priv = self.private(); + if (priv.title_override) |v| glib.free(@constCast(@ptrCast(v))); + priv.title_override = null; + if (title) |v| priv.title_override = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.@"title-override".impl.param_spec); + } + /// Returns the pwd property without a copy. pub fn getPwd(self: *Self) ?[:0]const u8 { return self.private().pwd; @@ -1383,6 +1506,17 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"mouse-hover-url".impl.param_spec); } + pub fn getBellRinging(self: *Self) bool { + return self.private().bell_ringing; + } + + pub fn setBellRinging(self: *Self, ringing: bool) void { + const priv = self.private(); + if (priv.bell_ringing == ringing) return; + priv.bell_ringing = ringing; + self.as(gobject.Object).notifyByPspec(properties.@"bell-ringing".impl.param_spec); + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, @@ -1454,7 +1588,7 @@ pub const Surface = extern struct { // If we're hidden we set it to "none" if (priv.mouse_hidden) { - priv.gl_area.as(gtk.Widget).setCursorFromName("none"); + self.as(gtk.Widget).setCursorFromName("none"); return; } @@ -1512,18 +1646,87 @@ pub const Surface = extern struct { }; // Set our new cursor. - priv.gl_area.as(gtk.Widget).setCursorFromName(name.ptr); + self.as(gtk.Widget).setCursorFromName(name.ptr); + } + + fn propBellRinging( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + if (!priv.bell_ringing) return; + + // Activate actions if they exist + _ = self.as(gtk.Widget).activateAction("tab.ring-bell", null); + _ = self.as(gtk.Widget).activateAction("win.ring-bell", null); + + // Do our sound + const config = if (priv.config) |c| c.get() else return; + if (config.@"bell-features".audio) audio: { + const config_path = config.@"bell-audio-path" orelse break :audio; + const path, const required = switch (config_path) { + .optional => |path| .{ path, false }, + .required => |path| .{ path, true }, + }; + + const volume = std.math.clamp( + config.@"bell-audio-volume", + 0.0, + 1.0, + ); + + assert(std.fs.path.isAbsolute(path)); + const media_file = gtk.MediaFile.newForFilename(path); + + // If the audio file is marked as required, we'll emit an error if + // there was a problem playing it. Otherwise there will be silence. + if (required) { + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + mediaFileError, + null, + .{ .detail = "error" }, + ); + } + + // Watch for the "ended" signal so that we can clean up after + // ourselves. + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + mediaFileEnded, + null, + .{ .detail = "ended" }, + ); + + const media_stream = media_file.as(gtk.MediaStream); + media_stream.setVolume(volume); + media_stream.play(); + } } //--------------------------------------------------------------- // Signal Handlers + pub fn actionPromptTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.prompt_surface_title) catch |err| { + log.warn("unable to perform prompt title action err={}", .{err}); + }; + } + fn childExitedClose( _: *ChildExited, self: *Self, ) callconv(.c) void { // This closes the surface with no confirmation. - self.close(.{ .surface = false }); + self.close(); } fn contextMenuClosed( @@ -1536,6 +1739,15 @@ pub const Surface = extern struct { self.grabFocus(); } + fn inspectorWeakNotify( + ud: ?*anyopaque, + _: *gobject.Object, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud orelse return)); + const priv = self.private(); + priv.inspector = null; + } + fn dtDrop( _: *gtk.DropTarget, value: *gobject.Value, @@ -1545,10 +1757,7 @@ pub const Surface = extern struct { ) callconv(.c) c_int { const alloc = Application.default().allocator(); - if (g_value_holds( - value, - gdk.FileList.getGObjectType(), - )) { + if (ext.gValueHolds(value, gdk.FileList.getGObjectType())) { var data = std.ArrayList(u8).init(alloc); defer data.deinit(); @@ -1592,7 +1801,7 @@ pub const Surface = extern struct { return 1; } - if (g_value_holds(value, gio.File.getGObjectType())) { + if (ext.gValueHolds(value, gio.File.getGObjectType())) { const object = value.getObject() orelse return 0; const file = gobject.ext.cast(gio.File, object) orelse return 0; const path = file.getPath() orelse return 0; @@ -1620,7 +1829,7 @@ pub const Surface = extern struct { return 1; } - if (g_value_holds(value, gobject.ext.types.string)) { + if (ext.gValueHolds(value, gobject.ext.types.string)) { if (value.getString()) |string| { Clipboard.paste(self, std.mem.span(string)); } @@ -1668,6 +1877,9 @@ pub const Surface = extern struct { priv.im_context.as(gtk.IMContext).focusIn(); _ = glib.idleAddOnce(idleFocus, self.ref()); self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); + + // Bell stops ringing as soon as we gain focus + self.setBellRinging(false); } fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { @@ -1704,6 +1916,9 @@ pub const Surface = extern struct { ) callconv(.c) void { const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return; + // Bell stops ringing if any mouse button is pressed. + self.setBellRinging(false); + // If we don't have focus, grab it. const priv = self.private(); const gl_area_widget = priv.gl_area.as(gtk.Widget); @@ -1760,18 +1975,29 @@ pub const Surface = extern struct { const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return; const priv = self.private(); - if (priv.core_surface) |surface| { - const gtk_mods = event.getModifierState(); - const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); - const mods = gtk_key.translateMods(gtk_mods); - _ = surface.mouseButtonCallback( - .release, - button, - mods, - ) catch |err| { - log.warn("error in key callback err={}", .{err}); - return; - }; + const surface = priv.core_surface orelse return; + const gtk_mods = event.getModifierState(); + const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); + + const mods = gtk_key.translateMods(gtk_mods); + const consumed = surface.mouseButtonCallback( + .release, + button, + mods, + ) catch |err| { + log.warn("error in key callback err={}", .{err}); + return; + }; + + // Trigger the on-screen keyboard if we have no selection, + // and that the mouse event hasn't been intercepted by the callback. + // + // It's better to do this here rather than within the core callback + // since we have direct access to the underlying gdk.Event here. + if (!consumed and button == .left and !surface.hasSelection()) { + if (!self.showOnScreenKeyboard(event)) { + log.warn("failed to activate the on-screen keyboard", .{}); + } } } @@ -2314,6 +2540,44 @@ pub const Surface = extern struct { right.setVisible(0); } + fn mediaFileError( + media_file: *gtk.MediaFile, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const path = path: { + const file = media_file.getFile() orelse break :path null; + break :path file.getPath(); + }; + defer if (path) |p| glib.free(p); + + const media_stream = media_file.as(gtk.MediaStream); + const err = media_stream.getError() orelse return; + log.warn("error playing bell from {s}: {s} {d} {s}", .{ + path orelse "<>", + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "", + }); + } + + fn mediaFileEnded( + media_file: *gtk.MediaFile, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + media_file.unref(); + } + + fn titleDialogSet( + _: *TitleDialog, + title_ptr: [*:0]const u8, + self: *Self, + ) callconv(.c) void { + const title = std.mem.span(title_ptr); + self.setTitleOverride(if (title.len == 0) null else title); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -2378,9 +2642,12 @@ pub const Surface = extern struct { class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl); class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden); class.bindTemplateCallback("notify_mouse_shape", &propMouseShape); + class.bindTemplateCallback("notify_bell_ringing", &propBellRinging); + class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); // Properties gobject.ext.registerProperties(class, &.{ + properties.@"bell-ringing".impl, properties.config.impl, properties.@"child-exited".impl, properties.@"default-size".impl, @@ -2392,12 +2659,12 @@ pub const Surface = extern struct { properties.@"mouse-hover-url".impl, properties.pwd.impl, properties.title.impl, + properties.@"title-override".impl, properties.zoom.impl, }); // Signals signals.@"close-request".impl.register(.{}); - signals.bell.impl.register(.{}); signals.@"clipboard-read".impl.register(.{}); signals.@"clipboard-write".impl.register(.{}); signals.init.impl.register(.{}); @@ -2416,25 +2683,6 @@ pub const Surface = extern struct { pub const bindTemplateCallback = C.Class.bindTemplateCallback; }; - /// The scope of a close request. - pub const CloseScope = union(enum) { - /// Close the surface. The boolean determines if there is a - /// process active. - surface: bool, - - /// Close the tab. We can't know if there are processes active - /// for the entire tab scope so listeners must query the app. - tab, - - /// Close the window. - window, - - pub const getGObjectType = gobject.ext.defineBoxed( - CloseScope, - .{ .name = "GhosttySurfaceCloseScope" }, - ); - }; - /// Simple dimensions struct for the surface used by various properties. pub const Size = extern struct { width: u32, @@ -2765,16 +3013,6 @@ const Clipboard = struct { }; }; -/// Check a GValue to see what's type its wrapping. This is equivalent to GTK's -/// `G_VALUE_HOLDS` macro but Zig's C translator does not like it. -fn g_value_holds(value_: ?*gobject.Value, g_type: gobject.Type) bool { - if (value_) |value| { - if (value.f_g_type == g_type) return true; - return gobject.typeCheckValueHolds(value, g_type) != 0; - } - return false; -} - /// Compute a fraction [0.0, 1.0] from the supplied progress, which is clamped /// to [0, 100]. fn computeFraction(progress: u8) f64 { diff --git a/src/apprt/gtk-ng/class/surface_title_dialog.zig b/src/apprt/gtk-ng/class/surface_title_dialog.zig new file mode 100644 index 000000000..de36f3090 --- /dev/null +++ b/src/apprt/gtk-ng/class/surface_title_dialog.zig @@ -0,0 +1,190 @@ +const std = @import("std"); +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const adw_version = @import("../adw_version.zig"); +const ext = @import("../ext.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_surface_title_dialog); + +pub const SurfaceTitleDialog = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.AlertDialog; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySurfaceTitleDialog", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const @"initial-value" = struct { + pub const name = "initial-value"; + pub const get = impl.get; + pub const set = impl.set; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("initial_value"), + }, + ); + }; + }; + + pub const signals = struct { + /// Set the title to the given value. + pub const set = struct { + pub const name = "set"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{[*:0]const u8}, + void, + ); + }; + }; + + const Private = struct { + /// The initial value of the entry field. + initial_value: ?[:0]const u8 = null, + + // Template bindings + entry: *gtk.Entry, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + pub fn present(self: *Self, parent_: *gtk.Widget) void { + // If we have a window we can attach to, we prefer that. + const parent: *gtk.Widget = if (ext.getAncestor( + adw.ApplicationWindow, + parent_, + )) |window| + window.as(gtk.Widget) + else if (ext.getAncestor( + adw.Window, + parent_, + )) |window| + window.as(gtk.Widget) + else + parent_; + + // Set our initial value + const priv = self.private(); + if (priv.initial_value) |v| { + priv.entry.getBuffer().setText(v, -1); + } + + // Show it. We could also just use virtual methods to bind to + // response but this is pretty simple. + self.as(adw.AlertDialog).choose( + parent, + null, + alertDialogReady, + self, + ); + } + + fn alertDialogReady( + _: ?*gobject.Object, + result: *gio.AsyncResult, + ud: ?*anyopaque, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud)); + const response = self.as(adw.AlertDialog).chooseFinish(result); + + // If we didn't hit "okay" then we do nothing. + if (std.mem.orderZ(u8, "ok", response) != .eq) return; + + // Emit our signal with the new title. + const title = std.mem.span(self.private().entry.getBuffer().getText()); + signals.set.impl.emit( + self, + null, + .{title.ptr}, + null, + ); + } + + fn dispose(self: *Self) callconv(.c) void { + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.initial_value) |v| { + glib.free(@constCast(@ptrCast(v))); + priv.initial_value = null; + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "surface-title-dialog", + }), + ); + + // Signals + signals.set.impl.register(.{}); + + // Bindings + class.bindTemplateChildPrivate("entry", .{}); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.@"initial-value".impl, + }); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 428ce72d6..5f1cf50de 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -11,6 +11,7 @@ const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const input = @import("../../../input.zig"); const CoreSurface = @import("../../../Surface.zig"); +const ext = @import("../ext.zig"); const gtk_version = @import("../gtk_version.zig"); const adw_version = @import("../adw_version.zig"); const gresource = @import("../build/gresource.zig"); @@ -70,6 +71,24 @@ pub const Tab = extern struct { ); }; + pub const @"split-tree" = struct { + pub const name = "split-tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*SplitTree, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*SplitTree, + .{ + .getter = getSplitTree, + }, + ), + }, + ); + }; + pub const @"surface-tree" = struct { pub const name = "surface-tree"; const impl = gobject.ext.defineProperty( @@ -88,10 +107,21 @@ pub const Tab = extern struct { ); }; + pub const tooltip = struct { + pub const name = "tooltip"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("tooltip"), + }, + ); + }; + pub const title = struct { pub const name = "title"; - pub const get = impl.get; - pub const set = impl.set; const impl = gobject.ext.defineProperty( name, Self, @@ -122,12 +152,11 @@ pub const Tab = extern struct { /// The configuration that this surface is using. config: ?*Config = null, - /// The title to show for this tab. This is usually set to a binding - /// with the active surface but can be manually set to anything. + /// The title of this tab. This is usually bound to the active surface. title: ?[:0]const u8 = null, - /// The binding groups for the current active surface. - surface_bindings: *gobject.BindingGroup, + /// The tooltip of this tab. This is usually bound to the active surface. + tooltip: ?[:0]const u8 = null, // Template bindings split_tree: *SplitTree, @@ -147,6 +176,9 @@ pub const Tab = extern struct { fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); + // Init our actions + self.initActionMap(); + // If our configuration is null then we get the configuration // from the application. const priv = self.private(); @@ -155,15 +187,6 @@ pub const Tab = extern struct { priv.config = app.getConfig(); } - // Setup binding groups for surface properties - priv.surface_bindings = gobject.BindingGroup.new(); - priv.surface_bindings.bind( - "title", - self.as(gobject.Object), - "title", - .{}, - ); - // Create our initial surface in the split tree. priv.split_tree.newSplit(.right, null) catch |err| switch (err) { error.OutOfMemory => { @@ -175,6 +198,15 @@ pub const Tab = extern struct { }; } + fn initActionMap(self: *Self) void { + const actions = [_]ext.actions.Action(Self){ + .init("close", actionClose, null), + .init("ring-bell", actionRingBell, null), + }; + + ext.actions.addAsGroup(Self, self, "tab", &actions); + } + //--------------------------------------------------------------- // Properties @@ -199,9 +231,17 @@ pub const Tab = extern struct { /// Returns true if this tab needs confirmation before quitting based /// on the various Ghostty configurations. pub fn getNeedsConfirmQuit(self: *Self) bool { - const surface = self.getActiveSurface() orelse return false; - const core_surface = surface.core() orelse return false; - return core_surface.needsConfirmQuit(); + const tree = self.getSplitTree(); + return tree.getNeedsConfirmQuit(); + } + + /// Get the tab page holding this tab, if any. + fn getTabPage(self: *Self) ?*adw.TabPage { + const tab_view = ext.getAncestor( + adw.TabView, + self.as(gtk.Widget), + ) orelse return null; + return tab_view.getPage(self.as(gtk.Widget)); } //--------------------------------------------------------------- @@ -213,7 +253,6 @@ pub const Tab = extern struct { v.unref(); priv.config = null; } - priv.surface_bindings.setSource(null); gtk.Widget.disposeTemplate( self.as(gtk.Widget), @@ -228,11 +267,14 @@ pub const Tab = extern struct { fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); + if (priv.tooltip) |v| { + glib.free(@constCast(@ptrCast(v))); + priv.tooltip = null; + } if (priv.title) |v| { glib.free(@constCast(@ptrCast(v))); priv.title = null; } - priv.surface_bindings.unref(); gobject.Object.virtual_methods.finalize.call( Class.parent, @@ -267,13 +309,90 @@ pub const Tab = extern struct { _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { - const priv = self.private(); - priv.surface_bindings.setSource(null); - if (self.getActiveSurface()) |surface| { - priv.surface_bindings.setSource(surface.as(gobject.Object)); + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + } + + fn actionClose( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const tab_view = ext.getAncestor( + adw.TabView, + self.as(gtk.Widget), + ) orelse return; + const page = tab_view.getPage(self.as(gtk.Widget)); + + // Delegate to our parent to handle this, since this will emit + // a close-page signal that the parent can intercept. + tab_view.closePage(page); + } + + fn actionRingBell( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + // Future note: I actually don't like this logic living here at all. + // I think a better approach will be for the ring bell action to + // specify its sending surface and then do all this in the window. + + // If the page is selected already we don't mark it as needing + // attention. We only want to mark unfocused pages. This will then + // clear when the page is selected. + const page = self.getTabPage() orelse return; + if (page.getSelected() != 0) return; + page.setNeedsAttention(@intFromBool(true)); + } + + fn closureComputedTitle( + _: *Self, + config_: ?*Config, + terminal_: ?[*:0]const u8, + override_: ?[*:0]const u8, + zoomed_: c_int, + bell_ringing_: c_int, + _: *gobject.ParamSpec, + ) callconv(.c) ?[*:0]const u8 { + const zoomed = zoomed_ != 0; + const bell_ringing = bell_ringing_ != 0; + + // Our plain title is the overridden title if it exists, otherwise + // the terminal title if it exists, otherwise a default string. + const plain = plain: { + const default = "Ghostty"; + const plain = override_ orelse + terminal_ orelse + break :plain default; + break :plain std.mem.span(plain); + }; + + // We don't need a config in every case, but if we don't have a config + // let's just assume something went terribly wrong and use our + // default title. Its easier then guarding on the config existing + // in every case for something so unlikely. + const config = if (config_) |v| v.get() else { + log.warn("config unavailable for computed title, likely bug", .{}); + return glib.ext.dupeZ(u8, plain); + }; + + // Use an allocator to build up our string as we write it. + var buf: std.ArrayList(u8) = .init(Application.default().allocator()); + defer buf.deinit(); + const writer = buf.writer(); + + // If our bell is ringing, then we prefix the bell icon to the title. + if (bell_ringing and config.@"bell-features".title) { + writer.writeAll("🔔 ") catch {}; } - self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + // If we're zoomed, prefix with the magnifying glass emoji. + if (zoomed) { + writer.writeAll("🔍 ") catch {}; + } + + writer.writeAll(plain) catch return glib.ext.dupeZ(u8, plain); + return glib.ext.dupeZ(u8, buf.items); } const C = Common(Self, Private); @@ -303,14 +422,17 @@ pub const Tab = extern struct { gobject.ext.registerProperties(class, &.{ properties.@"active-surface".impl, properties.config.impl, + properties.@"split-tree".impl, properties.@"surface-tree".impl, properties.title.impl, + properties.tooltip.impl, }); // Bindings class.bindTemplateChildPrivate("split_tree", .{}); // Template Callbacks + class.bindTemplateCallback("computed_title", &closureComputedTitle); class.bindTemplateCallback("notify_active_surface", &propActiveSurface); class.bindTemplateCallback("notify_tree", &propSplitTree); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index eb41b61d0..82d961e17 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -28,6 +28,7 @@ const Surface = @import("surface.zig").Surface; const Tab = @import("tab.zig").Tab; const DebugWarning = @import("debug_warning.zig").DebugWarning; const CommandPalette = @import("command_palette.zig").CommandPalette; +const InspectorWindow = @import("inspector_window.zig").InspectorWindow; const WeakRef = @import("../weak_ref.zig").WeakRef; const log = std.log.scoped(.gtk_ghostty_window); @@ -318,8 +319,15 @@ pub const Window = extern struct { ); } + // Start states based on config. + if (priv.config) |config_obj| { + const config = config_obj.get(); + if (config.maximize) self.as(gtk.Window).maximize(); + if (config.fullscreen) self.as(gtk.Window).fullscreen(); + } + // We always sync our appearance at the end because loading our - // config and such can affect our bindings which ar setup initially + // config and such can affect our bindings which are setup initially // in initTemplate. self.syncAppearance(); @@ -330,40 +338,27 @@ pub const Window = extern struct { /// Setup our action map. fn initActionMap(self: *Self) void { - const actions = .{ - .{ "about", actionAbout, null }, - .{ "close", actionClose, null }, - .{ "close-tab", actionCloseTab, null }, - .{ "new-tab", actionNewTab, null }, - .{ "new-window", actionNewWindow, null }, - .{ "split-right", actionSplitRight, null }, - .{ "split-left", actionSplitLeft, null }, - .{ "split-up", actionSplitUp, null }, - .{ "split-down", actionSplitDown, null }, - .{ "copy", actionCopy, null }, - .{ "paste", actionPaste, null }, - .{ "reset", actionReset, null }, - .{ "clear", actionClear, null }, + const actions = [_]ext.actions.Action(Self){ + .init("about", actionAbout, null), + .init("close", actionClose, null), + .init("close-tab", actionCloseTab, null), + .init("new-tab", actionNewTab, null), + .init("new-window", actionNewWindow, null), + .init("ring-bell", actionRingBell, null), + .init("split-right", actionSplitRight, null), + .init("split-left", actionSplitLeft, null), + .init("split-up", actionSplitUp, null), + .init("split-down", actionSplitDown, null), + .init("copy", actionCopy, null), + .init("paste", actionPaste, null), + .init("reset", actionReset, null), + .init("clear", actionClear, null), // TODO: accept the surface that toggled the command palette - .{ "toggle-command-palette", actionToggleCommandPalette, null }, + .init("toggle-command-palette", actionToggleCommandPalette, null), + .init("toggle-inspector", actionToggleInspector, null), }; - const action_map = self.as(gio.ActionMap); - inline for (actions) |entry| { - const action = gio.SimpleAction.new( - entry[0], - entry[2], - ); - defer action.unref(); - _ = gio.SimpleAction.signals.activate.connect( - action, - *Self, - entry[1], - self, - .{}, - ); - action_map.addAction(action.as(gio.Action)); - } + ext.actions.add(Self, self, &actions); } /// Winproto backend for this window. @@ -416,6 +411,12 @@ pub const Window = extern struct { "title", .{ .sync_create = true }, ); + _ = tab.as(gobject.Object).bindProperty( + "tooltip", + page.as(gobject.Object), + "tooltip", + .{ .sync_create = true }, + ); // Bind signals const split_tree = tab.getSplitTree(); @@ -556,16 +557,46 @@ pub const Window = extern struct { /// fullscreen, etc.). fn syncAppearance(self: *Self) void { const priv = self.private(); - const csd_enabled = priv.winproto.clientSideDecorationEnabled(); - self.as(gtk.Window).setDecorated(@intFromBool(csd_enabled)); + const widget = self.as(gtk.Widget); - // Fix any artifacting that may occur in window corners. The .ssd CSS - // class is defined in the GtkWindow documentation: - // https://docs.gtk.org/gtk4/class.Window.html#css-nodes. A definition - // for .ssd is provided by GTK and Adwaita. - self.toggleCssClass("csd", csd_enabled); - self.toggleCssClass("ssd", !csd_enabled); - self.toggleCssClass("no-border-radius", !csd_enabled); + // Toggle style classes based on whether we're using CSDs or SSDs. + // + // These classes are defined in the gtk.Window documentation: + // https://docs.gtk.org/gtk4/class.Window.html#css-nodes. + { + // Reset all style classes first + inline for (&.{ + "ssd", + "csd", + "solid-csd", + "no-border-radius", + }) |class| + widget.removeCssClass(class); + + const csd_enabled = priv.winproto.clientSideDecorationEnabled(); + self.as(gtk.Window).setDecorated(@intFromBool(csd_enabled)); + + if (csd_enabled) { + const display = widget.getDisplay(); + + // We do the exact same check GTK is doing internally and toggle + // either the `csd` or `solid-csd` style, based on whether the user's + // window manager is deemed _non-compositing_. + // + // In practice this only impacts users of traditional X11 window + // managers (e.g. i3, dwm, awesomewm, etc.) and not X11 desktop + // environments or Wayland compositors/DEs. + if (display.isRgba() != 0 and display.isComposited() != 0) { + widget.addCssClass("csd"); + } else { + widget.addCssClass("solid-csd"); + } + } else { + widget.addCssClass("ssd"); + // Fix any artifacting that may occur in window corners. + widget.addCssClass("no-border-radius"); + } + } // Trigger all our dynamic properties that depend on the config. inline for (&.{ @@ -674,13 +705,6 @@ pub const Window = extern struct { var it = tree.iterator(); while (it.next()) |entry| { const surface = entry.view; - _ = Surface.signals.@"close-request".connect( - surface, - *Self, - surfaceCloseRequest, - self, - .{}, - ); _ = Surface.signals.@"present-request".connect( surface, *Self, @@ -1060,6 +1084,21 @@ pub const Window = extern struct { }); } + fn closureSubtitle( + _: *Self, + config_: ?*Config, + pwd_: ?[*:0]const u8, + ) callconv(.c) ?[*:0]const u8 { + const config = if (config_) |v| v.get() else return null; + return switch (config.@"window-subtitle") { + .false => null, + .@"working-directory" => pwd: { + const pwd = pwd_ orelse return null; + break :pwd glib.ext.dupeZ(u8, std.mem.span(pwd)); + }, + }; + } + //--------------------------------------------------------------- // Virtual methods @@ -1296,6 +1335,10 @@ pub const Window = extern struct { // Setup our binding group. This ensures things like the title // are synced from the active tab. priv.tab_bindings.setSource(child.as(gobject.Object)); + + // If the tab was previously marked as needing attention + // (e.g. due to a bell character), we now unmark that + page.setNeedsAttention(@intFromBool(false)); } fn tabViewPageAttached( @@ -1430,25 +1473,6 @@ pub const Window = extern struct { self.addToast(i18n._("Cleared clipboard")); } - fn surfaceCloseRequest( - _: *Surface, - scope: *const Surface.CloseScope, - self: *Self, - ) callconv(.c) void { - switch (scope.*) { - // Handled directly by the tab. If the surface is the last - // surface then the tab will emit its own signal to request - // closing itself. - .surface => return, - - // Also handled directly by the tab. - .tab => return, - - // The only one we care about! - .window => self.as(gtk.Window).close(), - } - } - fn surfaceMenu( _: *Surface, self: *Self, @@ -1708,6 +1732,30 @@ pub const Window = extern struct { self.performBindingAction(.clear_screen); } + fn actionRingBell( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return; + + if (config.@"bell-features".system) system: { + const native = self.as(gtk.Native).getSurface() orelse { + log.warn("unable to get native surface from window", .{}); + break :system; + }; + native.beep(); + } + + if (config.@"bell-features".attention) { + // Request user attention + self.winproto().setUrgent(true) catch |err| { + log.warn("failed to request user attention={}", .{err}); + }; + } + } + /// Toggle the command palette. /// /// TODO: accept the surface that toggled the command palette as a parameter @@ -1770,6 +1818,23 @@ pub const Window = extern struct { self.toggleCommandPalette(); } + /// Toggle the Ghostty inspector for the active surface. + fn toggleInspector(self: *Self) void { + const surface = self.getActiveSurface() orelse return; + _ = surface.controlInspector(.toggle); + } + + /// React to a GTK action requesting that the Ghostty inspector be toggled. + fn actionToggleInspector( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + // TODO: accept the surface that toggled the command palette as a + // parameter + self.toggleInspector(); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -1783,6 +1848,9 @@ pub const Window = extern struct { fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(DebugWarning); + gobject.ext.ensureType(SplitTree); + gobject.ext.ensureType(Surface); + gobject.ext.ensureType(Tab); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), comptime gresource.blueprint(.{ @@ -1832,6 +1900,7 @@ pub const Window = extern struct { class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal); class.bindTemplateCallback("notify_scale_factor", &propScaleFactor); class.bindTemplateCallback("titlebar_style_is_tabs", &closureTitlebarStyleIsTab); + class.bindTemplateCallback("computed_subtitle", &closureSubtitle); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk-ng/css/style.css b/src/apprt/gtk-ng/css/style.css index a1a425f66..5901d1d7e 100644 --- a/src/apprt/gtk-ng/css/style.css +++ b/src/apprt/gtk-ng/css/style.css @@ -102,6 +102,12 @@ label.resize-overlay { /* background-color: color-mix(in srgb, var(--error-bg-color), transparent); */ } +.surface .bell-overlay { + border-color: color-mix(in srgb, var(--accent-color), transparent 50%); + border-width: 3px; + border-style: solid; +} + /* * Command Palette */ diff --git a/src/apprt/gtk-ng/ext.zig b/src/apprt/gtk-ng/ext.zig index 3e80a9998..18587d9ca 100644 --- a/src/apprt/gtk-ng/ext.zig +++ b/src/apprt/gtk-ng/ext.zig @@ -5,11 +5,15 @@ const std = @import("std"); const assert = std.debug.assert; +const testing = std.testing; +const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +pub const actions = @import("ext/actions.zig"); + /// Wrapper around `gobject.boxedCopy` to copy a boxed type `T`. pub fn boxedCopy(comptime T: type, ptr: *const T) *T { const copy = gobject.boxedCopy(T.getGObjectType(), ptr); @@ -50,3 +54,15 @@ pub fn getAncestor(comptime T: type, widget: *gtk.Widget) ?*T { // We can assert the unwrap because getAncestor above return gobject.ext.cast(T, ancestor).?; } + +/// Check a gobject.Value to see what type it is wrapping. This is equivalent to GTK's +/// `G_VALUE_HOLDS()` macro but Zig's C translator does not like it. +pub fn gValueHolds(value_: ?*const gobject.Value, g_type: gobject.Type) bool { + const value = value_ orelse return false; + if (value.f_g_type == g_type) return true; + return gobject.typeCheckValueHolds(value, g_type) != 0; +} + +test { + _ = actions; +} diff --git a/src/apprt/gtk-ng/ext/actions.zig b/src/apprt/gtk-ng/ext/actions.zig new file mode 100644 index 000000000..9f724c850 --- /dev/null +++ b/src/apprt/gtk-ng/ext/actions.zig @@ -0,0 +1,158 @@ +const std = @import("std"); + +const assert = std.debug.assert; +const testing = std.testing; + +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gValueHolds = @import("../ext.zig").gValueHolds; + +/// Check that an action name is valid. +/// +/// Reimplementation of `g_action_name_is_valid()` so that it can be +/// used at comptime. +/// +/// See: +/// https://docs.gtk.org/gio/type_func.Action.name_is_valid.html +fn gActionNameIsValid(name: [:0]const u8) bool { + if (name.len == 0) return false; + + for (name) |c| switch (c) { + '-' => continue, + '.' => continue, + '0'...'9' => continue, + 'a'...'z' => continue, + 'A'...'Z' => continue, + else => return false, + }; + + return true; +} + +test "gActionNameIsValid" { + try testing.expect(gActionNameIsValid("ring-bell")); + try testing.expect(!gActionNameIsValid("ring_bell")); +} + +/// Function to create a structure for describing an action. +pub fn Action(comptime T: type) type { + return struct { + pub const Callback = *const fn (*gio.SimpleAction, ?*glib.Variant, *T) callconv(.c) void; + + name: [:0]const u8, + callback: Callback, + parameter_type: ?*const glib.VariantType, + + /// Function to initialize a new action so that we can comptime check the name. + pub fn init(comptime name: [:0]const u8, callback: Callback, parameter_type: ?*const glib.VariantType) @This() { + comptime assert(gActionNameIsValid(name)); + + return .{ + .name = name, + .callback = callback, + .parameter_type = parameter_type, + }; + } + }; +} + +/// Add actions to a widget that implements gio.ActionMap. +pub fn add(comptime T: type, self: *T, actions: []const Action(T)) void { + addToMap(T, self, self.as(gio.ActionMap), actions); +} + +/// Add actions to the given map. +pub fn addToMap(comptime T: type, self: *T, map: *gio.ActionMap, actions: []const Action(T)) void { + for (actions) |entry| { + assert(gActionNameIsValid(entry.name)); + const action = gio.SimpleAction.new( + entry.name, + entry.parameter_type, + ); + defer action.unref(); + _ = gio.SimpleAction.signals.activate.connect( + action, + *T, + entry.callback, + self, + .{}, + ); + map.addAction(action.as(gio.Action)); + } +} + +/// Add actions to a widget that doesn't implement ActionGroup directly. +pub fn addAsGroup(comptime T: type, self: *T, comptime name: [:0]const u8, actions: []const Action(T)) void { + comptime assert(gActionNameIsValid(name)); + + // Collect our actions into a group since we're just a plain widget that + // doesn't implement ActionGroup directly. + const group = gio.SimpleActionGroup.new(); + errdefer group.unref(); + + addToMap(T, self, group.as(gio.ActionMap), actions); + + self.as(gtk.Widget).insertActionGroup( + name, + group.as(gio.ActionGroup), + ); +} + +test "adding actions to an object" { + // This test requires a connection to an active display environment. + if (gtk.initCheck() == 0) return; + + const callbacks = struct { + fn callback(_: *gio.SimpleAction, variant_: ?*glib.Variant, self: *gtk.Box) callconv(.c) void { + const i32_variant_type = glib.ext.VariantType.newFor(i32); + defer i32_variant_type.free(); + + const variant = variant_ orelse return; + assert(variant.isOfType(i32_variant_type) != 0); + + var value = std.mem.zeroes(gobject.Value); + _ = value.init(gobject.ext.types.int); + defer value.unset(); + + value.setInt(variant.getInt32()); + + self.as(gobject.Object).setProperty("spacing", &value); + } + }; + + const box = gtk.Box.new(.vertical, 0); + _ = box.as(gobject.Object).refSink(); + defer box.unref(); + + { + const i32_variant_type = glib.ext.VariantType.newFor(i32); + defer i32_variant_type.free(); + + const actions = [_]Action(gtk.Box){ + .init("test", callbacks.callback, i32_variant_type), + }; + + addAsGroup(gtk.Box, box, "test", &actions); + } + + const expected = std.crypto.random.intRangeAtMost(i32, 1, std.math.maxInt(u31)); + const parameter = glib.Variant.newInt32(expected); + + try testing.expect(box.as(gtk.Widget).activateActionVariant("test.test", parameter) != 0); + + _ = glib.MainContext.iteration(null, @intFromBool(true)); + + var value = std.mem.zeroes(gobject.Value); + _ = value.init(gobject.ext.types.int); + defer value.unset(); + + box.as(gobject.Object).getProperty("spacing", &value); + + try testing.expect(gValueHolds(&value, gobject.ext.types.int)); + + const actual = value.getInt(); + try testing.expectEqual(expected, actual); +} diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 23499c7f3..6c027e735 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -6,6 +6,7 @@ template $GhosttySurface: Adw.Bin { "surface", ] + notify::bell-ringing => $notify_bell_ringing(); notify::config => $notify_config(); notify::mouse-hover-url => $notify_mouse_hover_url(); notify::mouse-hidden => $notify_mouse_hidden(); @@ -53,6 +54,27 @@ template $GhosttySurface: Adw.Bin { valign: start; } + [overlay] + // The "border" bell feature is implemented here as an overlay rather than + // just adding a border to the GLArea or other widget for two reasons. + // First, adding a border to an existing widget causes a resize of the + // widget which undesirable side effects. Second, we can make it reactive + // here in the blueprint with relatively little code. + Revealer { + reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as ; + transition-type: crossfade; + transition-duration: 500; + + Box bell_overlay { + styles [ + "bell-overlay", + ] + + halign: fill; + valign: fill; + } + } + [overlay] $GhosttySurfaceChildExited child_exited_overlay { visible: bind template.child-exited; @@ -130,6 +152,7 @@ template $GhosttySurface: Adw.Bin { } IMMulticontext im_context { + input-purpose: terminal; preedit-start => $im_preedit_start(); preedit-changed => $im_preedit_changed(); preedit-end => $im_preedit_end(); @@ -167,27 +190,31 @@ menu context_menu_model { item { label: _("Change Title…"); - action: "win.prompt-title"; + action: "surface.prompt-title"; } item { label: _("Split Up"); - action: "split-tree.new-up"; + action: "split-tree.new-split"; + target: "up"; } item { label: _("Split Down"); - action: "split-tree.new-down"; + action: "split-tree.new-split"; + target: "down"; } item { label: _("Split Left"); - action: "split-tree.new-left"; + action: "split-tree.new-split"; + target: "left"; } item { label: _("Split Right"); - action: "split-tree.new-right"; + action: "split-tree.new-split"; + target: "right"; } } diff --git a/src/apprt/gtk-ng/ui/1.5/imgui-widget.blp b/src/apprt/gtk-ng/ui/1.5/imgui-widget.blp new file mode 100644 index 000000000..d5b973a70 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/imgui-widget.blp @@ -0,0 +1,51 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyImguiWidget: Adw.Bin { + styles [ + "imgui", + ] + + Adw.Bin { + Gtk.GLArea gl_area { + auto-render: true; + // needs to be focusable so that we can receive events + focusable: true; + focus-on-click: true; + allowed-apis: gl; + realize => $realize(); + unrealize => $unrealize(); + resize => $resize(); + render => $render(); + + EventControllerFocus { + enter => $focus_enter(); + leave => $focus_leave(); + } + + EventControllerKey { + key-pressed => $key_pressed(); + key-released => $key_released(); + } + + GestureClick { + pressed => $mouse_pressed(); + released => $mouse_released(); + button: 0; + } + + EventControllerMotion { + motion => $mouse_motion(); + } + + EventControllerScroll { + scroll => $scroll(); + flags: both_axes; + } + } + } +} + +IMMulticontext im_context { + commit => $im_commit(); +} diff --git a/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp b/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp new file mode 100644 index 000000000..985a7ed23 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp @@ -0,0 +1,18 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyInspectorWidget: Adw.Bin { + styles [ + "inspector", + ] + + hexpand: true; + vexpand: true; + + Adw.Bin { + $GhosttyImguiWidget imgui_widget { + render => $imgui_render(); + setup => $imgui_setup(); + } + } +} diff --git a/src/apprt/gtk-ng/ui/1.5/inspector-window.blp b/src/apprt/gtk-ng/ui/1.5/inspector-window.blp new file mode 100644 index 000000000..a7625bc2c --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/inspector-window.blp @@ -0,0 +1,38 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyInspectorWindow: Adw.ApplicationWindow { + title: _("Ghostty: Terminal Inspector"); + icon-name: "com.mitchellh.ghostty"; + default-width: 1000; + default-height: 600; + + styles [ + "inspector", + ] + + content: Adw.ToolbarView { + [top] + Adw.HeaderBar { + title-widget: Adw.WindowTitle { + title: bind template.title; + }; + } + + Gtk.Box { + orientation: vertical; + spacing: 0; + hexpand: true; + vexpand: true; + + $GhosttyDebugWarning { + visible: bind template.debug; + } + + $GhosttyInspectorWidget inspector_widget { + notify::surface => $notify_inspector_surface(); + surface: bind template.surface; + } + } + }; +} diff --git a/src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp b/src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp new file mode 100644 index 000000000..24ae26f37 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp @@ -0,0 +1,16 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySurfaceTitleDialog: Adw.AlertDialog { + heading: _("Change Terminal Title"); + body: _("Leave blank to restore the default title."); + + responses [ + cancel: _("Cancel") suggested, + ok: _("OK") destructive, + ] + + focus-widget: entry; + + extra-child: Entry entry {}; +} diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 4cb47487d..687b18890 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -8,6 +8,8 @@ template $GhosttyTab: Box { orientation: vertical; hexpand: true; vexpand: true; + title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as ; + tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd; $GhosttySplitTree split_tree { notify::active-surface => $notify_active_surface(); diff --git a/src/apprt/gtk-ng/ui/1.5/window.blp b/src/apprt/gtk-ng/ui/1.5/window.blp index 4ca90dfb5..b09c0d9b3 100644 --- a/src/apprt/gtk-ng/ui/1.5/window.blp +++ b/src/apprt/gtk-ng/ui/1.5/window.blp @@ -40,6 +40,13 @@ template $GhosttyWindow: Adw.ApplicationWindow { title-widget: Adw.WindowTitle { title: bind template.title; + // Blueprint auto-formatter won't let me split this into multiple + // lines. Let me explain myself. All parameters to a closure are used + // as notifications to recompute the value of the closure. All + // elements of a property chain are also subscribed to for changes. + // This one long, ugly line saves us from manually building up this + // massive notify chain in code. + subtitle: bind $computed_subtitle(template.config, tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd) as ; }; [start] diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index faa4781f6..0f75a2d97 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -539,6 +539,7 @@ pub fn performAction( .check_for_updates, .undo, .redo, + .show_on_screen_keyboard, => { log.warn("unimplemented action={}", .{action}); return false; diff --git a/src/build/GhosttyDist.zig b/src/build/GhosttyDist.zig index 25ec7182b..6123582b7 100644 --- a/src/build/GhosttyDist.zig +++ b/src/build/GhosttyDist.zig @@ -25,6 +25,11 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { try resources.append(alloc, gtk.resources_c); try resources.append(alloc, gtk.resources_h); } + { + const gtk = SharedDeps.gtkNgDistResources(b); + try resources.append(alloc, gtk.resources_c); + try resources.append(alloc, gtk.resources_h); + } // git archive to create the final tarball. "git archive" is the // easiest way I can find to create a tarball that ignores stuff diff --git a/src/build/docker/debian/Dockerfile b/src/build/docker/debian/Dockerfile index b1389aa17..73c7da7c8 100644 --- a/src/build/docker/debian/Dockerfile +++ b/src/build/docker/debian/Dockerfile @@ -1,10 +1,11 @@ -ARG DISTRO_VERSION="12" +ARG DISTRO_VERSION="13" FROM docker.io/library/debian:${DISTRO_VERSION} # Install Dependencies RUN DEBIAN_FRONTEND="noninteractive" apt-get -qq update && \ apt-get -qq -y --no-install-recommends install \ # Build Tools + blueprint-compiler \ build-essential \ curl \ libbz2-dev \ @@ -16,33 +17,28 @@ RUN DEBIAN_FRONTEND="noninteractive" apt-get -qq update && \ pandoc \ # Ghostty Dependencies libadwaita-1-dev \ - libgtk-4-dev && \ - # TODO: Add when this is updated to Debian 13++ - # gtk4-layer-shell + libgtk-4-dev \ + libgtk4-layer-shell-dev && \ # Clean up for better caching rm -rf /var/lib/apt/lists/* -# work around the fact that Debian 12 doesn't ship a pkg-config file for bzip2 -RUN . /etc/os-release; if [ $VERSION_ID -le 12 ]; then ln -s libbz2.so /usr/lib/$(gcc -dumpmachine)/libbzip2.so; fi +WORKDIR /src + +COPY ./build.zig /src # Install zig # https://ziglang.org/download/ -COPY . /src - -WORKDIR /src - RUN export ZIG_VERSION=$(sed -n -e 's/^.*requireZig("\(.*\)").*$/\1/p' build.zig) && curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-linux-$(uname -m)-$ZIG_VERSION.tar.xz" && \ tar -xf /tmp/zig.tar.xz -C /opt && \ rm /tmp/zig.tar.xz && \ ln -s "/opt/zig-linux-$(uname -m)-$ZIG_VERSION/zig" /usr/local/bin/zig -# Debian 12 doesn't have gtk4-layer-shell, so we have to manually compile it ourselves +COPY . /src + RUN zig build \ -Doptimize=Debug \ - -Dcpu=baseline \ - -Dapp-runtime=gtk \ - -fno-sys=gtk4-layer-shell + -Dcpu=baseline RUN ./zig-out/bin/ghostty +version diff --git a/src/config/Config.zig b/src/config/Config.zig index 2cf5a3e17..2f6643c7d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2433,7 +2433,12 @@ keybind: Keybinds = .{}, /// 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. +/// * `border` +/// +/// Display a border around the alerted surface until the terminal is +/// re-focused or interacted with (such as on keyboard input). +/// +/// GTK only. /// /// Example: `audio`, `no-audio`, `system`, `no-system` /// @@ -6988,6 +6993,7 @@ pub const BellFeatures = packed struct { audio: bool = false, attention: bool = true, title: bool = true, + border: bool = false, }; /// See mouse-shift-capture diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 6d224757b..57da22109 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -56,6 +56,11 @@ pub fn SplitTree(comptime V: type) type { /// All the nodes in the tree. Node at index 0 is always the root. nodes: []const Node, + /// The handle of the zoomed node. A "zoomed" node is one that is + /// expected to be made the full size of the split tree. Various + /// operations may unzoom (e.g. resize). + zoomed: ?Node.Handle, + /// An empty tree. pub const empty: Self = .{ // Arena can be undefined because we have zero allocated nodes. @@ -63,6 +68,7 @@ pub fn SplitTree(comptime V: type) type { // arena. .arena = undefined, .nodes = &.{}, + .zoomed = null, }; pub const Node = union(enum) { @@ -72,7 +78,24 @@ pub fn SplitTree(comptime V: type) type { /// A handle into the nodes array. This lets us keep track of /// nodes with 16-bit handles rather than full pointer-width /// values. - pub const Handle = u16; + pub const Handle = enum(Backing) { + root = 0, + _, + + pub const Backing = u16; + + pub inline fn idx(self: Handle) usize { + return @intFromEnum(self); + } + + /// Offset the handle by a given amount. + pub fn offset(self: Handle, v: usize) Handle { + const self_usize: usize = @intCast(@intFromEnum(self)); + const final = self_usize + v; + assert(final < std.math.maxInt(Backing)); + return @enumFromInt(final); + } + }; }; pub const Split = struct { @@ -98,6 +121,7 @@ pub fn SplitTree(comptime V: type) type { return .{ .arena = arena, .nodes = nodes, + .zoomed = null, }; } @@ -136,6 +160,7 @@ pub fn SplitTree(comptime V: type) type { return .{ .arena = arena, .nodes = nodes, + .zoomed = self.zoomed, }; } @@ -158,17 +183,17 @@ pub fn SplitTree(comptime V: type) type { }; pub const Iterator = struct { - i: Node.Handle = 0, + i: Node.Handle = .root, nodes: []const Node, pub fn next(self: *Iterator) ?ViewEntry { // If we have no nodes, return null. - if (self.i >= self.nodes.len) return null; + if (@intFromEnum(self.i) >= self.nodes.len) return null; // Get the current node and increment the index. const handle = self.i; - self.i += 1; - const node = self.nodes[handle]; + self.i = @enumFromInt(handle.idx() + 1); + const node = self.nodes[handle.idx()]; return switch (node) { .leaf => |v| .{ .handle = handle, .view = v }, @@ -177,6 +202,16 @@ pub fn SplitTree(comptime V: type) type { } }; + /// Change the zoomed state to the given node. Assumes the handle + /// is valid. + pub fn zoom(self: *Self, handle: ?Node.Handle) void { + if (handle) |v| { + assert(@intFromEnum(v) >= 0); + assert(@intFromEnum(v) < self.nodes.len); + } + self.zoomed = handle; + } + pub const Goto = union(enum) { /// Previous view, null if we're the first view. previous, @@ -211,8 +246,8 @@ pub fn SplitTree(comptime V: type) type { return switch (to) { .previous => self.previous(from), .next => self.next(from), - .previous_wrapped => self.previous(from) orelse self.deepest(.right, 0), - .next_wrapped => self.next(from) orelse self.deepest(.left, 0), + .previous_wrapped => self.previous(from) orelse self.deepest(.right, .root), + .next_wrapped => self.next(from) orelse self.deepest(.left, .root), .spatial => |d| spatial: { // Get our spatial representation. var sp = try self.spatial(alloc); @@ -234,7 +269,7 @@ pub fn SplitTree(comptime V: type) type { ) Node.Handle { var current: Node.Handle = from; while (true) { - switch (self.nodes[current]) { + switch (self.nodes[current.idx()]) { .leaf => return current, .split => |s| current = switch (side) { .left => s.left, @@ -253,7 +288,7 @@ pub fn SplitTree(comptime V: type) type { /// may want to change this to something that better matches a /// spatial view of the tree later. fn previous(self: *const Self, from: Node.Handle) ?Node.Handle { - return switch (self.previousBacktrack(from, 0)) { + return switch (self.previousBacktrack(from, .root)) { .result => |v| v, .backtrack, .deadend => null, }; @@ -261,7 +296,7 @@ pub fn SplitTree(comptime V: type) type { /// Same as `previous`, but returns the next view instead. fn next(self: *const Self, from: Node.Handle) ?Node.Handle { - return switch (self.nextBacktrack(from, 0)) { + return switch (self.nextBacktrack(from, .root)) { .result => |v| v, .backtrack, .deadend => null, }; @@ -286,7 +321,7 @@ pub fn SplitTree(comptime V: type) type { // value of, then we need to backtrack from here. if (from == current) return .backtrack; - return switch (self.nodes[current]) { + return switch (self.nodes[current.idx()]) { // If we hit a leaf that isn't our target, then deadend. .leaf => .deadend, @@ -322,7 +357,7 @@ pub fn SplitTree(comptime V: type) type { current: Node.Handle, ) Backtrack { if (from == current) return .backtrack; - return switch (self.nodes[current]) { + return switch (self.nodes[current.idx()]) { .leaf => .deadend, .split => |s| switch (self.nextBacktrack(from, s.right)) { .result => |v| .{ .result = v }, @@ -343,7 +378,7 @@ pub fn SplitTree(comptime V: type) type { from: Node.Handle, direction: Spatial.Direction, ) ?Node.Handle { - const target = sp.slots[from]; + const target = sp.slots[from.idx()]; var result: ?struct { handle: Node.Handle, @@ -351,7 +386,7 @@ pub fn SplitTree(comptime V: type) type { } = null; for (sp.slots, 0..) |slot, handle| { // Never match ourself - if (handle == from) continue; + if (handle == from.idx()) continue; // Only match leaves switch (self.nodes[handle]) { @@ -377,7 +412,7 @@ pub fn SplitTree(comptime V: type) type { if (distance >= n.distance) continue; } result = .{ - .handle = @intCast(handle), + .handle = @enumFromInt(handle), .distance = distance, }; } @@ -402,7 +437,7 @@ pub fn SplitTree(comptime V: type) type { // who directly access the nodes to be able to modify them // (without nasty stuff like this), but given this is internal // usage its perfectly fine to modify the node in-place. - const s: *Split = @constCast(&self.nodes[at].split); + const s: *Split = @constCast(&self.nodes[at.idx()].split); s.ratio = ratio; } @@ -430,7 +465,7 @@ pub fn SplitTree(comptime V: type) type { // We know we're going to need the sum total of the nodes // between the two trees plus one for the new split node. const nodes = try alloc.alloc(Node, self.nodes.len + insert.nodes.len + 1); - if (nodes.len > std.math.maxInt(Node.Handle)) return error.OutOfMemory; + if (nodes.len > std.math.maxInt(Node.Handle.Backing)) return error.OutOfMemory; // We can copy our nodes exactly as they are, since they're // mostly not changing (only `at` is changing). @@ -446,8 +481,8 @@ pub fn SplitTree(comptime V: type) type { .leaf => {}, .split => |*s| { // We need to offset the handles in the split - s.left += @intCast(self.nodes.len); - s.right += @intCast(self.nodes.len); + s.left = s.left.offset(self.nodes.len); + s.right = s.right.offset(self.nodes.len); }, }; @@ -461,18 +496,23 @@ pub fn SplitTree(comptime V: type) type { // Copy our previous value to the end of the nodes list and // create our new split node. - nodes[nodes.len - 1] = nodes[at]; - nodes[at] = .{ .split = .{ + nodes[nodes.len - 1] = nodes[at.idx()]; + nodes[at.idx()] = .{ .split = .{ .layout = layout, .ratio = ratio, - .left = @intCast(if (left) self.nodes.len else nodes.len - 1), - .right = @intCast(if (left) nodes.len - 1 else self.nodes.len), + .left = @enumFromInt(if (left) self.nodes.len else nodes.len - 1), + .right = @enumFromInt(if (left) nodes.len - 1 else self.nodes.len), } }; // We need to increase the reference count of all the nodes. try refNodes(gpa, nodes); - return .{ .arena = arena, .nodes = nodes }; + return .{ + .arena = arena, + .nodes = nodes, + // Splitting always resets zoom state. + .zoomed = null, + }; } /// Remove a node from the tree. @@ -481,10 +521,10 @@ pub fn SplitTree(comptime V: type) type { gpa: Allocator, at: Node.Handle, ) Allocator.Error!Self { - assert(at < self.nodes.len); + assert(at.idx() < self.nodes.len); // If we're removing node zero then we're clearing the tree. - if (at == 0) return .empty; + if (at == .root) return .empty; // The new arena for our new tree. var arena = ArenaAllocator.init(gpa); @@ -494,43 +534,61 @@ pub fn SplitTree(comptime V: type) type { // Allocate our new nodes list with the number of nodes we'll // need after the removal. const nodes = try alloc.alloc(Node, self.countAfterRemoval( - 0, + .root, at, 0, )); + var result: Self = .{ + .arena = arena, + .nodes = nodes, + .zoomed = null, + }; + // Traverse the tree and copy all our nodes into place. assert(self.removeNode( - nodes, - 0, + &result, 0, + .root, at, - ) > 0); + ) != 0); // Increase the reference count of all the nodes. try refNodes(gpa, nodes); - return .{ - .arena = arena, - .nodes = nodes, - }; + return result; } fn removeNode( - self: *Self, - nodes: []Node, - new_offset: Node.Handle, + old: *Self, + new: *Self, + new_offset: usize, current: Node.Handle, target: Node.Handle, - ) Node.Handle { + ) usize { assert(current != target); - switch (self.nodes[current]) { + // If we have a zoomed node and this is it then we migrate it. + if (old.zoomed) |v| { + if (v == current) { + assert(new.zoomed == null); + new.zoomed = @enumFromInt(new_offset); + } + } + + // Let's talk about this constCast. Our member are const but + // we actually always own their memory. We don't want consumers + // who directly access the nodes to be able to modify them + // (without nasty stuff like this), but given this is internal + // usage its perfectly fine to modify the node in-place. + const new_nodes: []Node = @constCast(new.nodes); + + switch (old.nodes[current.idx()]) { // Leaf is simple, just copy it over. We don't ref anything // yet because it'd make undo (errdefer) harder. We do that // all at once later. .leaf => |view| { - nodes[new_offset] = .{ .leaf = view }; + new_nodes[new_offset] = .{ .leaf = view }; return 1; }, @@ -538,39 +596,39 @@ pub fn SplitTree(comptime V: type) type { // If we're removing one of the split node sides then // we remove the split node itself as well and only add // the other (non-removed) side. - if (s.left == target) return self.removeNode( - nodes, + if (s.left == target) return old.removeNode( + new, new_offset, s.right, target, ); - if (s.right == target) return self.removeNode( - nodes, + if (s.right == target) return old.removeNode( + new, new_offset, s.left, target, ); // Neither side is being directly removed, so we traverse. - const left = self.removeNode( - nodes, + const left = old.removeNode( + new, new_offset + 1, s.left, target, ); - assert(left > 0); - const right = self.removeNode( - nodes, - new_offset + 1 + left, + assert(left != 0); + const right = old.removeNode( + new, + new_offset + left + 1, s.right, target, ); - assert(right > 0); - nodes[new_offset] = .{ .split = .{ + assert(right != 0); + new_nodes[new_offset] = .{ .split = .{ .layout = s.layout, .ratio = s.ratio, - .left = new_offset + 1, - .right = new_offset + 1 + left, + .left = @enumFromInt(new_offset + 1), + .right = @enumFromInt(new_offset + 1 + left), } }; return left + right + 1; @@ -588,7 +646,7 @@ pub fn SplitTree(comptime V: type) type { ) usize { assert(current != target); - return switch (self.nodes[current]) { + return switch (self.nodes[current.idx()]) { // Leaf is simple, always takes one node. .leaf => acc + 1, @@ -679,6 +737,7 @@ pub fn SplitTree(comptime V: type) type { return .{ .arena = arena, .nodes = nodes, + .zoomed = self.zoomed, }; } @@ -688,7 +747,7 @@ pub fn SplitTree(comptime V: type) type { layout: Split.Layout, acc: usize, ) usize { - return switch (self.nodes[from]) { + return switch (self.nodes[from.idx()]) { .leaf => acc + 1, .split => |s| if (s.layout == layout) self.weight(s.left, layout, acc) + @@ -698,6 +757,114 @@ pub fn SplitTree(comptime V: type) type { }; } + /// Resize the nearest split matching the layout by the given ratio. + /// Positive is right and down. + /// + /// The ratio is a value between 0 and 1 representing the percentage + /// to move the divider in the given direction. The percentage is + /// of the entire grid size, not just the specific split size. + /// We use the entire grid size because that's what Ghostty's + /// `resize_split` keybind does, because it maps to a general human + /// understanding of moving a split relative to the entire window + /// (generally). + /// + /// For example, a ratio of 0.1 and a layout of `vertical` will find + /// the nearest vertical split and move the divider down by 10% of + /// the total grid height. + /// + /// If no matching split is found, this does nothing, but will always + /// still return a cloned tree. + pub fn resize( + self: *const Self, + gpa: Allocator, + from: Node.Handle, + layout: Split.Layout, + ratio: f16, + ) Allocator.Error!Self { + assert(ratio >= 0 and ratio <= 1); + assert(!std.math.isNan(ratio)); + assert(!std.math.isInf(ratio)); + + // Fast path empty trees. + if (self.isEmpty()) return .empty; + + // From this point forward worst case we return a clone. + var result = try self.clone(gpa); + errdefer result.deinit(); + + // Find our nearest parent split node matching the layout. + const parent_handle = switch (self.findParentSplit( + layout, + from, + .root, + )) { + .deadend, .backtrack => return result, + .result => |v| v, + }; + + // Get our spatial layout, because we need the dimensions of this + // split with regards to the entire grid. + var sp = try result.spatial(gpa); + defer sp.deinit(gpa); + + // Get the ratio of the split relative to the full grid. + const full_ratio = full_ratio: { + // Our scale is the amount we need to multiply our individual + // ratio by to get the full ratio. Its actually a ratio on its + // own but I'm trying to avoid that word: its the ratio of + // our spatial width/height to the total. + const scale = switch (layout) { + .horizontal => sp.slots[parent_handle.idx()].width / sp.slots[0].width, + .vertical => sp.slots[parent_handle.idx()].height / sp.slots[0].height, + }; + + const current = result.nodes[parent_handle.idx()].split.ratio; + break :full_ratio current * scale; + }; + + // Set the final new ratio, clamping it to [0, 1] + result.resizeInPlace( + parent_handle, + @min(@max(full_ratio + ratio, 0), 1), + ); + return result; + } + + fn findParentSplit( + self: *const Self, + layout: Split.Layout, + from: Node.Handle, + current: Node.Handle, + ) Backtrack { + if (from == current) return .backtrack; + return switch (self.nodes[current.idx()]) { + .leaf => .deadend, + .split => |s| switch (self.findParentSplit( + layout, + from, + s.left, + )) { + .result => |v| .{ .result = v }, + .backtrack => if (s.layout == layout) + .{ .result = current } + else + .backtrack, + .deadend => switch (self.findParentSplit( + layout, + from, + s.right, + )) { + .deadend => .deadend, + .result => |v| .{ .result = v }, + .backtrack => if (s.layout == layout) + .{ .result = current } + else + .backtrack, + }, + }, + }; + } + /// Spatial representation of the split tree. See spatial. pub const Spatial = struct { /// The slots of the spatial representation in the same order @@ -732,11 +899,11 @@ pub fn SplitTree(comptime V: type) type { /// Spatial representation of the split tree. This can be used to /// better understand the layout of the tree in a 2D space. /// - /// The bounds of the representation are always based on each split - /// being exactly 1 unit wide and high. The x and y coordinates - /// are offsets into that space. This means that the spatial - /// representation is a normalized representation of the actual - /// space. + /// The bounds of the representation are always based on the total + /// 2D space being 1x1. The x/y coordinates and width/height dimensions + /// of each individual split and leaf are relative to this. + /// This means that the spatial representation is a normalized + /// representation of the actual space. /// /// The top-left corner of the tree is always (0, 0). /// @@ -753,7 +920,7 @@ pub fn SplitTree(comptime V: type) type { if (self.nodes.len == 0) return .empty; // Get our total dimensions. - const dim = self.dimensions(0); + const dim = self.dimensions(.root); // Create our slots which will match our nodes exactly. const slots = try alloc.alloc(Spatial.Slot, self.nodes.len); @@ -764,7 +931,15 @@ pub fn SplitTree(comptime V: type) type { .width = @floatFromInt(dim.width), .height = @floatFromInt(dim.height), }; - self.fillSpatialSlots(slots, 0); + self.fillSpatialSlots(slots, .root); + + // Normalize the dimensions to 1x1 grid. + for (slots) |*slot| { + slot.x /= @floatFromInt(dim.width); + slot.y /= @floatFromInt(dim.height); + slot.width /= @floatFromInt(dim.width); + slot.height /= @floatFromInt(dim.height); + } return .{ .slots = slots }; } @@ -772,10 +947,10 @@ pub fn SplitTree(comptime V: type) type { fn fillSpatialSlots( self: *const Self, slots: []Spatial.Slot, - current: Node.Handle, + current_: Node.Handle, ) void { - assert(slots[current].width > 0 and slots[current].height > 0); - + const current = current_.idx(); + assert(slots[current].width >= 0 and slots[current].height >= 0); switch (self.nodes[current]) { // Leaf node, current slot is already filled by caller. .leaf => {}, @@ -783,13 +958,13 @@ pub fn SplitTree(comptime V: type) type { .split => |s| { switch (s.layout) { .horizontal => { - slots[s.left] = .{ + slots[s.left.idx()] = .{ .x = slots[current].x, .y = slots[current].y, .width = slots[current].width * s.ratio, .height = slots[current].height, }; - slots[s.right] = .{ + slots[s.right.idx()] = .{ .x = slots[current].x + slots[current].width * s.ratio, .y = slots[current].y, .width = slots[current].width * (1 - s.ratio), @@ -798,13 +973,13 @@ pub fn SplitTree(comptime V: type) type { }, .vertical => { - slots[s.left] = .{ + slots[s.left.idx()] = .{ .x = slots[current].x, .y = slots[current].y, .width = slots[current].width, .height = slots[current].height * s.ratio, }; - slots[s.right] = .{ + slots[s.right.idx()] = .{ .x = slots[current].x, .y = slots[current].y + slots[current].height * s.ratio, .width = slots[current].width, @@ -827,7 +1002,7 @@ pub fn SplitTree(comptime V: type) type { width: u16, height: u16, } { - return switch (self.nodes[current]) { + return switch (self.nodes[current.idx()]) { .leaf => .{ .width = 1, .height = 1 }, .split => |s| split: { const left = self.dimensions(s.left); @@ -872,10 +1047,10 @@ pub fn SplitTree(comptime V: type) type { self.formatDiagram(writer) catch try writer.writeAll("failed to draw split tree diagram"); } else if (std.mem.eql(u8, fmt, "text")) { - try self.formatText(writer, 0, 0); + try self.formatText(writer, .root, 0); } else if (fmt.len == 0) { self.formatDiagram(writer) catch {}; - try self.formatText(writer, 0, 0); + try self.formatText(writer, .root, 0); } else { return error.InvalidFormat; } @@ -889,7 +1064,11 @@ pub fn SplitTree(comptime V: type) type { ) !void { for (0..depth) |_| try writer.writeAll(" "); - switch (self.nodes[current]) { + if (self.zoomed) |zoomed| if (zoomed == current) { + try writer.writeAll("(zoomed) "); + }; + + switch (self.nodes[current.idx()]) { .leaf => |v| if (@hasDecl(View, "splitTreeLabel")) try writer.print("leaf: {s}\n", .{v.splitTreeLabel()}) else @@ -926,8 +1105,8 @@ pub fn SplitTree(comptime V: type) type { var min_w: f16 = 1; var min_h: f16 = 1; for (sp.slots) |slot| { - min_w = @min(min_w, slot.width); - min_h = @min(min_h, slot.height); + if (slot.width > 0) min_w = @min(min_w, slot.width); + if (slot.height > 0) min_h = @min(min_h, slot.height); } const ratio_w: f16 = 1 / min_w; @@ -1007,6 +1186,9 @@ pub fn SplitTree(comptime V: type) type { .split => continue, } + // If our width/height is zero then we skip this. + if (slot.width == 0 or slot.height == 0) continue; + var x: usize = @intFromFloat(@floor(slot.x)); var y: usize = @intFromFloat(@floor(slot.y)); var width: usize = @intFromFloat(@max(@floor(slot.width), 1)); @@ -1193,7 +1375,7 @@ test "SplitTree: split horizontal" { defer t2.deinit(); var t3 = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 0.5, &t2, // insert t2 @@ -1297,7 +1479,7 @@ test "SplitTree: split horizontal" { } else return error.NotFound, ).?; - const entry = t5.nodes[handle].leaf; + const entry = t5.nodes[handle.idx()].leaf; try testing.expectEqualStrings( entry.label, &.{current - 1}, @@ -1327,7 +1509,7 @@ test "SplitTree: split horizontal" { } else return error.NotFound, ).?; - const entry = t5.nodes[handle].leaf; + const entry = t5.nodes[handle.idx()].leaf; try testing.expectEqualStrings( entry.label, &.{current + 1}, @@ -1358,7 +1540,7 @@ test "SplitTree: split vertical" { var t3 = try t1.split( alloc, - 0, // at root + .root, // at root .down, // split down 0.5, &t2, // insert t2 @@ -1378,6 +1560,142 @@ test "SplitTree: split vertical" { ); } +test "SplitTree: split horizontal with zero ratio" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + + // A | B horizontal + var splitAB = try t1.split( + alloc, + .root, // at root + .right, // split right + 0, + &t2, // insert t2 + ); + defer splitAB.deinit(); + const split = splitAB; + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| B | + \\+---+ + \\ + ); + } +} + +test "SplitTree: split vertical with zero ratio" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + + // A | B horizontal + var splitAB = try t1.split( + alloc, + .root, // at root + .down, // split right + 0, + &t2, // insert t2 + ); + defer splitAB.deinit(); + const split = splitAB; + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| B | + \\+---+ + \\ + ); + } +} + +test "SplitTree: split horizontal with full width" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + + // A | B horizontal + var splitAB = try t1.split( + alloc, + .root, // at root + .right, // split right + 1, + &t2, // insert t2 + ); + defer splitAB.deinit(); + const split = splitAB; + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| A | + \\+---+ + \\ + ); + } +} + +test "SplitTree: split vertical with full width" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + + // A | B horizontal + var splitAB = try t1.split( + alloc, + .root, // at root + .down, // split right + 1, + &t2, // insert t2 + ); + defer splitAB.deinit(); + const split = splitAB; + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| A | + \\+---+ + \\ + ); + } +} + test "SplitTree: remove leaf" { const testing = std.testing; const alloc = testing.allocator; @@ -1390,7 +1708,7 @@ test "SplitTree: remove leaf" { defer t2.deinit(); var t3 = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 0.5, &t2, // insert t2 @@ -1436,7 +1754,7 @@ test "SplitTree: split twice, remove intermediary" { // A | B horizontal. var split1 = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 0.5, &t2, // insert t2 @@ -1446,7 +1764,7 @@ test "SplitTree: split twice, remove intermediary" { // Insert C below that. var split2 = try split1.split( alloc, - 0, // at root + .root, // at root .down, // split down 0.5, &t3, // insert t3 @@ -1497,7 +1815,7 @@ test "SplitTree: split twice, remove intermediary" { // never crash. We don't test the result is correct, this just verifies // we don't hit any assertion failures. for (0..split2.nodes.len) |i| { - var t = try split2.remove(alloc, @intCast(i)); + var t = try split2.remove(alloc, @enumFromInt(i)); t.deinit(); } } @@ -1522,7 +1840,7 @@ test "SplitTree: spatial goto" { // A | B horizontal var splitAB = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 0.5, &t2, // insert t2 @@ -1598,7 +1916,7 @@ test "SplitTree: spatial goto" { }, .{ .spatial = .right }, )).?; - const view = split.nodes[target].leaf; + const view = split.nodes[target.idx()].leaf; try testing.expectEqualStrings(view.label, "D"); } @@ -1616,7 +1934,7 @@ test "SplitTree: spatial goto" { }, .{ .spatial = .left }, )).?; - const view = split.nodes[target].leaf; + const view = split.nodes[target.idx()].leaf; try testing.expectEqualStrings("A", view.label); } @@ -1639,6 +1957,65 @@ test "SplitTree: spatial goto" { } } +test "SplitTree: resize" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + + // A | B horizontal + var split = try t1.split( + alloc, + .root, // at root + .right, // split right + 0.5, + &t2, // insert t2 + ); + defer split.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++---+ + \\| A || B | + \\+---++---+ + \\ + ); + } + + // Resize + { + var resized = try split.resize( + alloc, + at: { + var it = split.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound; + }, + .horizontal, // resize right + 0.25, + ); + defer resized.deinit(); + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{resized}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+-------------++---+ + \\| A || B | + \\+-------------++---+ + \\ + ); + } +} + test "SplitTree: clone empty tree" { const testing = std.testing; const alloc = testing.allocator; @@ -1656,3 +2033,179 @@ test "SplitTree: clone empty tree" { ); } } + +test "SplitTree: zoom" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + + // A | B horizontal + var split = try t1.split( + alloc, + .root, // at root + .right, // split right + 0.5, + &t2, // insert t2 + ); + defer split.deinit(); + split.zoom(at: { + var it = split.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound; + }); + + { + const str = try std.fmt.allocPrint(alloc, "{text}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\split (layout: horizontal, ratio: 0.50) + \\ leaf: A + \\ (zoomed) leaf: B + \\ + ); + } + + // Clone preserves zoom + var clone = try split.clone(alloc); + defer clone.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{text}", .{clone}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\split (layout: horizontal, ratio: 0.50) + \\ leaf: A + \\ (zoomed) leaf: B + \\ + ); + } +} + +test "SplitTree: split resets zoom" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + + // Zoom A + t1.zoom(at: { + var it = t1.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "A")) { + break entry.handle; + } + } else return error.NotFound; + }); + + // A | B horizontal + var split = try t1.split( + alloc, + .root, // at root + .right, // split right + 0.5, + &t2, // insert t2 + ); + defer split.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{text}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\split (layout: horizontal, ratio: 0.50) + \\ leaf: A + \\ leaf: B + \\ + ); + } +} + +test "SplitTree: remove and zoom" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + + // A | B horizontal + var split = try t1.split( + alloc, + .root, // at root + .right, // split right + 0.5, + &t2, // insert t2 + ); + defer split.deinit(); + split.zoom(at: { + var it = split.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "A")) { + break entry.handle; + } + } else return error.NotFound; + }); + + // Remove A, should unzoom + { + var removed = try split.remove( + alloc, + at: { + var it = split.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "A")) { + break entry.handle; + } + } else return error.NotFound; + }, + ); + defer removed.deinit(); + try testing.expect(removed.zoomed == null); + + const str = try std.fmt.allocPrint(alloc, "{text}", .{removed}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\leaf: B + \\ + ); + } + + // Remove B, should keep zoom + { + var removed = try split.remove( + alloc, + at: { + var it = split.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound; + }, + ); + defer removed.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{text}", .{removed}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\(zoomed) leaf: A + \\ + ); + } +} diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index 7b31e2794..68ccaddcc 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -86,6 +86,11 @@ pub const Region = extern struct { height: u32, }; +/// Number of nodes to preallocate in the list on init. +/// +/// TODO: figure out optimal prealloc based on real world usage +const node_prealloc: usize = 64; + pub fn init(alloc: Allocator, size: u32, format: Format) Allocator.Error!Atlas { var result = Atlas{ .data = try alloc.alloc(u8, size * size * format.depth()), @@ -95,8 +100,8 @@ pub fn init(alloc: Allocator, size: u32, format: Format) Allocator.Error!Atlas { }; errdefer result.deinit(alloc); - // TODO: figure out optimal prealloc based on real world usage - try result.nodes.ensureUnusedCapacity(alloc, 64); + // Prealloc some nodes. + result.nodes = try .initCapacity(alloc, node_prealloc); // This sets up our initial state result.clear(); @@ -287,30 +292,30 @@ pub fn grow(self: *Atlas, alloc: Allocator, size_new: u32) Allocator.Error!void assert(size_new >= self.size); if (size_new == self.size) return; - // Preserve our old values so we can copy the old data + // We reserve space ahead of time for the new node, so that we + // won't have to handle any errors after allocating our new data. + try self.nodes.ensureUnusedCapacity(alloc, 1); + + const data_new = try alloc.alloc( + u8, + size_new * size_new * self.format.depth(), + ); + + // Function is infallible from this point. + errdefer comptime unreachable; + + // Keep track of our old data so that we can copy it. const data_old = self.data; const size_old = self.size; - // Allocate our new data - self.data = try alloc.alloc(u8, size_new * size_new * self.format.depth()); - defer alloc.free(data_old); - errdefer { - alloc.free(self.data); - self.data = data_old; - } - - // Add our new rectangle for our added righthand space. We do this - // right away since its the only operation that can fail and we want - // to make error cleanup easier. - try self.nodes.append(alloc, .{ - .x = size_old - 1, - .y = 1, - .width = size_new - size_old, - }); - - // If our allocation and rectangle add succeeded, we can go ahead - // and persist our new size and copy over the old data. + // Update our data and size to our new ones. + self.data = data_new; self.size = size_new; + + // Free the old data once we're done with it. + defer alloc.free(data_old); + + // Zero the new data out and copy the old data over. @memset(self.data, 0); self.set(.{ .x = 0, // don't bother skipping border so we can avoid strides @@ -319,6 +324,13 @@ pub fn grow(self: *Atlas, alloc: Allocator, size_new: u32) Allocator.Error!void .height = size_old - 2, // skip the last border row }, data_old[size_old * self.format.depth() ..]); + // Add the new rectangle for our added righthand space. + self.nodes.appendAssumeCapacity(.{ + .x = size_old - 1, + .y = 1, + .width = size_new - size_old, + }); + // We are both modified and resized _ = self.modified.fetchAdd(1, .monotonic); _ = self.resized.fetchAdd(1, .monotonic); @@ -737,3 +749,49 @@ test "grow BGR" { _ = try atlas.reserve(alloc, 2, 1); try testing.expectError(Error.AtlasFull, atlas.reserve(alloc, 1, 1)); } + +test "grow OOM" { + // We use a fixed buffer allocator so that we can consistently hit OOM. + // + // We calculate the size to exactly fit the 4x4 pixels and node list. + var buf: [ + 4 * 4 * 1 // 4x4 pixels, each 1 byte. + + node_prealloc * @sizeOf(Node) // preallocated nodes. + ]u8 = undefined; + var fba: std.heap.FixedBufferAllocator = .init(&buf); + const alloc = fba.allocator(); + + var atlas = try init(alloc, 4, .grayscale); // +2 for 1px border + defer atlas.deinit(alloc); + + const reg = try atlas.reserve(alloc, 2, 2); + try testing.expectError( + Error.AtlasFull, + atlas.reserve(alloc, 1, 1), + ); + + // Write some data so we can verify that attempted growing doesn't mess it up. + atlas.set(reg, &[_]u8{ 1, 2, 3, 4 }); + try testing.expectEqual(@as(u8, 1), atlas.data[5]); + try testing.expectEqual(@as(u8, 2), atlas.data[6]); + try testing.expectEqual(@as(u8, 3), atlas.data[9]); + try testing.expectEqual(@as(u8, 4), atlas.data[10]); + + // Expand by 1, should give OOM, modified and resized should be unchanged. + const old_modified = atlas.modified.load(.monotonic); + const old_resized = atlas.resized.load(.monotonic); + try testing.expectError( + Allocator.Error.OutOfMemory, + atlas.grow(alloc, atlas.size + 1), + ); + const new_modified = atlas.modified.load(.monotonic); + const new_resized = atlas.resized.load(.monotonic); + try testing.expectEqual(old_modified, new_modified); + try testing.expectEqual(old_resized, new_resized); + + // Ensure our data is still set. + try testing.expectEqual(@as(u8, 1), atlas.data[5]); + try testing.expectEqual(@as(u8, 2), atlas.data[6]); + try testing.expectEqual(@as(u8, 3), atlas.data[9]); + try testing.expectEqual(@as(u8, 4), atlas.data[10]); +} diff --git a/src/font/sprite/testdata/U+1FB00...U+1FBFF-12x24+3.png b/src/font/sprite/testdata/U+1FB00...U+1FBFF-12x24+3.png index 2eff59c76..1a4dcb01f 100644 Binary files a/src/font/sprite/testdata/U+1FB00...U+1FBFF-12x24+3.png and b/src/font/sprite/testdata/U+1FB00...U+1FBFF-12x24+3.png differ diff --git a/src/font/sprite/testdata/U+1FB00...U+1FBFF-18x36+4.png b/src/font/sprite/testdata/U+1FB00...U+1FBFF-18x36+4.png index b77f7dfae..73ead485d 100644 Binary files a/src/font/sprite/testdata/U+1FB00...U+1FBFF-18x36+4.png and b/src/font/sprite/testdata/U+1FB00...U+1FBFF-18x36+4.png differ diff --git a/src/font/sprite/testdata/U+1FB00...U+1FBFF-9x17+1.png b/src/font/sprite/testdata/U+1FB00...U+1FBFF-9x17+1.png index 062a7da81..b58ded46c 100644 Binary files a/src/font/sprite/testdata/U+1FB00...U+1FBFF-9x17+1.png and b/src/font/sprite/testdata/U+1FB00...U+1FBFF-9x17+1.png differ diff --git a/src/font/sprite/testdata/U+E000...U+E0FF-12x24+3.png b/src/font/sprite/testdata/U+E000...U+E0FF-12x24+3.png index ddfa79e85..70ae81021 100644 Binary files a/src/font/sprite/testdata/U+E000...U+E0FF-12x24+3.png and b/src/font/sprite/testdata/U+E000...U+E0FF-12x24+3.png differ diff --git a/src/font/sprite/testdata/U+E000...U+E0FF-18x36+4.png b/src/font/sprite/testdata/U+E000...U+E0FF-18x36+4.png index 787cbcb6c..32a42097d 100644 Binary files a/src/font/sprite/testdata/U+E000...U+E0FF-18x36+4.png and b/src/font/sprite/testdata/U+E000...U+E0FF-18x36+4.png differ diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 6c7870f06..5b1cefca0 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -524,6 +524,14 @@ pub const Action = union(enum) { /// Has no effect on macOS. show_gtk_inspector, + /// Show the on-screen keyboard if one is present. + /// + /// Only implemented on Linux (GTK). On GNOME, the "Screen Keyboard" + /// accessibility feature must be turned on, which can be found under + /// Settings > Accessibility > Typing. Other platforms are as of now + /// untested. + show_on_screen_keyboard, + /// Open the configuration file in the default OS editor. /// /// If your default OS editor isn't configured then this will fail. @@ -1051,6 +1059,7 @@ pub const Action = union(enum) { .toggle_window_float_on_top, .toggle_secure_input, .toggle_command_palette, + .show_on_screen_keyboard, .reset_window_size, .crash, => .surface, diff --git a/src/input/command.zig b/src/input/command.zig index 84e9afc79..615ffb713 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -369,6 +369,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Show the GTK inspector.", }}, + .show_on_screen_keyboard => comptime &.{.{ + .action = .show_on_screen_keyboard, + .title = "Show On-Screen Keyboard", + .description = "Show the on-screen keyboard if present.", + }}, + .open_config => comptime &.{.{ .action = .open_config, .title = "Open Config", diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 8c16d8cea..661b2a326 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -141,7 +141,12 @@ pub const Contents = struct { } /// Set the cursor value. If the value is null then the cursor is hidden. - pub fn setCursor(self: *Contents, v: ?shaderpkg.CellText, cursor_style: ?renderer.CursorStyle) void { + pub fn setCursor( + self: *Contents, + v: ?shaderpkg.CellText, + cursor_style: ?renderer.CursorStyle, + ) void { + if (self.size.rows == 0) return; self.fg_rows.lists[0].clearRetainingCapacity(); self.fg_rows.lists[self.size.rows + 1].clearRetainingCapacity(); @@ -158,6 +163,7 @@ pub const Contents = struct { /// Returns the current cursor glyph if present, checking both cursor lists. pub fn getCursorGlyph(self: *Contents) ?shaderpkg.CellText { + if (self.size.rows == 0) return null; if (self.fg_rows.lists[0].items.len > 0) { return self.fg_rows.lists[0].items[0]; } @@ -469,3 +475,14 @@ test "Contents clear last added content" { // Fg row index is +1 because of cursor list at start try testing.expectEqual(fg_cell_1, c.fg_rows.lists[2].items[0]); } + +test "Contents with zero-sized screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Contents = .{}; + defer c.deinit(alloc); + + c.setCursor(null, null); + try testing.expect(c.getCursorGlyph() == null); +} diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 1517ec662..d975f0f96 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -395,6 +395,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } pub fn deinit(self: *FrameState) void { + self.target.deinit(); self.uniforms.deinit(); self.cells.deinit(); self.cells_bg.deinit(); diff --git a/valgrind.supp b/valgrind.supp index cf82b7c2a..162f3393a 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -529,6 +529,17 @@ ... } +{ + pango fontset + Memcheck:Leak + match-leak-kinds: possible + fun:*alloc + ... + fun:FcFontRenderPrepare + fun:pango_fc_fontset_get_font_at + ... +} + { pango and fontconfig Memcheck:Leak