diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 56e50889b..825cf52f5 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -42,7 +42,7 @@ jobs: /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 9edc8b48d..82970a065 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -89,7 +89,7 @@ jobs: /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable @@ -132,7 +132,7 @@ jobs: GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: DeterminateSystems/nix-installer-action@main with: @@ -306,7 +306,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download macOS Artifacts uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index fffc0ca4c..df73198d1 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -29,11 +29,11 @@ jobs: commit: ${{ steps.extract_build_info.outputs.commit }} commit_long: ${{ steps.extract_build_info.outputs.commit_long }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -66,7 +66,7 @@ jobs: needs: [setup, build-macos] if: needs.setup.outputs.should_skip != 'true' steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Tip Tag run: | git config user.name "github-actions[bot]" @@ -81,7 +81,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install sentry-cli run: | @@ -104,7 +104,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install sentry-cli run: | @@ -127,7 +127,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install sentry-cli run: | @@ -159,14 +159,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -186,7 +186,7 @@ jobs: nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password - name: Update Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -217,7 +217,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -356,7 +356,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -451,7 +451,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -583,7 +583,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -635,7 +635,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -767,7 +767,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a067cea3..916745f58 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -79,7 +79,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -112,7 +112,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -122,7 +122,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -145,7 +145,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -155,7 +155,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -179,7 +179,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -189,7 +189,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -222,7 +222,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -232,7 +232,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -258,7 +258,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -268,7 +268,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -287,7 +287,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -297,7 +297,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -320,7 +320,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -330,7 +330,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -366,7 +366,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -376,7 +376,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -404,7 +404,7 @@ jobs: needs: [build-dist, build-snap] steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Trigger Snap workflow run: | @@ -421,7 +421,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -464,7 +464,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -509,7 +509,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # This could be from a script if we wanted to but inlining here for now # in one place. @@ -580,7 +580,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Get required Zig version id: zig @@ -595,7 +595,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -627,7 +627,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -637,7 +637,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -675,7 +675,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -685,7 +685,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -710,7 +710,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -720,7 +720,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -737,7 +737,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -774,7 +774,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -784,7 +784,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -804,14 +804,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -832,14 +832,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -859,14 +859,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -886,14 +886,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -913,14 +913,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -940,14 +940,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -974,14 +974,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1001,14 +1001,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1035,7 +1035,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1045,7 +1045,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1104,7 +1104,7 @@ jobs: runs-on: ${{ matrix.variant.runner }} needs: test steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 with: bundle: com.mitchellh.ghostty @@ -1123,7 +1123,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1133,7 +1133,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1162,7 +1162,7 @@ jobs: # timeout-minutes: 10 # steps: # - name: Checkout Ghostty - # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + # uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # # - name: Start SSH # run: | diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index a0dfdf298..b9ff89c35 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 @@ -29,7 +29,7 @@ jobs: /zig - name: Setup Nix - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 + uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -62,7 +62,7 @@ jobs: run: nix build .#ghostty - name: Create pull request - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 with: title: Update iTerm2 colorschemes base: main diff --git a/build.zig b/build.zig index 68dc0028b..5fd611b6c 100644 --- a/build.zig +++ b/build.zig @@ -55,7 +55,7 @@ pub fn build(b: *std.Build) !void { ); // Ghostty resources like terminfo, shell integration, themes, etc. - const resources = try buildpkg.GhosttyResources.init(b, &config); + const resources = try buildpkg.GhosttyResources.init(b, &config, &deps); const i18n = if (config.i18n) try buildpkg.GhosttyI18n.init(b, &config) else null; // Ghostty executable, the actual runnable Ghostty program. diff --git a/build.zig.zon b/build.zig.zon index dfccaf61d..993904aec 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -38,9 +38,9 @@ .lazy = true, }, .uucode = .{ - // TODO: currently the use-llvm branch because its broken on self-hosted - .url = "https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz", - .hash = "uucode-0.1.0-ZZjBPl_mQwDHQowHOzXwgTPhAqcFekfYpAeUfeHZNCl3", + // jacobsandlund/uucode + .url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", }, .zig_wayland = .{ // codeberg ifreund/zig-wayland @@ -116,7 +116,7 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", .hash = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index cd2621b2e..9ca70c410 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -51,8 +51,8 @@ }, "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz", - "hash": "sha256-VZq3L/cAAu7kLA5oqJYNjAZApoblfBtAzfdKVOuJPQI=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", + "hash": "sha256-5mmXW7d9SkesHyIwUBlWmyGtOWf6wu0S6zkHe93FVLM=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", @@ -114,10 +114,10 @@ "url": "git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732", "hash": "sha256-sHPh+TQSdUGus/QTbj7KSJJkTuNTrK4VNmQDjS30Lf8=" }, - "uucode-0.1.0-ZZjBPl_mQwDHQowHOzXwgTPhAqcFekfYpAeUfeHZNCl3": { + "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E": { "name": "uucode", - "url": "https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz", - "hash": "sha256-jvko1MdWr1OG4P58KjdB1JMnWy4EbrO3xIkV8fiQkC0=" + "url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "hash": "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4=" }, "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { "name": "vaxis", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index c38504847..2563f5411 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -166,8 +166,8 @@ in name = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz"; - hash = "sha256-VZq3L/cAAu7kLA5oqJYNjAZApoblfBtAzfdKVOuJPQI="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz"; + hash = "sha256-5mmXW7d9SkesHyIwUBlWmyGtOWf6wu0S6zkHe93FVLM="; }; } { @@ -267,11 +267,11 @@ in }; } { - name = "uucode-0.1.0-ZZjBPl_mQwDHQowHOzXwgTPhAqcFekfYpAeUfeHZNCl3"; + name = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E"; path = fetchZigArtifact { name = "uucode"; - url = "https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz"; - hash = "sha256-jvko1MdWr1OG4P58KjdB1JMnWy4EbrO3xIkV8fiQkC0="; + url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz"; + hash = "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 6bd86a206..4362c5d36 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -28,8 +28,8 @@ https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz -https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz -https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz +https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 8ed18e38b..672fd7a5f 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -61,9 +61,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz", + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", "dest": "vendor/p/N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", - "sha256": "559ab72ff70002eee42c0e68a8960d8c0640a686e57c1b40cdf74a54eb893d02" + "sha256": "e669975bb77d4a47ac1f22305019569b21ad3967fac2ed12eb39077bddc554b3" }, { "type": "archive", @@ -139,9 +139,9 @@ }, { "type": "archive", - "url": "https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz", - "dest": "vendor/p/uucode-0.1.0-ZZjBPl_mQwDHQowHOzXwgTPhAqcFekfYpAeUfeHZNCl3", - "sha256": "8ef928d4c756af5386e0fe7c2a3741d493275b2e046eb3b7c48915f1f890902d" + "url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "dest": "vendor/p/uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", + "sha256": "4b3a581a1806e01e8bb9ff1e4f7e6c28ba1b0b18e6c04ba8d53c24d237aef60e" }, { "type": "archive", diff --git a/include/ghostty.h b/include/ghostty.h index 9b7a918ec..6cafe8773 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -747,6 +747,21 @@ typedef struct { uint64_t duration; } ghostty_action_command_finished_s; +// apprt.action.StartSearch.C +typedef struct { + const char* needle; +} ghostty_action_start_search_s; + +// apprt.action.SearchTotal +typedef struct { + ssize_t total; +} ghostty_action_search_total_s; + +// apprt.action.SearchSelected +typedef struct { + ssize_t selected; +} ghostty_action_search_selected_s; + // terminal.Scrollbar typedef struct { uint64_t total; @@ -811,6 +826,10 @@ typedef enum { GHOSTTY_ACTION_PROGRESS_REPORT, GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, GHOSTTY_ACTION_COMMAND_FINISHED, + GHOSTTY_ACTION_START_SEARCH, + GHOSTTY_ACTION_END_SEARCH, + GHOSTTY_ACTION_SEARCH_TOTAL, + GHOSTTY_ACTION_SEARCH_SELECTED, } ghostty_action_tag_e; typedef union { @@ -844,6 +863,9 @@ typedef union { ghostty_surface_message_childexited_s child_exited; ghostty_action_progress_report_s progress_report; ghostty_action_command_finished_s command_finished; + ghostty_action_start_search_s start_search; + ghostty_action_search_total_s search_total; + ghostty_action_search_selected_s search_selected; } ghostty_action_u; typedef struct { diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index f83b438f7..192135c15 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -44,6 +44,11 @@ class AppDelegate: NSObject, @IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuPasteSelection: NSMenuItem? @IBOutlet private var menuSelectAll: NSMenuItem? + @IBOutlet private var menuFindParent: NSMenuItem? + @IBOutlet private var menuFind: NSMenuItem? + @IBOutlet private var menuFindNext: NSMenuItem? + @IBOutlet private var menuFindPrevious: NSMenuItem? + @IBOutlet private var menuHideFindBar: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem? @IBOutlet private var menuToggleFullScreen: NSMenuItem? @@ -553,6 +558,7 @@ class AppDelegate: NSObject, self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line") self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line") self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square") + self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass") } /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. @@ -581,6 +587,9 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) + syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) + syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) + syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) @@ -885,12 +894,17 @@ class AppDelegate: NSObject, NSApplication.shared.appearance = .init(ghosttyConfig: config) } - @concurrent + // Using AppIconActor to ensure this work + // happens synchronously in the background + @AppIconActor private func updateAppIcon(from config: Ghostty.Config) async { var appIcon: NSImage? + var appIconName: String? = config.macosIcon.rawValue switch (config.macosIcon) { case .official: + // Discard saved icon name + appIconName = nil break case .blueprint: appIcon = NSImage(named: "BlueprintImage")! @@ -919,10 +933,15 @@ class AppDelegate: NSObject, case .custom: if let userIcon = NSImage(contentsOfFile: config.macosCustomIcon) { appIcon = userIcon + appIconName = config.macosCustomIcon } else { appIcon = nil // Revert back to official icon if invalid location + appIconName = nil // Discard saved icon name } case .customStyle: + // Discard saved icon name + // if no valid colours were found + appIconName = nil guard let ghostColor = config.macosIconGhostColor else { break } guard let screenColors = config.macosIconScreenColor else { break } guard let icon = ColorizedGhosttyIcon( @@ -931,6 +950,24 @@ class AppDelegate: NSObject, frame: config.macosIconFrame ).makeImage() else { break } appIcon = icon + let colorStrings = ([ghostColor] + screenColors).compactMap(\.hexString) + appIconName = (colorStrings + [config.macosIconFrame.rawValue]) + .joined(separator: "_") + } + // Only change the icon if it has actually changed + // from the current one + guard UserDefaults.standard.string(forKey: "CustomGhosttyIcon") != appIconName else { +#if DEBUG + if appIcon == nil { + await MainActor.run { + // Changing the app bundle's icon will corrupt code signing. + // We only use the default blueprint icon for the dock, + // so developers don't need to clean and re-build every time. + NSApplication.shared.applicationIconImage = NSImage(named: "BlueprintImage") + } + } +#endif + return } // make it immutable, so Swift 6 won't complain let newIcon = appIcon @@ -941,16 +978,9 @@ class AppDelegate: NSObject, await MainActor.run { self.appIcon = newIcon -#if DEBUG - // if no custom icon specified, we use blueprint to distinguish from release app - NSApplication.shared.applicationIconImage = newIcon ?? NSImage(named: "BlueprintImage") - // Changing the app bundle's icon will corrupt code signing. - // We only use the default blueprint icon for the dock, - // so developers don't need to clean and re-build every time. -#else NSApplication.shared.applicationIconImage = newIcon -#endif } + UserDefaults.standard.set(appIconName, forKey: "CustomGhosttyIcon") } //MARK: - Restorable State @@ -1154,10 +1184,19 @@ class AppDelegate: NSObject, // want to bring back these windows if we remove the toggle. // // We also ignore fullscreen windows because they don't hide anyways. - self.hiddenWindows = NSApp.windows.filter { + var visibleWindows = [Weak]() + NSApp.windows.filter { $0.isVisible && !$0.styleMask.contains(.fullScreen) - }.map { Weak($0) } + }.forEach { window in + // We only keep track of selectedWindow if it's in a tabGroup, + // so we can keep its selection state when restoring + let windowToHide = window.tabGroup?.selectedWindow ?? window + if !visibleWindows.contains(where: { $0.value === windowToHide }) { + visibleWindows.append(Weak(windowToHide)) + } + } + self.hiddenWindows = visibleWindows } func restore() { @@ -1229,3 +1268,8 @@ extension AppDelegate: NSMenuItemValidation { } } } + +@globalActor +fileprivate actor AppIconActor: GlobalActor { + static let shared = AppIconActor() +} diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index c97ed7c61..3e1084cd7 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -26,7 +26,12 @@ + + + + + @@ -245,6 +250,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 537137fe6..79c3ca756 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -44,6 +44,7 @@ struct CommandPaletteView: View { @State private var query = "" @State private var selectedIndex: UInt? @State private var hoveredOptionID: UUID? + @FocusState private var isTextFieldFocused: Bool // The options that we should show, taking into account any filtering from // the query. @@ -72,7 +73,7 @@ struct CommandPaletteView: View { } VStack(alignment: .leading, spacing: 0) { - CommandPaletteQuery(query: $query) { event in + CommandPaletteQuery(query: $query, isTextFieldFocused: _isTextFieldFocused) { event in switch (event) { case .exit: isPresented = false @@ -144,6 +145,28 @@ struct CommandPaletteView: View { .shadow(radius: 32, x: 0, y: 12) .padding() .environment(\.colorScheme, scheme) + .onChange(of: isPresented) { newValue in + // Reset focus when quickly showing and hiding. + // macOS will destroy this view after a while, + // so task/onAppear will not be called again. + // If you toggle it rather quickly, we reset + // it here when dismissing. + isTextFieldFocused = newValue + if !isPresented { + // This is optional, since most of the time + // there will be a delay before the next use. + // To keep behavior the same as before, we reset it. + query = "" + } + } + .task { + // Grab focus on the first appearance. + // This happens right after onAppear, + // so we don’t need to dispatch it again. + // Fixes: https://github.com/ghostty-org/ghostty/issues/8497 + // Also fixes initial focus while animating. + isTextFieldFocused = isPresented + } } } @@ -153,6 +176,12 @@ fileprivate struct CommandPaletteQuery: View { var onEvent: ((KeyboardEvent) -> Void)? = nil @FocusState private var isTextFieldFocused: Bool + init(query: Binding, isTextFieldFocused: FocusState, onEvent: ((KeyboardEvent) -> Void)? = nil) { + _query = query + self.onEvent = onEvent + _isTextFieldFocused = isTextFieldFocused + } + enum KeyboardEvent { case exit case submit @@ -185,14 +214,6 @@ fileprivate struct CommandPaletteQuery: View { .frame(height: 48) .textFieldStyle(.plain) .focused($isTextFieldFocused) - .onAppear { - // We want to grab focus on appearance. We have to do this after a tick - // on macOS Tahoe otherwise this doesn't work. See: - // https://github.com/ghostty-org/ghostty/issues/8497 - DispatchQueue.main.async { - isTextFieldFocused = true - } - } .onChange(of: isTextFieldFocused) { focused in if !focused { onEvent?(.exit) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 673f5dd78..96ff3d0c1 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -90,19 +90,19 @@ struct TerminalCommandPaletteView: View { backgroundColor: ghosttyConfig.backgroundColor, options: commandOptions ) - .transition( - .move(edge: .top) - .combined(with: .opacity) - .animation(.spring(response: 0.4, dampingFraction: 0.8)) - ) // Spring animation .zIndex(1) // Ensure it's on top Spacer() } .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top) } + .transition( + .move(edge: .top) + .combined(with: .opacity) + ) } } + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: isPresented) .onChange(of: isPresented) { newValue in // When the command palette disappears we need to send focus back to the // surface view we were overlaid on top of. There's probably a better way diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index b3ad88666..4c2052f23 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -342,7 +342,10 @@ class QuickTerminalController: BaseTerminalController { // animate out. if surfaceTree.isEmpty, let ghostty_app = ghostty.app { - let view = Ghostty.SurfaceView(ghostty_app, baseConfig: nil) + var config = Ghostty.SurfaceConfiguration() + config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1" + + let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config) surfaceTree = SplitTree(view: view) focusedSurface = view } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 552f864ee..9104e61ff 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1112,6 +1112,22 @@ class BaseTerminalController: NSWindowController, @IBAction func toggleCommandPalette(_ sender: Any?) { commandPaletteIsShowing.toggle() } + + @IBAction func find(_ sender: Any) { + focusedSurface?.find(sender) + } + + @IBAction func findNext(_ sender: Any) { + focusedSurface?.findNext(sender) + } + + @IBAction func findPrevious(_ sender: Any) { + focusedSurface?.findNext(sender) + } + + @IBAction func findHide(_ sender: Any) { + focusedSurface?.findHide(sender) + } @objc func resetTerminal(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } @@ -1136,3 +1152,15 @@ class BaseTerminalController: NSWindowController, } } } + +extension BaseTerminalController: NSMenuItemValidation { + func validateMenuItem(_ item: NSMenuItem) -> Bool { + switch item.action { + case #selector(findHide): + return focusedSurface?.searchState != nil + + default: + return true + } + } +} diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 4de0336ce..93a05b6b9 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -508,55 +508,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr window.syncAppearance(surfaceConfig) } - /// Returns the default size of the window. This is contextual based on the focused surface because - /// the focused surface may specify a different default size than others. - private var defaultSize: NSRect? { - guard let screen = window?.screen ?? NSScreen.main else { return nil } - - if derivedConfig.maximize { - return screen.visibleFrame - } else if let focusedSurface, - let initialSize = focusedSurface.initialSize { - // Get the current frame of the window - guard var frame = window?.frame else { return nil } - - // Calculate the chrome size (window size minus view size) - let chromeWidth = frame.size.width - focusedSurface.frame.size.width - let chromeHeight = frame.size.height - focusedSurface.frame.size.height - - // Calculate the new width and height, clamping to the screen's size - let newWidth = min(initialSize.width + chromeWidth, screen.visibleFrame.width) - let newHeight = min(initialSize.height + chromeHeight, screen.visibleFrame.height) - - // Update the frame size while keeping the window's position intact - frame.size.width = newWidth - frame.size.height = newHeight - - // Ensure the window doesn't go outside the screen boundaries - frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth)) - frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight)) - - return adjustForWindowPosition(frame: frame, on: screen) - } - - guard let initialFrame else { return nil } - guard var frame = window?.frame else { return nil } - - // Calculate the new width and height, clamping to the screen's size - let newWidth = min(initialFrame.size.width, screen.visibleFrame.width) - let newHeight = min(initialFrame.size.height, screen.visibleFrame.height) - - // Update the frame size while keeping the window's position intact - frame.size.width = newWidth - frame.size.height = newHeight - - // Ensure the window doesn't go outside the screen boundaries - frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth)) - frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight)) - - return adjustForWindowPosition(frame: frame, on: screen) - } - /// Adjusts the given frame for the configured window position. func adjustForWindowPosition(frame: NSRect, on screen: NSScreen) -> NSRect { guard let x = derivedConfig.windowPositionX else { return frame } @@ -922,9 +873,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr super.windowDidLoad() guard let window else { return } - // Store our initial frame so we can know our default later. - initialFrame = window.frame - // I copy this because we may change the source in the future but also because // I regularly audit our codebase for "ghostty.config" access because generally // you shouldn't use it. Its safe in this case because for a new window we should @@ -944,19 +892,38 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // If this is our first surface then our focused surface will be nil // so we force the focused surface to the leaf. focusedSurface = view - - if let defaultSize { - window.setFrame(defaultSize, display: true) - } } // Initialize our content view to the SwiftUI root window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, viewModel: self, - delegate: self + delegate: self, )) - + + // If we have a default size, we want to apply it. + if let defaultSize { + switch (defaultSize) { + case .frame: + // Frames can be applied immediately + defaultSize.apply(to: window) + + case .contentIntrinsicSize: + // Content intrinsic size requires a short delay so that AppKit + // can layout our SwiftUI views. + DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak window] in + guard let window else { return } + defaultSize.apply(to: window) + } + } + } + + // Store our initial frame so we can know our default later. This MUST + // be after the defaultSize call above so that we don't re-apply our frame. + // Note: we probably want to set this on the first frame change or something + // so it respects cascade. + initialFrame = window.frame + // In various situations, macOS automatically tabs new windows. Ghostty handles // its own tabbing so we DONT want this behavior. This detects this scenario and undoes // it. @@ -1144,8 +1111,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } @IBAction func returnToDefaultSize(_ sender: Any?) { - guard let defaultSize else { return } - window?.setFrame(defaultSize, display: true) + guard let window, let defaultSize else { return } + defaultSize.apply(to: window) } @IBAction override func closeWindow(_ sender: Any?) { @@ -1403,8 +1370,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // MARK: NSMenuItemValidation -extension TerminalController: NSMenuItemValidation { - func validateMenuItem(_ item: NSMenuItem) -> Bool { +extension TerminalController { + override func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { case #selector(returnToDefaultSize): guard let window else { return false } @@ -1421,19 +1388,68 @@ extension TerminalController: NSMenuItemValidation { // If our window is already the default size or we don't have a // default size, then disable. - guard let defaultSize, - window.frame.size != .init( - width: defaultSize.size.width, - height: defaultSize.size.height - ) - else { - return false - } - - return true + return defaultSize?.isChanged(for: window) ?? false default: - return true + return super.validateMenuItem(item) + } + } +} + +// MARK: Default Size + +extension TerminalController { + /// The possible default sizes for a terminal. The size can't purely be known as a + /// window frame because if we set `window-width/height` then it is based + /// on content size. + enum DefaultSize { + /// A frame, set with `window.setFrame` + case frame(NSRect) + + /// A content size, set with `window.setContentSize` + case contentIntrinsicSize + + func isChanged(for window: NSWindow) -> Bool { + switch self { + case .frame(let rect): + return window.frame != rect + case .contentIntrinsicSize: + guard let view = window.contentView else { + return false + } + + return view.frame.size != view.intrinsicContentSize + } + } + + func apply(to window: NSWindow) { + switch self { + case .frame(let rect): + window.setFrame(rect, display: true) + case .contentIntrinsicSize: + guard let size = window.contentView?.intrinsicContentSize else { + return + } + + window.setContentSize(size) + window.constrainToScreen() + } + } + } + + private var defaultSize: DefaultSize? { + if derivedConfig.maximize, let screen = window?.screen ?? NSScreen.main { + // Maximize takes priority, we take up the full screen we're on. + return .frame(screen.visibleFrame) + } else if focusedSurface?.initialSize != nil { + // Initial size as requested by the configuration (e.g. `window-width`) + // takes next priority. + return .contentIntrinsicSize + } else if let initialFrame { + // The initial frame we had when we started otherwise. + return .frame(initialFrame) + } else { + return nil } } } diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 8c5955c7f..fd53a617b 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -45,7 +45,7 @@ struct TerminalView: View { // An optional delegate to receive information about terminal changes. weak var delegate: (any TerminalViewDelegate)? = nil - + // The most recently focused surface, equal to focusedSurface when // it is non-nil. @State private var lastFocusedSurface: Weak = .init() @@ -100,6 +100,8 @@ struct TerminalView: View { guard let size = newValue else { return } self.delegate?.cellSizeDidChange(to: size) } + .frame(idealWidth: lastFocusedSurface.value?.initialSize?.width, + idealHeight: lastFocusedSurface.value?.initialSize?.height) } // Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style .ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : []) diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 9d389a8c2..8fce2199d 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -115,6 +115,18 @@ extension Ghostty.Action { len = c.len } } + + struct StartSearch { + let needle: String? + + init(c: ghostty_action_start_search_s) { + if let needleCString = c.needle { + self.needle = String(cString: needleCString) + } else { + self.needle = nil + } + } + } } // Putting the initializer in an extension preserves the automatic one. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 9c19199e8..39ebbb51f 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -180,14 +180,14 @@ extension Ghostty { func newTab(surface: ghostty_surface_t) { let action = "new_tab" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func newWindow(surface: ghostty_surface_t) { let action = "new_window" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } @@ -210,14 +210,14 @@ extension Ghostty { func splitToggleZoom(surface: ghostty_surface_t) { let action = "toggle_split_zoom" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func toggleFullscreen(surface: ghostty_surface_t) { let action = "toggle_fullscreen" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } @@ -238,21 +238,21 @@ extension Ghostty { case .reset: action = "reset_font_size" } - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func toggleTerminalInspector(surface: ghostty_surface_t) { let action = "inspector:toggle" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func resetTerminal(surface: ghostty_surface_t) { let action = "reset" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } @@ -606,6 +606,18 @@ extension Ghostty { case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: closeAllWindows(app, target: target) + case GHOSTTY_ACTION_START_SEARCH: + startSearch(app, target: target, v: action.action.start_search) + + case GHOSTTY_ACTION_END_SEARCH: + endSearch(app, target: target) + + case GHOSTTY_ACTION_SEARCH_TOTAL: + searchTotal(app, target: target, v: action.action.search_total) + + case GHOSTTY_ACTION_SEARCH_SELECTED: + searchSelected(app, target: target, v: action.action.search_selected) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -1641,6 +1653,100 @@ extension Ghostty { } } + private static func startSearch( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_start_search_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("start_search does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let startSearch = Ghostty.Action.StartSearch(c: v) + DispatchQueue.main.async { + if surfaceView.searchState != nil { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView) + } else { + surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) + } + } + + default: + assertionFailure() + } + } + + private static func endSearch( + _ app: ghostty_app_t, + target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("end_search does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + DispatchQueue.main.async { + surfaceView.searchState = nil + } + + default: + assertionFailure() + } + } + + private static func searchTotal( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_search_total_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("search_total does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let total: UInt? = v.total >= 0 ? UInt(v.total) : nil + DispatchQueue.main.async { + surfaceView.searchState?.total = total + } + + default: + assertionFailure() + } + } + + private static func searchSelected( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_search_selected_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("search_selected does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let selected: UInt? = v.selected >= 0 ? UInt(v.selected) : nil + DispatchQueue.main.async { + surfaceView.searchState?.selected = selected + } + + default: + assertionFailure() + } + } + private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index f380345c7..2df0a8656 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -105,7 +105,7 @@ extension Ghostty { func keyboardShortcut(for action: String) -> KeyboardShortcut? { guard let cfg = self.config else { return nil } - let trigger = ghostty_config_trigger(cfg, action, UInt(action.count)) + let trigger = ghostty_config_trigger(cfg, action, UInt(action.lengthOfBytes(using: .utf8))) return Ghostty.keyboardShortcut(for: trigger) } #endif @@ -120,7 +120,7 @@ extension Ghostty { guard let config = self.config else { return .init() } var v: CUnsignedInt = 0 let key = "bell-features" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .init() } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .init() } return .init(rawValue: v) } @@ -128,7 +128,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = true; let key = "initial-window" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -136,7 +136,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "quit-after-last-window-closed" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -144,7 +144,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "title" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } return String(cString: ptr) } @@ -153,7 +153,7 @@ extension Ghostty { guard let config = self.config else { return "" } var v: UnsafePointer? = nil let key = "window-save-state" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return "" } guard let ptr = v else { return "" } return String(cString: ptr) } @@ -162,21 +162,21 @@ extension Ghostty { guard let config = self.config else { return nil } var v: Int16 = 0 let key = "window-position-x" - return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + return ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) ? v : nil } var windowPositionY: Int16? { guard let config = self.config else { return nil } var v: Int16 = 0 let key = "window-position-y" - return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + return ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) ? v : nil } var windowNewTabPosition: String { guard let config = self.config else { return "" } var v: UnsafePointer? = nil let key = "window-new-tab-position" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return "" } guard let ptr = v else { return "" } return String(cString: ptr) } @@ -186,7 +186,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "window-decoration" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return WindowDecoration(rawValue: str)?.enabled() ?? defaultValue @@ -196,7 +196,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "window-theme" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } return String(cString: ptr) } @@ -205,7 +205,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false let key = "window-step-resize" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -213,7 +213,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false let key = "fullscreen" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -223,7 +223,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-non-native-fullscreen" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return switch str { @@ -245,7 +245,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "window-title-font-family" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } return String(cString: ptr) } @@ -255,7 +255,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-window-buttons" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSWindowButtons(rawValue: str) ?? defaultValue @@ -266,7 +266,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-titlebar-style" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } return String(cString: ptr) } @@ -276,7 +276,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-titlebar-proxy-icon" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSTitlebarProxyIcon(rawValue: str) ?? defaultValue @@ -287,7 +287,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-dock-drop-behavior" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacDockDropBehavior(rawValue: str) ?? defaultValue @@ -297,7 +297,7 @@ extension Ghostty { guard let config = self.config else { return false } var v = false; let key = "macos-window-shadow" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -306,7 +306,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-icon" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSIcon(rawValue: str) ?? defaultValue @@ -318,7 +318,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-custom-icon" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } guard let path = NSString(utf8String: ptr) else { return defaultValue } return path.expandingTildeInPath @@ -332,7 +332,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-icon-frame" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSIconFrame(rawValue: str) ?? defaultValue @@ -342,7 +342,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: ghostty_config_color_s = .init() let key = "macos-icon-ghost-color" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } return .init(ghostty: v) } @@ -350,7 +350,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: ghostty_config_color_list_s = .init() let key = "macos-icon-screen-color" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard v.len > 0 else { return nil } let buffer = UnsafeBufferPointer(start: v.colors, count: v.len) return buffer.map { .init(ghostty: $0) } @@ -360,7 +360,7 @@ extension Ghostty { guard let config = self.config else { return .never } var v: UnsafePointer? = nil let key = "macos-hidden" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .never } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .never } guard let ptr = v else { return .never } let str = String(cString: ptr) return MacHidden(rawValue: str) ?? .never @@ -370,14 +370,14 @@ extension Ghostty { guard let config = self.config else { return false } var v = false; let key = "focus-follows-mouse" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } var backgroundColor: Color { var color: ghostty_config_color_s = .init(); let bg_key = "background" - if (!ghostty_config_get(config, &color, bg_key, UInt(bg_key.count))) { + if (!ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8)))) { #if os(macOS) return Color(NSColor.windowBackgroundColor) #elseif os(iOS) @@ -398,7 +398,7 @@ extension Ghostty { guard let config = self.config else { return 1 } var v: Double = 1 let key = "background-opacity" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v; } @@ -406,7 +406,7 @@ extension Ghostty { guard let config = self.config else { return 1 } var v: Int = 0 let key = "background-blur" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v; } @@ -414,7 +414,7 @@ extension Ghostty { guard let config = self.config else { return 1 } var opacity: Double = 0.85 let key = "unfocused-split-opacity" - _ = ghostty_config_get(config, &opacity, key, UInt(key.count)) + _ = ghostty_config_get(config, &opacity, key, UInt(key.lengthOfBytes(using: .utf8))) return 1 - opacity } @@ -423,9 +423,9 @@ extension Ghostty { var color: ghostty_config_color_s = .init(); let key = "unfocused-split-fill" - if (!ghostty_config_get(config, &color, key, UInt(key.count))) { + if (!ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8)))) { let bg_key = "background" - _ = ghostty_config_get(config, &color, bg_key, UInt(bg_key.count)); + _ = ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8))); } return .init( @@ -444,7 +444,7 @@ extension Ghostty { var color: ghostty_config_color_s = .init(); let key = "split-divider-color" - if (!ghostty_config_get(config, &color, key, UInt(key.count))) { + if (!ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8)))) { return Color(newColor) } @@ -460,7 +460,7 @@ extension Ghostty { guard let config = self.config else { return .top } var v: UnsafePointer? = nil let key = "quick-terminal-position" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .top } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .top } guard let ptr = v else { return .top } let str = String(cString: ptr) return QuickTerminalPosition(rawValue: str) ?? .top @@ -470,7 +470,7 @@ extension Ghostty { guard let config = self.config else { return .main } var v: UnsafePointer? = nil let key = "quick-terminal-screen" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .main } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .main } guard let ptr = v else { return .main } let str = String(cString: ptr) return QuickTerminalScreen(fromGhosttyConfig: str) ?? .main @@ -480,7 +480,7 @@ extension Ghostty { guard let config = self.config else { return 0.2 } var v: Double = 0.2 let key = "quick-terminal-animation-duration" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -488,7 +488,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = true let key = "quick-terminal-autohide" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -496,7 +496,7 @@ extension Ghostty { guard let config = self.config else { return .move } var v: UnsafePointer? = nil let key = "quick-terminal-space-behavior" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .move } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .move } guard let ptr = v else { return .move } let str = String(cString: ptr) return QuickTerminalSpaceBehavior(fromGhosttyConfig: str) ?? .move @@ -506,7 +506,7 @@ extension Ghostty { guard let config = self.config else { return QuickTerminalSize() } var v = ghostty_config_quick_terminal_size_s() let key = "quick-terminal-size" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return QuickTerminalSize() } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return QuickTerminalSize() } return QuickTerminalSize(from: v) } #endif @@ -515,7 +515,7 @@ extension Ghostty { guard let config = self.config else { return .after_first } var v: UnsafePointer? = nil let key = "resize-overlay" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .after_first } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .after_first } guard let ptr = v else { return .after_first } let str = String(cString: ptr) return ResizeOverlay(rawValue: str) ?? .after_first @@ -526,7 +526,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "resize-overlay-position" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return ResizeOverlayPosition(rawValue: str) ?? defaultValue @@ -536,7 +536,7 @@ extension Ghostty { guard let config = self.config else { return 1000 } var v: UInt = 0 let key = "resize-overlay-duration" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v; } @@ -544,7 +544,7 @@ extension Ghostty { guard let config = self.config else { return .seconds(5) } var v: UInt = 0 let key = "undo-timeout" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return .milliseconds(v) } @@ -552,7 +552,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "auto-update" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } let str = String(cString: ptr) return AutoUpdate(rawValue: str) @@ -563,7 +563,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "auto-update-channel" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return AutoUpdateChannel(rawValue: str) ?? defaultValue @@ -573,7 +573,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "macos-auto-secure-input" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -581,7 +581,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "macos-secure-input-indication" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -589,7 +589,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "maximize" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -598,7 +598,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-shortcuts" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacShortcuts(rawValue: str) ?? defaultValue @@ -609,7 +609,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "scrollbar" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return Scrollbar(rawValue: str) ?? defaultValue diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index f36b486ba..7ee815caa 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -396,6 +396,9 @@ extension Notification.Name { /// Notification sent when scrollbar updates static let ghosttyDidUpdateScrollbar = Notification.Name("com.mitchellh.ghostty.didUpdateScrollbar") static let ScrollbarKey = ghosttyDidUpdateScrollbar.rawValue + ".scrollbar" + + /// Focus the search field + static let ghosttySearchFocus = Notification.Name("com.mitchellh.ghostty.searchFocus") } // NOTE: I am moving all of these to Notification.Name extensions over time. This diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 86ec355fa..4e81eda14 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -172,13 +172,16 @@ class SurfaceScrollView: NSView { } // MARK: Scrolling - + private func synchronizeAppearance() { let scrollbarConfig = surfaceView.derivedConfig.scrollbar scrollView.hasVerticalScroller = scrollbarConfig != .never scrollView.verticalScroller?.controlSize = .small + let hasLightBackground = OSColor(surfaceView.derivedConfig.backgroundColor).isLightColor + // Make sure the scroller’s appearance matches the surface's background color. + scrollView.appearance = NSAppearance(named: hasLightBackground ? .aqua : .darkAqua) } - + /// Positions the surface view to fill the currently visible rectangle. /// /// This is called whenever the scroll position changes. The surface view (which does the diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 0358f765b..ba678db59 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -197,7 +197,16 @@ extension Ghostty { SecureInputOverlay() } #endif - + + // Search overlay + if let searchState = surfaceView.searchState { + SurfaceSearchOverlay( + surfaceView: surfaceView, + searchState: searchState, + onClose: { surfaceView.searchState = nil } + ) + } + // Show bell border if enabled if (ghostty.config.bellFeatures.contains(.border)) { BellBorderOverlay(bell: surfaceView.bell) @@ -382,6 +391,202 @@ extension Ghostty { } } + /// Search overlay view that displays a search bar with input field and navigation buttons. + struct SurfaceSearchOverlay: View { + let surfaceView: SurfaceView + @ObservedObject var searchState: SurfaceView.SearchState + let onClose: () -> Void + @State private var corner: Corner = .topRight + @State private var dragOffset: CGSize = .zero + @State private var barSize: CGSize = .zero + @FocusState private var isSearchFieldFocused: Bool + + private let padding: CGFloat = 8 + + var body: some View { + GeometryReader { geo in + HStack(spacing: 4) { + TextField("Search", text: $searchState.needle) + .textFieldStyle(.plain) + .frame(width: 180) + .padding(.leading, 8) + .padding(.trailing, 50) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.1)) + .cornerRadius(6) + .focused($isSearchFieldFocused) + .overlay(alignment: .trailing) { + if let selected = searchState.selected { + Text("\(selected + 1)/\(searchState.total, default: "?")") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + .padding(.trailing, 8) + } else if let total = searchState.total { + Text("-/\(total)") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + .padding(.trailing, 8) + } + } +#if canImport(AppKit) + .onExitCommand { + Ghostty.moveFocus(to: surfaceView) + } +#endif + .backport.onKeyPress(.return) { modifiers in + guard let surface = surfaceView.surface else { return .ignored } + let action = modifiers.contains(.shift) + ? "navigate_search:previous" + : "navigate_search:next" + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) + return .handled + } + + Button(action: { + guard let surface = surfaceView.surface else { return } + let action = "navigate_search:next" + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) + }) { + Image(systemName: "chevron.up") + } + .buttonStyle(SearchButtonStyle()) + + Button(action: { + guard let surface = surfaceView.surface else { return } + let action = "navigate_search:previous" + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) + }) { + Image(systemName: "chevron.down") + } + .buttonStyle(SearchButtonStyle()) + + Button(action: onClose) { + Image(systemName: "xmark") + } + .buttonStyle(SearchButtonStyle()) + } + .padding(8) + .background(.background) + .clipShape(clipShape) + .shadow(radius: 4) + .onAppear { + isSearchFieldFocused = true + } + .onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in + guard notification.object as? SurfaceView === surfaceView else { return } + isSearchFieldFocused = true + } + .background( + GeometryReader { barGeo in + Color.clear.onAppear { + barSize = barGeo.size + } + } + ) + .padding(padding) + .offset(dragOffset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment) + .gesture( + DragGesture() + .onChanged { value in + dragOffset = value.translation + } + .onEnded { value in + let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize) + let newCenter = CGPoint( + x: centerPos.x + value.translation.width, + y: centerPos.y + value.translation.height + ) + let newCorner = closestCorner(to: newCenter, in: geo.size) + withAnimation(.easeOut(duration: 0.2)) { + corner = newCorner + dragOffset = .zero + } + } + ) + } + } + + private var clipShape: some Shape { + if #available(iOS 26.0, macOS 26.0, *) { + return ConcentricRectangle(corners: .concentric(minimum: 8), isUniform: true) + } else { + return RoundedRectangle(cornerRadius: 8) + } + } + + enum Corner { + case topLeft, topRight, bottomLeft, bottomRight + + var alignment: Alignment { + switch self { + case .topLeft: return .topLeading + case .topRight: return .topTrailing + case .bottomLeft: return .bottomLeading + case .bottomRight: return .bottomTrailing + } + } + } + + private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint { + let halfWidth = barSize.width / 2 + padding + let halfHeight = barSize.height / 2 + padding + + switch corner { + case .topLeft: + return CGPoint(x: halfWidth, y: halfHeight) + case .topRight: + return CGPoint(x: containerSize.width - halfWidth, y: halfHeight) + case .bottomLeft: + return CGPoint(x: halfWidth, y: containerSize.height - halfHeight) + case .bottomRight: + return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight) + } + } + + private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner { + let midX = containerSize.width / 2 + let midY = containerSize.height / 2 + + if point.x < midX { + return point.y < midY ? .topLeft : .bottomLeft + } else { + return point.y < midY ? .topRight : .bottomRight + } + } + + struct SearchButtonStyle: ButtonStyle { + @State private var isHovered = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(isHovered || configuration.isPressed ? .primary : .secondary) + .padding(.horizontal, 2) + .frame(height: 26) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(backgroundColor(isPressed: configuration.isPressed)) + ) + .onHover { hovering in + isHovered = hovering + } + .backport.pointerStyle(.link) + } + + private func backgroundColor(isPressed: Bool) -> Color { + if isPressed { + return Color.primary.opacity(0.2) + } else if isHovered { + return Color.primary.opacity(0.1) + } else { + return Color.clear + } + } + } + } + /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. @@ -658,3 +863,17 @@ extension FocusedValues { typealias Value = OSSize } } + +// MARK: Search State + +extension Ghostty.SurfaceView { + class SearchState: ObservableObject { + @Published var needle: String = "" + @Published var selected: UInt? = nil + @Published var total: UInt? = nil + + init(from startSearch: Ghostty.Action.StartSearch) { + self.needle = startSearch.needle ?? "" + } + } +} diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 063b13300..03ef293af 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI import CoreText import UserNotifications @@ -64,6 +65,43 @@ extension Ghostty { // The currently active key sequence. The sequence is not active if this is empty. @Published var keySequence: [KeyboardShortcut] = [] + // The current search state. When non-nil, the search overlay should be shown. + @Published var searchState: SearchState? = nil { + didSet { + if let searchState { + // I'm not a Combine expert so if there is a better way to do this I'm + // all ears. What we're doing here is grabbing the latest needle. If the + // needle is less than 3 chars, we debounce it for a few hundred ms to + // avoid kicking off expensive searches. + searchNeedleCancellable = searchState.$needle + .removeDuplicates() + .map { needle -> AnyPublisher in + if needle.isEmpty || needle.count >= 3 { + return Just(needle).eraseToAnyPublisher() + } else { + return Just(needle) + .delay(for: .milliseconds(300), scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + } + } + .switchToLatest() + .sink { [weak self] needle in + guard let surface = self?.surface else { return } + let action = "search:\(needle)" + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) + } + } else if oldValue != nil { + searchNeedleCancellable = nil + guard let surface = self.surface else { return } + let action = "end_search" + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) + } + } + } + + // Cancellable for search state needle changes + private var searchNeedleCancellable: AnyCancellable? + // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. @Published var focusInstant: ContinuousClock.Instant? = nil @@ -1410,7 +1448,7 @@ extension Ghostty { @IBAction func copy(_ sender: Any?) { guard let surface = self.surface else { return } let action = "copy_to_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1418,7 +1456,7 @@ extension Ghostty { @IBAction func paste(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1427,7 +1465,7 @@ extension Ghostty { @IBAction func pasteAsPlainText(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1435,7 +1473,7 @@ extension Ghostty { @IBAction func pasteSelection(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_selection" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1443,7 +1481,39 @@ extension Ghostty { @IBAction override func selectAll(_ sender: Any?) { guard let surface = self.surface else { return } let action = "select_all" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func find(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "start_search" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func findNext(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "search:next" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func findPrevious(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "search:previous" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func findHide(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "end_search" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1471,7 +1541,7 @@ extension Ghostty { @objc func resetTerminal(_ sender: Any) { guard let surface = self.surface else { return } let action = "reset" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1479,7 +1549,7 @@ extension Ghostty { @objc func toggleTerminalInspector(_ sender: Any) { guard let surface = self.surface else { return } let action = "inspector:toggle" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1740,7 +1810,13 @@ extension Ghostty.SurfaceView: NSTextInputClient { } else { ghostty_surface_ime_point(surface, &x, &y, &width, &height) } - + if range.length == 0, width > 0 { + // This fixes #8493 while speaking + // My guess is that positive width doesn't make sense + // for the dictation microphone indicator + width = 0 + x += cellSize.width * Double(range.location + range.length) + } // Ghostty coordinates are in top-left (0, 0) so we have to convert to // bottom-left since that is what UIKit expects // when there's is no characters selected, @@ -1914,6 +1990,9 @@ extension Ghostty.SurfaceView: NSMenuItemValidation { let pb = NSPasteboard.ghosttySelection guard let str = pb.getOpinionatedStringContents() else { return false } return !str.isEmpty + + case #selector(findHide): + return searchState != nil default: return true diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 29364d4a5..09c41c0b5 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -40,6 +40,9 @@ extension Ghostty { /// True when the bell is active. This is set inactive on focus or event. @Published var bell: Bool = false + + // The current search state. When non-nil, the search overlay should be shown. + @Published var searchState: SearchState? = nil // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index a28be15ae..8c43652e4 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -18,6 +18,12 @@ extension Backport where Content: Scene { // None currently } +/// Result type for backported onKeyPress handler +enum BackportKeyPressResult { + case handled + case ignored +} + extension Backport where Content: View { func pointerVisibility(_ v: BackportVisibility) -> some View { #if canImport(AppKit) @@ -42,6 +48,24 @@ extension Backport where Content: View { return content #endif } + + /// Backported onKeyPress that works on macOS 14+ and is a no-op on macOS 13. + func onKeyPress(_ key: KeyEquivalent, action: @escaping (EventModifiers) -> BackportKeyPressResult) -> some View { + #if canImport(AppKit) + if #available(macOS 14, *) { + return content.onKeyPress(key, phases: .down, action: { keyPress in + switch action(keyPress.modifiers) { + case .handled: return .handled + case .ignored: return .ignored + } + }) + } else { + return content + } + #else + return content + #endif + } } enum BackportVisibility { diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index f9ed364aa..d834f5e63 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -15,4 +15,20 @@ extension NSWindow { guard let firstWindow = tabGroup?.windows.first else { return true } return firstWindow === self } + + /// Adjusts the window origin if necessary to ensure the window remains visible on screen. + func constrainToScreen() { + guard let screen = screen ?? NSScreen.main else { return } + let visibleFrame = screen.visibleFrame + var windowFrame = frame + + windowFrame.origin.x = max(visibleFrame.minX, + min(windowFrame.origin.x, visibleFrame.maxX - windowFrame.width)) + windowFrame.origin.y = max(visibleFrame.minY, + min(windowFrame.origin.y, visibleFrame.maxY - windowFrame.height)) + + if windowFrame.origin != frame.origin { + setFrameOrigin(windowFrame.origin) + } + } } diff --git a/pkg/fontconfig/lang_set.zig b/pkg/fontconfig/lang_set.zig index aaf55bab6..abefcc3e6 100644 --- a/pkg/fontconfig/lang_set.zig +++ b/pkg/fontconfig/lang_set.zig @@ -11,8 +11,12 @@ pub const LangSet = opaque { c.FcLangSetDestroy(self.cval()); } + pub fn addLang(self: *LangSet, lang: [:0]const u8) bool { + return c.FcLangSetAdd(self.cval(), lang.ptr) == c.FcTrue; + } + pub fn hasLang(self: *const LangSet, lang: [:0]const u8) bool { - return c.FcLangSetHasLang(self.cvalConst(), lang.ptr) == c.FcTrue; + return c.FcLangSetHasLang(self.cvalConst(), lang.ptr) == c.FcLangEqual; } pub inline fn cval(self: *LangSet) *c.struct__FcLangSet { @@ -32,3 +36,26 @@ test "create" { try testing.expect(!fs.hasLang("und-zsye")); } + +test "hasLang exact match" { + const testing = std.testing; + + // Test exact match: langset with "en-US" should return true for "en-US" + var fs = LangSet.create(); + defer fs.destroy(); + try testing.expect(fs.addLang("en-US")); + try testing.expect(fs.hasLang("en-US")); + + // Test exact match: langset with "und-zsye" should return true for "und-zsye" + var fs_emoji = LangSet.create(); + defer fs_emoji.destroy(); + try testing.expect(fs_emoji.addLang("und-zsye")); + try testing.expect(fs_emoji.hasLang("und-zsye")); + + // Test mismatch: langset with "en-US" should return false for "fr" + try testing.expect(!fs.hasLang("fr")); + + // Test partial match: langset with "en-US" should return false for "en-GB" + // (different territory, but we only want exact matches) + try testing.expect(!fs.hasLang("en-GB")); +} diff --git a/pkg/freetype/face.zig b/pkg/freetype/face.zig index f8714d4fe..d4f74b7ee 100644 --- a/pkg/freetype/face.zig +++ b/pkg/freetype/face.zig @@ -252,9 +252,13 @@ pub const RenderMode = enum(c_uint) { sdf = c.FT_RENDER_MODE_SDF, }; -/// A list of bit field constants for FT_Load_Glyph to indicate what kind of -/// operations to perform during glyph loading. -pub const LoadFlags = packed struct { +/// A collection of flags for FT_Load_Glyph that indicate +/// what kind of operations to perform during glyph loading. +/// +/// Some of these flags are not included in the official FreeType +/// documentation, but are nevertheless present and named in the +/// header, so the names have been copied from there. +pub const LoadFlags = packed struct(c_int) { no_scale: bool = false, no_hinting: bool = false, render: bool = false, @@ -263,39 +267,97 @@ pub const LoadFlags = packed struct { force_autohint: bool = false, crop_bitmap: bool = false, pedantic: bool = false, - ignore_global_advance_with: bool = false, + advance_only: bool = false, + ignore_global_advance_width: bool = false, no_recurse: bool = false, ignore_transform: bool = false, monochrome: bool = false, linear_design: bool = false, + sbits_only: bool = false, no_autohint: bool = false, - _padding1: u1 = 0, - target_normal: bool = false, - target_light: bool = false, - target_mono: bool = false, - target_lcd: bool = false, - target_lcd_v: bool = false, + target: Target = .normal, color: bool = false, compute_metrics: bool = false, bitmap_metrics_only: bool = false, - _padding2: u1 = 0, + svg_only: bool = false, no_svg: bool = false, - _padding3: u7 = 0, + _padding: u7 = 0, - test { - // This must always be an i32 size so we can bitcast directly. - const testing = std.testing; - try testing.expectEqual(@sizeOf(i32), @sizeOf(LoadFlags)); - } + pub const Target = enum(u4) { + normal = 0, + light = 1, + mono = 2, + lcd = 3, + lcd_v = 4, + }; test "bitcast" { const testing = std.testing; + const cval: i32 = c.FT_LOAD_RENDER | c.FT_LOAD_PEDANTIC | c.FT_LOAD_COLOR; const flags = @as(LoadFlags, @bitCast(cval)); try testing.expect(!flags.no_hinting); try testing.expect(flags.render); try testing.expect(flags.pedantic); try testing.expect(flags.color); + + // Verify bit alignment (for bit 9) + const cval2: i32 = c.FT_LOAD_IGNORE_GLOBAL_ADVANCE_WIDTH; + const flags2 = @as(LoadFlags, @bitCast(cval2)); + try testing.expect(flags2.ignore_global_advance_width); + try testing.expect(!flags2.no_recurse); + } + + test "all flags individually" { + const testing = std.testing; + + try testing.expectEqual( + c.FT_LOAD_DEFAULT, + @as(c_int, @bitCast(LoadFlags{})), + ); + + inline for ([_]struct { c_int, []const u8 }{ + .{ c.FT_LOAD_NO_SCALE, "no_scale" }, + .{ c.FT_LOAD_NO_HINTING, "no_hinting" }, + .{ c.FT_LOAD_RENDER, "render" }, + .{ c.FT_LOAD_NO_BITMAP, "no_bitmap" }, + .{ c.FT_LOAD_VERTICAL_LAYOUT, "vertical_layout" }, + .{ c.FT_LOAD_FORCE_AUTOHINT, "force_autohint" }, + .{ c.FT_LOAD_CROP_BITMAP, "crop_bitmap" }, + .{ c.FT_LOAD_PEDANTIC, "pedantic" }, + .{ c.FT_LOAD_ADVANCE_ONLY, "advance_only" }, + .{ c.FT_LOAD_IGNORE_GLOBAL_ADVANCE_WIDTH, "ignore_global_advance_width" }, + .{ c.FT_LOAD_NO_RECURSE, "no_recurse" }, + .{ c.FT_LOAD_IGNORE_TRANSFORM, "ignore_transform" }, + .{ c.FT_LOAD_MONOCHROME, "monochrome" }, + .{ c.FT_LOAD_LINEAR_DESIGN, "linear_design" }, + .{ c.FT_LOAD_SBITS_ONLY, "sbits_only" }, + .{ c.FT_LOAD_NO_AUTOHINT, "no_autohint" }, + .{ c.FT_LOAD_COLOR, "color" }, + .{ c.FT_LOAD_COMPUTE_METRICS, "compute_metrics" }, + .{ c.FT_LOAD_BITMAP_METRICS_ONLY, "bitmap_metrics_only" }, + .{ c.FT_LOAD_SVG_ONLY, "svg_only" }, + .{ c.FT_LOAD_NO_SVG, "no_svg" }, + }) |pair| { + var flags: LoadFlags = .{}; + @field(flags, pair[1]) = true; + try testing.expectEqual(pair[0], @as(c_int, @bitCast(flags))); + } + } + + test "all load targets" { + const testing = std.testing; + + inline for ([_]struct { c_int, Target }{ + .{ c.FT_LOAD_TARGET_NORMAL, .normal }, + .{ c.FT_LOAD_TARGET_LIGHT, .light }, + .{ c.FT_LOAD_TARGET_MONO, .mono }, + .{ c.FT_LOAD_TARGET_LCD, .lcd }, + .{ c.FT_LOAD_TARGET_LCD_V, .lcd_v }, + }) |pair| { + const flags: LoadFlags = .{ .target = pair[1] }; + try testing.expectEqual(pair[0], @as(c_int, @bitCast(flags))); + } } }; diff --git a/pkg/freetype/res/FiraCode-Regular.ttf b/pkg/freetype/res/FiraCode-Regular.ttf new file mode 100755 index 000000000..bd7368519 Binary files /dev/null and b/pkg/freetype/res/FiraCode-Regular.ttf differ diff --git a/pkg/freetype/test.zig b/pkg/freetype/test.zig index 093061616..866c6f2a4 100644 --- a/pkg/freetype/test.zig +++ b/pkg/freetype/test.zig @@ -1 +1 @@ -pub const font_regular = @embedFile("res/JetBrainsMono-Regular.ttf"); +pub const font_regular = @embedFile("res/FiraCode-Regular.ttf"); diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index 4c75de49e..fd93675e6 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -67,6 +67,10 @@ pub fn build(b: *std.Build) !void { "-fno-cxx-exceptions", "-fno-slp-vectorize", "-fno-vectorize", + + // Fixes linker issues for release builds missing ubsanitizer symbols + "-fno-sanitize=undefined", + "-fno-sanitize-trap=undefined", }); if (target.result.os.tag != .windows) { try flags.appendSlice(b.allocator, &.{ diff --git a/pkg/simdutf/build.zig b/pkg/simdutf/build.zig index f2ddfeba4..0d827c1cc 100644 --- a/pkg/simdutf/build.zig +++ b/pkg/simdutf/build.zig @@ -24,7 +24,13 @@ pub fn build(b: *std.Build) !void { defer flags.deinit(b.allocator); // Zig 0.13 bug: https://github.com/ziglang/zig/issues/20414 // (See root Ghostty build.zig on why we do this) - try flags.appendSlice(b.allocator, &.{"-DSIMDUTF_IMPLEMENTATION_ICELAKE=0"}); + try flags.appendSlice(b.allocator, &.{ + "-DSIMDUTF_IMPLEMENTATION_ICELAKE=0", + + // Fixes linker issues for release builds missing ubsanitizer symbols + "-fno-sanitize=undefined", + "-fno-sanitize-trap=undefined", + }); lib.addCSourceFiles(.{ .flags = flags.items, diff --git a/src/App.zig b/src/App.zig index 69667dcb9..99d03399c 100644 --- a/src/App.zig +++ b/src/App.zig @@ -5,21 +5,16 @@ const App = @This(); const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const build_config = @import("build_config.zig"); const apprt = @import("apprt.zig"); const Surface = @import("Surface.zig"); -const tracy = @import("tracy"); const input = @import("input.zig"); const configpkg = @import("config.zig"); const Config = configpkg.Config; const BlockingQueue = @import("datastruct/main.zig").BlockingQueue; const renderer = @import("renderer.zig"); const font = @import("font/main.zig"); -const internal_os = @import("os/main.zig"); -const macos = @import("macos"); -const objc = @import("objc"); const log = std.log.scoped(.app); diff --git a/src/Surface.zig b/src/Surface.zig index 308b6d1f7..40929e168 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -17,7 +17,7 @@ pub const Message = apprt.surface.Message; const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const global_state = &@import("global.zig").state; @@ -26,9 +26,6 @@ const crash = @import("crash/main.zig"); const unicode = @import("unicode/main.zig"); const rendererpkg = @import("renderer.zig"); const termio = @import("termio.zig"); -const objc = @import("objc"); -const imgui = @import("imgui"); -const Pty = @import("pty.zig").Pty; const font = @import("font/main.zig"); const Command = @import("Command.zig"); const terminal = @import("terminal/main.zig"); @@ -155,6 +152,9 @@ selection_scroll_active: bool = false, /// the wall clock time that has elapsed between timestamps. command_timer: ?std.time.Instant = null, +/// Search state +search: ?Search = null, + /// The effect of an input event. This can be used by callers to take /// the appropriate action after an input event. For example, key /// input can be forwarded to the OS for further processing if it @@ -174,6 +174,26 @@ pub const InputEffect = enum { closed, }; +/// The search state for the surface. +const Search = struct { + state: terminal.search.Thread, + thread: std.Thread, + + pub fn deinit(self: *Search) void { + // Notify the thread to stop + self.state.stop.notify() catch |err| log.err( + "error notifying search thread to stop, may stall err={}", + .{err}, + ); + + // Wait for the OS thread to quit + self.thread.join(); + + // Now it is safe to deinit the state + self.state.deinit(); + } +}; + /// Mouse state for the surface. const Mouse = struct { /// The last tracked mouse button state by button. @@ -728,6 +748,9 @@ pub fn init( } pub fn deinit(self: *Surface) void { + // Stop search thread + if (self.search) |*s| s.deinit(); + // Stop rendering thread { self.renderer_thread.stop.notify() catch |err| @@ -778,6 +801,14 @@ pub fn close(self: *Surface) void { self.rt_surface.close(self.needsConfirmQuit()); } +/// Returns a mailbox that can be used to send messages to this surface. +inline fn surfaceMailbox(self: *Surface) Mailbox { + return .{ + .surface = self, + .app = .{ .rt_app = self.rt_app, .mailbox = &self.app.mailbox }, + }; +} + /// Forces the surface to render. This is useful for when the surface /// is in the middle of animation (such as a resize, etc.) or when /// the render timer is managed manually by the apprt. @@ -1043,6 +1074,22 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { log.warn("apprt failed to notify command finish={}", .{err}); }; }, + + .search_total => |v| { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .search_total, + .{ .total = v }, + ); + }, + + .search_selected => |v| { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .search_selected, + .{ .selected = v }, + ); + }, } } @@ -1301,6 +1348,118 @@ fn reportColorScheme(self: *Surface, force: bool) void { self.io.queueMessage(.{ .write_stable = output }, .unlocked); } +fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void { + // IMPORTANT: This function is run on the SEARCH THREAD! It is NOT SAFE + // to access anything other than values that never change on the surface. + // The surface is guaranteed to be valid for the lifetime of the search + // thread. + const self: *Surface = @ptrCast(@alignCast(ud.?)); + self.searchCallback_(event) catch |err| { + log.warn("error in search callback err={}", .{err}); + }; +} + +fn searchCallback_( + self: *Surface, + event: terminal.search.Thread.Event, +) !void { + // NOTE: This runs on the search thread. + + switch (event) { + .viewport_matches => |matches_unowned| { + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + const matches = try alloc.dupe(terminal.highlight.Flattened, matches_unowned); + for (matches) |*m| m.* = try m.clone(alloc); + + _ = self.renderer_thread.mailbox.push( + .{ .search_viewport_matches = .{ + .arena = arena, + .matches = matches, + } }, + .forever, + ); + try self.renderer_thread.wakeup.notify(); + }, + + .selected_match => |selected_| { + if (selected_) |sel| { + // Copy the flattened match. + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + const match = try sel.highlight.clone(alloc); + + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = .{ + .arena = arena, + .match = match, + } }, + .forever, + ); + + // Send the selected index to the surface mailbox + _ = self.surfaceMailbox().push( + .{ .search_selected = sel.idx }, + .forever, + ); + } else { + // Reset our selected match + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = null }, + .forever, + ); + + // Reset the selected index + _ = self.surfaceMailbox().push( + .{ .search_selected = null }, + .forever, + ); + } + + try self.renderer_thread.wakeup.notify(); + }, + + .total_matches => |total| { + _ = self.surfaceMailbox().push( + .{ .search_total = total }, + .forever, + ); + }, + + // When we quit, tell our renderer to reset any search state. + .quit => { + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = null }, + .forever, + ); + _ = self.renderer_thread.mailbox.push( + .{ .search_viewport_matches = .{ + .arena = .init(self.alloc), + .matches = &.{}, + } }, + .forever, + ); + try self.renderer_thread.wakeup.notify(); + + // Reset search totals in the surface + _ = self.surfaceMailbox().push( + .{ .search_total = null }, + .forever, + ); + _ = self.surfaceMailbox().push( + .{ .search_selected = null }, + .forever, + ); + }, + + // Unhandled, so far. + .complete => {}, + } +} + /// Call this when modifiers change. This is safe to call even if modifiers /// match the previous state. /// @@ -3305,6 +3464,8 @@ fn mouseReport( .five => 65, .six => 66, .seven => 67, + .eight => 128, + .nine => 129, else => return, // unsupported }; } @@ -4103,7 +4264,7 @@ fn osc8URI(self: *Surface, pin: terminal.Pin) ?[]const u8 { const cell = pin.rowAndCell().cell; const link_id = page.lookupHyperlink(cell) orelse return null; const entry = page.hyperlink_set.get(page.memory, link_id); - return entry.uri.offset.ptr(page.memory)[0..entry.uri.len]; + return entry.uri.slice(page.memory); } pub fn mousePressureCallback( @@ -4770,6 +4931,96 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.terminal.fullReset(); }, + .start_search => { + // To save resources, we don't actually start a search here, + // we just notify the apprt. The real thread will start when + // the first needles are set. + return try self.rt_app.performAction( + .{ .surface = self }, + .start_search, + .{ .needle = "" }, + ); + }, + + .end_search => { + // We only return that this was performed if we actually + // stopped a search, but we also send the apprt end_search so + // that GUIs can clean up stale stuff. + const performed = self.search != null; + + if (self.search) |*s| { + s.deinit(); + self.search = null; + } + + _ = try self.rt_app.performAction( + .{ .surface = self }, + .end_search, + {}, + ); + + return performed; + }, + + .search => |text| search: { + const s: *Search = if (self.search) |*s| s else init: { + // If we're stopping the search and we had no prior search, + // then there is nothing to do. + if (text.len == 0) return false; + + // We need to assign directly to self.search because we need + // a stable pointer back to the thread state. + self.search = .{ + .state = try .init(self.alloc, .{ + .mutex = self.renderer_state.mutex, + .terminal = self.renderer_state.terminal, + .event_cb = &searchCallback, + .event_userdata = self, + }), + .thread = undefined, + }; + const s: *Search = &self.search.?; + errdefer s.state.deinit(); + + s.thread = try .spawn( + .{}, + terminal.search.Thread.threadMain, + .{&s.state}, + ); + s.thread.setName("search") catch {}; + + break :init s; + }; + + // Zero-length text means stop searching. + if (text.len == 0) { + s.deinit(); + self.search = null; + break :search; + } + + _ = s.state.mailbox.push( + .{ .change_needle = try .init( + self.alloc, + text, + ) }, + .forever, + ); + s.state.wakeup.notify() catch {}; + }, + + .navigate_search => |nav| { + const s: *Search = if (self.search) |*s| s else return false; + _ = s.state.mailbox.push( + .{ .select = switch (nav) { + .next => .next, + .previous => .prev, + } }, + .forever, + ); + s.state.wakeup.notify() catch {}; + }, + .copy_to_clipboard => |format| { // We can read from the renderer state without holding // the lock because only we will write to this field. diff --git a/src/apprt.zig b/src/apprt.zig index dbd62fbfb..c467f1801 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -8,8 +8,6 @@ //! The goal is to have different implementations share as much of the core //! logic as possible, and to only reach out to platform-specific implementation //! code when absolutely necessary. -const std = @import("std"); -const builtin = @import("builtin"); const build_config = @import("build_config.zig"); const structs = @import("apprt/structs.zig"); diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 1c286e98d..00bf8685a 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -1,6 +1,6 @@ const std = @import("std"); const build_config = @import("../build_config.zig"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); const input = @import("../input.zig"); @@ -301,6 +301,18 @@ pub const Action = union(Key) { /// A command has finished, command_finished: CommandFinished, + /// Start the search overlay with an optional initial needle. + start_search: StartSearch, + + /// End the search overlay, clearing the search state and hiding it. + end_search, + + /// The total number of matches found by the search. + search_total: SearchTotal, + + /// The currently selected search match index (1-based). + search_selected: SearchSelected, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -358,6 +370,10 @@ pub const Action = union(Key) { progress_report, show_on_screen_keyboard, command_finished, + start_search, + end_search, + search_total, + search_selected, }; /// Sync with: ghostty_action_u @@ -770,3 +786,48 @@ pub const CommandFinished = struct { }; } }; + +pub const StartSearch = struct { + needle: [:0]const u8, + + // Sync with: ghostty_action_start_search_s + pub const C = extern struct { + needle: [*:0]const u8, + }; + + pub fn cval(self: StartSearch) C { + return .{ + .needle = self.needle.ptr, + }; + } +}; + +pub const SearchTotal = struct { + total: ?usize, + + // Sync with: ghostty_action_search_total_s + pub const C = extern struct { + total: isize, + }; + + pub fn cval(self: SearchTotal) C { + return .{ + .total = if (self.total) |t| @intCast(t) else -1, + }; + } +}; + +pub const SearchSelected = struct { + selected: ?usize, + + // Sync with: ghostty_action_search_selected_s + pub const C = extern struct { + selected: isize, + }; + + pub fn cval(self: SearchSelected) C { + return .{ + .selected = if (self.selected) |s| @intCast(s) else -1, + }; + } +}; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 25d09271e..da7a585a5 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -6,7 +6,7 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const objc = @import("objc"); const apprt = @import("../apprt.zig"); diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index aa2404566..415d3773d 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -1,5 +1,3 @@ -const internal_os = @import("../os/main.zig"); - // The required comptime API for any apprt. pub const App = @import("gtk/App.zig"); pub const Surface = @import("gtk/Surface.zig"); diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 4d2006fbb..6c7310339 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -5,18 +5,13 @@ const App = @This(); const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; -const adw = @import("adw"); -const gio = @import("gio"); const apprt = @import("../../apprt.zig"); const configpkg = @import("../../config.zig"); -const internal_os = @import("../../os/main.zig"); const Config = configpkg.Config; const CoreApp = @import("../../App.zig"); const Application = @import("class/application.zig").Application; const Surface = @import("Surface.zig"); -const gtk_version = @import("gtk_version.zig"); -const adw_version = @import("adw_version.zig"); const ipcNewWindow = @import("ipc/new_window.zig").newWindow; const log = std.log.scoped(.gtk); diff --git a/src/apprt/gtk/build/blueprint.zig b/src/apprt/gtk/build/blueprint.zig index f25e7e1f9..4920ce6f8 100644 --- a/src/apprt/gtk/build/blueprint.zig +++ b/src/apprt/gtk/build/blueprint.zig @@ -11,6 +11,20 @@ pub const c = @cImport({ @cInclude("adwaita.h"); }); +pub const blueprint_compiler_help = + \\ + \\When building from a Git checkout, Ghostty requires + \\version {f} or newer of `blueprint-compiler` as a + \\build-time dependency. Please install it, ensure that it + \\is available on your PATH, and then retry building Ghostty. + \\See `HACKING.md` for more details. + \\ + \\This message should *not* appear for normal users, who + \\should build Ghostty from official release tarballs instead. + \\Please consult https://ghostty.org/docs/install/build for + \\more information on the recommended build instructions. +; + const adwaita_version = std.SemanticVersion{ .major = c.ADW_MAJOR_VERSION, .minor = c.ADW_MINOR_VERSION, @@ -79,13 +93,9 @@ pub fn main() !void { error.FileNotFound => { std.debug.print( \\`blueprint-compiler` not found. - \\ - \\Ghostty requires version {f} or newer of - \\`blueprint-compiler` as a build-time dependency starting - \\from version 1.2. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - \\ - , .{required_blueprint_version}); + ++ blueprint_compiler_help, + .{required_blueprint_version}, + ); std.posix.exit(1); }, else => return err, @@ -103,13 +113,9 @@ pub fn main() !void { if (version.order(required_blueprint_version) == .lt) { std.debug.print( \\`blueprint-compiler` is the wrong version. - \\ - \\Ghostty requires version {f} or newer of - \\`blueprint-compiler` as a build-time dependency starting - \\from version 1.2. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - \\ - , .{required_blueprint_version}); + ++ blueprint_compiler_help, + .{required_blueprint_version}, + ); std.posix.exit(1); } } @@ -144,13 +150,9 @@ pub fn main() !void { error.FileNotFound => { std.debug.print( \\`blueprint-compiler` not found. - \\ - \\Ghostty requires version {f} or newer of - \\`blueprint-compiler` as a build-time dependency starting - \\from version 1.2. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - \\ - , .{required_blueprint_version}); + ++ blueprint_compiler_help, + .{required_blueprint_version}, + ); std.posix.exit(1); }, else => return err, diff --git a/src/apprt/gtk/build/gresource.zig b/src/apprt/gtk/build/gresource.zig index cc701d7c2..c77579aab 100644 --- a/src/apprt/gtk/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -43,6 +43,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "inspector-widget" }, .{ .major = 1, .minor = 5, .name = "inspector-window" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, + .{ .major = 1, .minor = 2, .name = "search-overlay" }, .{ .major = 1, .minor = 5, .name = "split-tree" }, .{ .major = 1, .minor = 5, .name = "split-tree-split" }, .{ .major = 1, .minor = 2, .name = "surface" }, diff --git a/src/apprt/gtk/cgroup.zig b/src/apprt/gtk/cgroup.zig index 697126798..654c1e1ac 100644 --- a/src/apprt/gtk/cgroup.zig +++ b/src/apprt/gtk/cgroup.zig @@ -1,14 +1,11 @@ /// Contains all the logic for putting the Ghostty process and /// each individual surface into its own cgroup. const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const gio = @import("gio"); const glib = @import("glib"); -const gobject = @import("gobject"); -const App = @import("App.zig"); const internal_os = @import("../../os/main.zig"); const log = std.log.scoped(.gtk_systemd_cgroup); diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 2f0a7c5c3..69576bf00 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1,7 +1,6 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const builtin = @import("builtin"); const adw = @import("adw"); const gdk = @import("gdk"); const gio = @import("gio"); @@ -9,7 +8,6 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); -const build_config = @import("../../../build_config.zig"); const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const cgroup = @import("../cgroup.zig"); @@ -729,6 +727,11 @@ pub const Application = extern struct { .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), + .start_search => Action.startSearch(target), + .end_search => Action.endSearch(target), + .search_total => Action.searchTotal(target, value), + .search_selected => Action.searchSelected(target, value), + // Unimplemented .secure_input, .close_all_windows, @@ -2339,6 +2342,34 @@ const Action = struct { } } + pub fn startSearch(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchActive(true), + } + } + + pub fn endSearch(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchActive(false), + } + } + + pub fn searchTotal(target: apprt.Target, value: apprt.action.SearchTotal) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchTotal(value.total), + } + } + + pub fn searchSelected(target: apprt.Target, value: apprt.action.SearchSelected) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchSelected(value.selected), + } + } + pub fn setTitle( target: apprt.Target, value: apprt.action.SetTitle, diff --git a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig index d3d1b30b1..d44d38a35 100644 --- a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig +++ b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const adw = @import("adw"); const glib = @import("glib"); const gobject = @import("gobject"); diff --git a/src/apprt/gtk/class/close_confirmation_dialog.zig b/src/apprt/gtk/class/close_confirmation_dialog.zig index e806eb354..5919f9c94 100644 --- a/src/apprt/gtk/class/close_confirmation_dialog.zig +++ b/src/apprt/gtk/class/close_confirmation_dialog.zig @@ -1,13 +1,10 @@ const std = @import("std"); -const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); const gresource = @import("../build/gresource.zig"); const i18n = @import("../../../os/main.zig").i18n; -const adw_version = @import("../adw_version.zig"); const Common = @import("../class.zig").Common; -const Config = @import("config.zig").Config; const Dialog = @import("dialog.zig").Dialog; const log = std.log.scoped(.gtk_ghostty_close_confirmation_dialog); diff --git a/src/apprt/gtk/class/config.zig b/src/apprt/gtk/class/config.zig index eadd3b7b8..9a705d356 100644 --- a/src/apprt/gtk/class/config.zig +++ b/src/apprt/gtk/class/config.zig @@ -1,7 +1,5 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const adw = @import("adw"); -const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); diff --git a/src/apprt/gtk/class/config_errors_dialog.zig b/src/apprt/gtk/class/config_errors_dialog.zig index fc76bc268..46d5fe621 100644 --- a/src/apprt/gtk/class/config_errors_dialog.zig +++ b/src/apprt/gtk/class/config_errors_dialog.zig @@ -1,10 +1,8 @@ const std = @import("std"); -const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); const gresource = @import("../build/gresource.zig"); -const adw_version = @import("../adw_version.zig"); const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; const Dialog = @import("dialog.zig").Dialog; diff --git a/src/apprt/gtk/class/debug_warning.zig b/src/apprt/gtk/class/debug_warning.zig index edda6659b..0ad320337 100644 --- a/src/apprt/gtk/class/debug_warning.zig +++ b/src/apprt/gtk/class/debug_warning.zig @@ -1,9 +1,7 @@ -const std = @import("std"); const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); -const build_config = @import("../../../build_config.zig"); const adw_version = @import("../adw_version.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; diff --git a/src/apprt/gtk/class/dialog.zig b/src/apprt/gtk/class/dialog.zig index 41a1988ba..5bc3cdfa5 100644 --- a/src/apprt/gtk/class/dialog.zig +++ b/src/apprt/gtk/class/dialog.zig @@ -3,10 +3,8 @@ const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); -const gresource = @import("../build/gresource.zig"); const adw_version = @import("../adw_version.zig"); const Common = @import("../class.zig").Common; -const Config = @import("config.zig").Config; const log = std.log.scoped(.gtk_ghostty_dialog); diff --git a/src/apprt/gtk/class/global_shortcuts.zig b/src/apprt/gtk/class/global_shortcuts.zig index 9c67be7c1..57652916a 100644 --- a/src/apprt/gtk/class/global_shortcuts.zig +++ b/src/apprt/gtk/class/global_shortcuts.zig @@ -1,14 +1,11 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const adw = @import("adw"); const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); -const gtk = @import("gtk"); const Binding = @import("../../../input.zig").Binding; -const gresource = @import("../build/gresource.zig"); const key = @import("../key.zig"); const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; diff --git a/src/apprt/gtk/class/imgui_widget.zig b/src/apprt/gtk/class/imgui_widget.zig index 854dec20b..ef1ca05c9 100644 --- a/src/apprt/gtk/class/imgui_widget.zig +++ b/src/apprt/gtk/class/imgui_widget.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const cimgui = @import("cimgui"); const gl = @import("opengl"); diff --git a/src/apprt/gtk/class/inspector_widget.zig b/src/apprt/gtk/class/inspector_widget.zig index 4321dcd57..046cd2174 100644 --- a/src/apprt/gtk/class/inspector_widget.zig +++ b/src/apprt/gtk/class/inspector_widget.zig @@ -1,6 +1,5 @@ const std = @import("std"); -const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); diff --git a/src/apprt/gtk/class/inspector_window.zig b/src/apprt/gtk/class/inspector_window.zig index 701718229..739e75691 100644 --- a/src/apprt/gtk/class/inspector_window.zig +++ b/src/apprt/gtk/class/inspector_window.zig @@ -2,15 +2,12 @@ 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; diff --git a/src/apprt/gtk/class/resize_overlay.zig b/src/apprt/gtk/class/resize_overlay.zig index f6e0c1442..e14f15636 100644 --- a/src/apprt/gtk/class/resize_overlay.zig +++ b/src/apprt/gtk/class/resize_overlay.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const adw = @import("adw"); const glib = @import("glib"); const gobject = @import("gobject"); diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig new file mode 100644 index 000000000..4936cd967 --- /dev/null +++ b/src/apprt/gtk/class/search_overlay.zig @@ -0,0 +1,486 @@ +const std = @import("std"); +const adw = @import("adw"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gdk = @import("gdk"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_search_overlay); + +/// The overlay that shows the current size while a surface is resizing. +/// This can be used generically to show pretty much anything with a +/// disappearing overlay, but we have no other use at this point so it +/// is named specifically for what it does. +/// +/// General usage: +/// +/// 1. Add it to an overlay +/// 2. Set the label with `setLabel` +/// 3. Schedule to show it with `schedule` +/// +/// Set any properties to change the behavior. +pub const SearchOverlay = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySearchOverlay", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const active = struct { + pub const name = "active"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getSearchActive, + .setter = setSearchActive, + }, + ), + }, + ); + }; + + pub const @"search-total" = struct { + pub const name = "search-total"; + const impl = gobject.ext.defineProperty( + name, + Self, + u64, + .{ + .default = 0, + .minimum = 0, + .maximum = std.math.maxInt(u64), + .accessor = gobject.ext.typedAccessor( + Self, + u64, + .{ .getter = getSearchTotal }, + ), + }, + ); + }; + + pub const @"has-search-total" = struct { + pub const name = "has-search-total"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasSearchTotal }, + ), + }, + ); + }; + + pub const @"search-selected" = struct { + pub const name = "search-selected"; + const impl = gobject.ext.defineProperty( + name, + Self, + u64, + .{ + .default = 0, + .minimum = 0, + .maximum = std.math.maxInt(u64), + .accessor = gobject.ext.typedAccessor( + Self, + u64, + .{ .getter = getSearchSelected }, + ), + }, + ); + }; + + pub const @"has-search-selected" = struct { + pub const name = "has-search-selected"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasSearchSelected }, + ), + }, + ); + }; + + pub const @"halign-target" = struct { + pub const name = "halign-target"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .end, + .accessor = C.privateShallowFieldAccessor("halign_target"), + }, + ); + }; + + pub const @"valign-target" = struct { + pub const name = "valign-target"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .start, + .accessor = C.privateShallowFieldAccessor("valign_target"), + }, + ); + }; + }; + + pub const signals = struct { + /// Emitted when the search is stopped (e.g., Escape pressed). + pub const @"stop-search" = struct { + pub const name = "stop-search"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted when the search text changes (debounced). + pub const @"search-changed" = struct { + pub const name = "search-changed"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{?[*:0]const u8}, + void, + ); + }; + + /// Emitted when navigating to the next match. + pub const @"next-match" = struct { + pub const name = "next-match"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted when navigating to the previous match. + pub const @"previous-match" = struct { + pub const name = "previous-match"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + }; + + const Private = struct { + /// The search entry widget. + search_entry: *gtk.SearchEntry, + + /// True when a search is active, meaning we should show the overlay. + active: bool = false, + + /// Total number of search matches (null means unknown/none). + search_total: ?usize = null, + + /// Currently selected match index (null means none selected). + search_selected: ?usize = null, + + /// Target horizontal alignment for the overlay. + halign_target: gtk.Align = .end, + + /// Target vertical alignment for the overlay. + valign_target: gtk.Align = .start, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + /// Grab focus on the search entry and select all text. + pub fn grabFocus(self: *Self) void { + const priv = self.private(); + _ = priv.search_entry.as(gtk.Widget).grabFocus(); + + // Select all text in the search entry field. -1 is distance from + // the end, causing the entire text to be selected. + priv.search_entry.as(gtk.Editable).selectRegion(0, -1); + } + + // Set active status, and update search on activation + fn setSearchActive(self: *Self, active: bool) void { + const priv = self.private(); + if (!priv.active and active) { + const text = priv.search_entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); + } + priv.active = active; + } + + /// Set the total number of search matches. + pub fn setSearchTotal(self: *Self, total: ?usize) void { + const priv = self.private(); + const had_total = priv.search_total != null; + if (priv.search_total == total) return; + priv.search_total = total; + self.as(gobject.Object).notifyByPspec(properties.@"search-total".impl.param_spec); + if (had_total != (total != null)) { + self.as(gobject.Object).notifyByPspec(properties.@"has-search-total".impl.param_spec); + } + } + + /// Set the currently selected match index. + pub fn setSearchSelected(self: *Self, selected: ?usize) void { + const priv = self.private(); + const had_selected = priv.search_selected != null; + if (priv.search_selected == selected) return; + priv.search_selected = selected; + self.as(gobject.Object).notifyByPspec(properties.@"search-selected".impl.param_spec); + if (had_selected != (selected != null)) { + self.as(gobject.Object).notifyByPspec(properties.@"has-search-selected".impl.param_spec); + } + } + + fn getSearchActive(self: *Self) bool { + return self.private().active; + } + + fn getSearchTotal(self: *Self) u64 { + return self.private().search_total orelse 0; + } + + fn getHasSearchTotal(self: *Self) bool { + return self.private().search_total != null; + } + + fn getSearchSelected(self: *Self) u64 { + return self.private().search_selected orelse 0; + } + + fn getHasSearchSelected(self: *Self) bool { + return self.private().search_selected != null; + } + + fn closureMatchLabel( + _: *Self, + has_selected: bool, + selected: u64, + has_total: bool, + total: u64, + ) callconv(.c) ?[*:0]const u8 { + if (!has_total or total == 0) return glib.ext.dupeZ(u8, "0/0"); + var buf: [32]u8 = undefined; + const label = std.fmt.bufPrintZ(&buf, "{}/{}", .{ + if (has_selected) selected + 1 else 0, + total, + }) catch return null; + return glib.ext.dupeZ(u8, label); + } + + //--------------------------------------------------------------- + // Template callbacks + + fn searchChanged(entry: *gtk.SearchEntry, self: *Self) callconv(.c) void { + const text = entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); + } + + // NOTE: The callbacks below use anyopaque for the first parameter + // because they're shared with multiple widgets in the template. + + fn stopSearch(_: *anyopaque, self: *Self) callconv(.c) void { + signals.@"stop-search".impl.emit(self, null, .{}, null); + } + + fn nextMatch(_: *anyopaque, self: *Self) callconv(.c) void { + signals.@"next-match".impl.emit(self, null, .{}, null); + } + + fn previousMatch(_: *anyopaque, self: *Self) callconv(.c) void { + signals.@"previous-match".impl.emit(self, null, .{}, null); + } + + fn searchEntryKeyPressed( + _: *gtk.EventControllerKey, + keyval: c_uint, + _: c_uint, + gtk_mods: gdk.ModifierType, + self: *Self, + ) callconv(.c) c_int { + if (keyval == gdk.KEY_Return or keyval == gdk.KEY_KP_Enter) { + if (gtk_mods.shift_mask) { + signals.@"previous-match".impl.emit(self, null, .{}, null); + } else { + signals.@"next-match".impl.emit(self, null, .{}, null); + } + + return 1; + } + + return 0; + } + + fn onDragEnd( + _: *gtk.GestureDrag, + offset_x: f64, + offset_y: f64, + self: *Self, + ) callconv(.c) void { + // On drag end, we want to move our halign/valign if we crossed + // the midpoint on either axis. This lets the search overlay be + // moved to different corners of the parent container. + + const priv = self.private(); + const widget = self.as(gtk.Widget); + const parent = widget.getParent() orelse return; + + const parent_width: f64 = @floatFromInt(parent.getAllocatedWidth()); + const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight()); + const self_width: f64 = @floatFromInt(widget.getAllocatedWidth()); + const self_height: f64 = @floatFromInt(widget.getAllocatedHeight()); + + const self_x: f64 = if (priv.halign_target == .start) 0 else parent_width - self_width; + const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height; + + const new_x = self_x + offset_x + (self_width / 2); + const new_y = self_y + offset_y + (self_height / 2); + + const new_halign: gtk.Align = if (new_x > parent_width / 2) .end else .start; + const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start; + + var changed = false; + if (new_halign != priv.halign_target) { + priv.halign_target = new_halign; + self.as(gobject.Object).notifyByPspec(properties.@"halign-target".impl.param_spec); + changed = true; + } + if (new_valign != priv.valign_target) { + priv.valign_target = new_valign; + self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec); + changed = true; + } + + if (changed) self.as(gtk.Widget).queueResize(); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + _ = priv; + + 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(); + _ = priv; + + 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 = 2, + .name = "search-overlay", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("search_entry", .{}); + + // Template Callbacks + class.bindTemplateCallback("stop_search", &stopSearch); + class.bindTemplateCallback("search_changed", &searchChanged); + class.bindTemplateCallback("match_label_closure", &closureMatchLabel); + class.bindTemplateCallback("next_match", &nextMatch); + class.bindTemplateCallback("previous_match", &previousMatch); + class.bindTemplateCallback("search_entry_key_pressed", &searchEntryKeyPressed); + class.bindTemplateCallback("on_drag_end", &onDragEnd); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.active.impl, + properties.@"search-total".impl, + properties.@"has-search-total".impl, + properties.@"search-selected".impl, + properties.@"has-search-selected".impl, + properties.@"halign-target".impl, + properties.@"valign-target".impl, + }); + + // Signals + signals.@"stop-search".impl.register(.{}); + signals.@"search-changed".impl.register(.{}); + signals.@"next-match".impl.register(.{}); + signals.@"previous-match".impl.register(.{}); + + // 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/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 1c901b1bb..48656c951 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -1,6 +1,5 @@ const std = @import("std"); -const build_config = @import("../../../build_config.zig"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const adw = @import("adw"); const gio = @import("gio"); @@ -8,17 +7,11 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); -const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); -const input = @import("../../../input.zig"); -const CoreSurface = @import("../../../Surface.zig"); -const gtk_version = @import("../gtk_version.zig"); -const adw_version = @import("../adw_version.zig"); const ext = @import("../ext.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; const WeakRef = @import("../weak_ref.zig").WeakRef; -const Config = @import("config.zig").Config; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const Surface = @import("surface.zig").Surface; diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 6b29c3e12..a4d2d6696 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const adw = @import("adw"); const gdk = @import("gdk"); @@ -19,18 +19,17 @@ const terminal = @import("../../../terminal/main.zig"); const CoreSurface = @import("../../../Surface.zig"); const gresource = @import("../build/gresource.zig"); const ext = @import("../ext.zig"); -const adw_version = @import("../adw_version.zig"); const gtk_key = @import("../key.zig"); const ApprtSurface = @import("../Surface.zig"); const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; const Config = @import("config.zig").Config; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; +const SearchOverlay = @import("search_overlay.zig").SearchOverlay; 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 i18n = @import("../../../os/i18n.zig"); @@ -551,6 +550,9 @@ pub const Surface = extern struct { /// The resize overlay resize_overlay: *ResizeOverlay, + /// The search overlay + search_overlay: *SearchOverlay, + /// The apprt Surface. rt_surface: ApprtSurface = undefined, @@ -1465,6 +1467,10 @@ pub const Surface = extern struct { // EnvMap is a bit annoying so I'm punting it. if (ext.getAncestor(Window, self.as(gtk.Widget))) |window| { try window.winproto().addSubprocessEnv(&env); + + if (window.isQuickTerminal()) { + try env.put("GHOSTTY_QUICK_TERMINAL", "1"); + } } return env; @@ -1949,6 +1955,29 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"error".impl.param_spec); } + pub fn setSearchActive(self: *Self, active: bool) void { + const priv = self.private(); + var value = gobject.ext.Value.newFrom(active); + defer value.unset(); + gobject.Object.setProperty( + priv.search_overlay.as(gobject.Object), + SearchOverlay.properties.active.name, + &value, + ); + + if (active) { + priv.search_overlay.grabFocus(); + } + } + + pub fn setSearchTotal(self: *Self, total: ?usize) void { + self.private().search_overlay.setSearchTotal(total); + } + + pub fn setSearchSelected(self: *Self, selected: ?usize) void { + self.private().search_overlay.setSearchSelected(selected); + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, @@ -3168,6 +3197,35 @@ pub const Surface = extern struct { self.setTitleOverride(if (title.len == 0) null else title); } + fn searchStop(_: *SearchOverlay, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.end_search) catch |err| { + log.warn("unable to perform end_search action err={}", .{err}); + }; + _ = self.private().gl_area.as(gtk.Widget).grabFocus(); + } + + fn searchChanged(_: *SearchOverlay, needle: ?[*:0]const u8, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .search = std.mem.sliceTo(needle orelse "", 0) }) catch |err| { + log.warn("unable to perform search action err={}", .{err}); + }; + } + + fn searchNextMatch(_: *SearchOverlay, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .navigate_search = .next }) catch |err| { + log.warn("unable to perform navigate_search action err={}", .{err}); + }; + } + + fn searchPreviousMatch(_: *SearchOverlay, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .navigate_search = .previous }) catch |err| { + log.warn("unable to perform navigate_search action err={}", .{err}); + }; + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -3182,6 +3240,7 @@ pub const Surface = extern struct { fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(ResizeOverlay); + gobject.ext.ensureType(SearchOverlay); gobject.ext.ensureType(ChildExited); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), @@ -3201,6 +3260,7 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("error_page", .{}); class.bindTemplateChildPrivate("progress_bar_overlay", .{}); class.bindTemplateChildPrivate("resize_overlay", .{}); + class.bindTemplateChildPrivate("search_overlay", .{}); class.bindTemplateChildPrivate("terminal_page", .{}); class.bindTemplateChildPrivate("drop_target", .{}); class.bindTemplateChildPrivate("im_context", .{}); @@ -3238,6 +3298,10 @@ pub const Surface = extern struct { class.bindTemplateCallback("notify_vadjustment", &propVAdjustment); class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown); + class.bindTemplateCallback("search_stop", &searchStop); + class.bindTemplateCallback("search_changed", &searchChanged); + class.bindTemplateCallback("search_next_match", &searchNextMatch); + class.bindTemplateCallback("search_previous_match", &searchPreviousMatch); // Properties gobject.ext.registerProperties(class, &.{ @@ -3369,12 +3433,16 @@ const Clipboard = struct { // text/plain type. The default charset when there is // none is ASCII, and lots of things look for UTF-8 // specifically. + // The specs are not clear about the order here, but + // some clients apparently pick the first match in the + // order we set here then garble up bare 'text/plain' + // with non-ASCII UTF-8 content, so offer UTF-8 first. // // Note that under X11, GTK automatically adds the // UTF8_STRING atom when this is present. const text_provider_atoms = [_][:0]const u8{ - "text/plain", "text/plain;charset=utf-8", + "text/plain", }; var text_providers: [text_provider_atoms.len]*gdk.ContentProvider = undefined; for (text_provider_atoms, 0..) |atom, j| { diff --git a/src/apprt/gtk/class/surface_child_exited.zig b/src/apprt/gtk/class/surface_child_exited.zig index bdee81397..d7dd41bcb 100644 --- a/src/apprt/gtk/class/surface_child_exited.zig +++ b/src/apprt/gtk/class/surface_child_exited.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const adw = @import("adw"); const glib = @import("glib"); const gobject = @import("gobject"); diff --git a/src/apprt/gtk/class/surface_scrolled_window.zig b/src/apprt/gtk/class/surface_scrolled_window.zig index 3095b4c78..488fdb3f4 100644 --- a/src/apprt/gtk/class/surface_scrolled_window.zig +++ b/src/apprt/gtk/class/surface_scrolled_window.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const adw = @import("adw"); const gobject = @import("gobject"); const gtk = @import("gtk"); diff --git a/src/apprt/gtk/class/surface_title_dialog.zig b/src/apprt/gtk/class/surface_title_dialog.zig index 6d3bf33de..aa1d1a153 100644 --- a/src/apprt/gtk/class/surface_title_dialog.zig +++ b/src/apprt/gtk/class/surface_title_dialog.zig @@ -6,7 +6,6 @@ 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; diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index c9928be8b..c8b5607a6 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -1,19 +1,13 @@ const std = @import("std"); -const build_config = @import("../../../build_config.zig"); -const assert = std.debug.assert; const adw = @import("adw"); const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); -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"); const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 8c79d6b75..c691b84a6 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -1,6 +1,6 @@ const std = @import("std"); const build_config = @import("../../../build_config.zig"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const adw = @import("adw"); const gdk = @import("gdk"); const gio = @import("gio"); @@ -28,7 +28,6 @@ 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); diff --git a/src/apprt/gtk/css/style.css b/src/apprt/gtk/css/style.css index 5620c9ca4..938d23ad8 100644 --- a/src/apprt/gtk/css/style.css +++ b/src/apprt/gtk/css/style.css @@ -34,6 +34,18 @@ label.url-overlay.right { border-radius: 6px 0px 0px 0px; } +/* + * GhosttySurface search overlay + */ +.search-overlay { + padding: 6px 8px; + margin: 8px; + border-radius: 8px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + /* * GhosttySurface resize overlay */ diff --git a/src/apprt/gtk/ext.zig b/src/apprt/gtk/ext.zig index 18587d9ca..9b1eeecc6 100644 --- a/src/apprt/gtk/ext.zig +++ b/src/apprt/gtk/ext.zig @@ -4,10 +4,9 @@ //! helpers. const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; -const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); diff --git a/src/apprt/gtk/ext/actions.zig b/src/apprt/gtk/ext/actions.zig index 344c08e05..3232bc18b 100644 --- a/src/apprt/gtk/ext/actions.zig +++ b/src/apprt/gtk/ext/actions.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const testing = std.testing; const gio = @import("gio"); diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index bf0f0e2f6..19bdc8315 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const build_options = @import("build_options"); const gdk = @import("gdk"); const glib = @import("glib"); diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp new file mode 100644 index 000000000..6523d4149 --- /dev/null +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -0,0 +1,94 @@ +using Gtk 4.0; +using Gdk 4.0; +using Adw 1; + +template $GhosttySearchOverlay: Adw.Bin { + visible: bind template.active; + halign-target: end; + valign-target: start; + halign: bind template.halign-target; + valign: bind template.valign-target; + + GestureDrag { + button: 1; + propagation-phase: capture; + drag-end => $on_drag_end(); + } + + Adw.Bin { + Box container { + styles [ + "background", + "search-overlay", + ] + + orientation: horizontal; + spacing: 6; + + SearchEntry search_entry { + placeholder-text: _("Find…"); + width-chars: 20; + hexpand: true; + stop-search => $stop_search(); + search-changed => $search_changed(); + next-match => $next_match(); + previous-match => $previous_match(); + + EventControllerKey { + // We need this so we capture before the SearchEntry. + propagation-phase: capture; + key-pressed => $search_entry_key_pressed(); + } + } + + Label { + styles [ + "dim-label", + ] + + label: bind $match_label_closure(template.has-search-selected, template.search-selected, template.has-search-total, template.search-total) as ; + width-chars: 6; + xalign: 1.0; + } + + Box button_box { + orientation: horizontal; + spacing: 1; + + styles [ + "linked", + ] + + Button prev_button { + icon-name: "go-up-symbolic"; + tooltip-text: _("Previous Match"); + clicked => $next_match(); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + + Button next_button { + icon-name: "go-down-symbolic"; + tooltip-text: _("Next Match"); + clicked => $previous_match(); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + } + + Button close_button { + icon-name: "window-close-symbolic"; + tooltip-text: _("Close"); + clicked => $stop_search(); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + } + } +} diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 0596bf15d..4ebfeabfb 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -41,6 +41,34 @@ Overlay terminal_page { halign: start; has-arrow: false; } + + EventControllerFocus { + enter => $focus_enter(); + leave => $focus_leave(); + } + + EventControllerKey { + key-pressed => $key_pressed(); + key-released => $key_released(); + } + + EventControllerScroll { + scroll => $scroll(); + scroll-begin => $scroll_begin(); + scroll-end => $scroll_end(); + flags: both_axes; + } + + EventControllerMotion { + motion => $mouse_motion(); + leave => $mouse_leave(); + } + + GestureClick { + pressed => $mouse_down(); + released => $mouse_up(); + button: 0; + } }; [overlay] @@ -64,6 +92,10 @@ Overlay terminal_page { reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as ; transition-type: crossfade; transition-duration: 500; + // Revealers take up the full size, we need this to not capture events. + can-focus: false; + can-target: false; + focusable: false; Box bell_overlay { styles [ @@ -115,12 +147,26 @@ Overlay terminal_page { label: bind template.mouse-hover-url; } + [overlay] + $GhosttySearchOverlay search_overlay { + stop-search => $search_stop(); + search-changed => $search_changed(); + next-match => $search_next_match(); + previous-match => $search_previous_match(); + } + [overlay] // Apply unfocused-split-fill and unfocused-split-opacity to current surface // this is only applied when a tab has more than one surface Revealer { reveal-child: bind $should_unfocused_split_be_shown(template.focused, template.is-split) as ; transition-duration: 0; + // This is all necessary so that the Revealer itself doesn't override + // any input events from the other overlays. Namely, if you don't have + // these then the search overlay won't get mouse events. + can-focus: false; + can-target: false; + focusable: false; DrawingArea { styles [ @@ -129,35 +175,6 @@ Overlay terminal_page { } } - // Event controllers for interactivity - EventControllerFocus { - enter => $focus_enter(); - leave => $focus_leave(); - } - - EventControllerKey { - key-pressed => $key_pressed(); - key-released => $key_released(); - } - - EventControllerMotion { - motion => $mouse_motion(); - leave => $mouse_leave(); - } - - EventControllerScroll { - scroll => $scroll(); - scroll-begin => $scroll_begin(); - scroll-end => $scroll_end(); - flags: both_axes; - } - - GestureClick { - pressed => $mouse_down(); - released => $mouse_up(); - button: 0; - } - DropTarget drop_target { drop => $drop(); actions: copy; diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 5837e3e5e..ec02fbee5 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -1,7 +1,6 @@ //! Wayland protocol implementation for the Ghostty GTK apprt. const std = @import("std"); const Allocator = std.mem.Allocator; -const build_options = @import("build_options"); const gdk = @import("gdk"); const gdk_wayland = @import("gdk_wayland"); diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 8956a29ed..9dc273563 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -1,10 +1,8 @@ //! X11 window protocol implementation for the Ghostty GTK apprt. const std = @import("std"); const builtin = @import("builtin"); -const build_options = @import("build_options"); const Allocator = std.mem.Allocator; -const adw = @import("adw"); const gdk = @import("gdk"); const gdk_x11 = @import("gdk_x11"); const glib = @import("glib"); diff --git a/src/apprt/ipc.zig b/src/apprt/ipc.zig index 6be8bdf07..a6e8412e0 100644 --- a/src/apprt/ipc.zig +++ b/src/apprt/ipc.zig @@ -2,7 +2,7 @@ //! process. const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; pub const Errors = error{ /// The IPC failed. If a function returns this error, it's expected that diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index b71bf1e6e..45a847493 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -6,15 +6,15 @@ const build_config = @import("../build_config.zig"); const App = @import("../App.zig"); const Surface = @import("../Surface.zig"); const renderer = @import("../renderer.zig"); -const termio = @import("../termio.zig"); const terminal = @import("../terminal/main.zig"); const Config = @import("../config.zig").Config; +const MessageData = @import("../datastruct/main.zig").MessageData; /// The message types that can be sent to a single surface. pub const Message = union(enum) { /// Represents a write request. Magic number comes from the max size /// we want this union to be. - pub const WriteReq = termio.MessageData(u8, 255); + pub const WriteReq = MessageData(u8, 255); /// Set the title of the surface. /// TODO: we should change this to a "WriteReq" style structure in @@ -107,6 +107,12 @@ pub const Message = union(enum) { /// The scrollbar state changed for the surface. scrollbar: terminal.Scrollbar, + /// Search progress update + search_total: ?usize, + + /// Selected search index change + search_selected: ?usize, + pub const ReportTitleStyle = enum { csi_21_t, diff --git a/src/benchmark/CodepointWidth.zig b/src/benchmark/CodepointWidth.zig index 552df8d1f..effabb036 100644 --- a/src/benchmark/CodepointWidth.zig +++ b/src/benchmark/CodepointWidth.zig @@ -107,7 +107,7 @@ fn stepWcwidth(ptr: *anyopaque) Benchmark.Error!void { const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -134,7 +134,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -166,7 +166,7 @@ fn stepSimd(ptr: *anyopaque) Benchmark.Error!void { const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/GraphemeBreak.zig b/src/benchmark/GraphemeBreak.zig index a1b3380f0..328d63a75 100644 --- a/src/benchmark/GraphemeBreak.zig +++ b/src/benchmark/GraphemeBreak.zig @@ -90,7 +90,7 @@ fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { const self: *GraphemeBreak = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -113,7 +113,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const self: *GraphemeBreak = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/IsSymbol.zig b/src/benchmark/IsSymbol.zig index c4667b333..4fbffd1ec 100644 --- a/src/benchmark/IsSymbol.zig +++ b/src/benchmark/IsSymbol.zig @@ -4,7 +4,6 @@ const IsSymbol = @This(); const std = @import("std"); -const builtin = @import("builtin"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Benchmark = @import("Benchmark.zig"); @@ -90,7 +89,7 @@ fn stepUucode(ptr: *anyopaque) Benchmark.Error!void { const self: *IsSymbol = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -117,7 +116,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const self: *IsSymbol = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/ScreenClone.zig b/src/benchmark/ScreenClone.zig new file mode 100644 index 000000000..380379bc3 --- /dev/null +++ b/src/benchmark/ScreenClone.zig @@ -0,0 +1,196 @@ +//! This benchmark tests the performance of the Screen.clone +//! function. This is useful because it is one of the primary lock +//! holders that impact IO performance when the renderer is active. +//! We do this very frequently. +const ScreenClone = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Terminal = terminalpkg.Terminal; + +const log = std.log.scoped(.@"terminal-stream-bench"); + +opts: Options, +terminal: Terminal, + +pub const Options = struct { + /// The type of codepoint width calculation to use. + mode: Mode = .clone, + + /// The size of the terminal. This affects benchmarking when + /// dealing with soft line wrapping and the memory impact + /// of page sizes. + @"terminal-rows": u16 = 80, + @"terminal-cols": u16 = 120, + + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, then we will + /// do nothing (benchmark is a noop). It'd be more unixy to + /// use stdin by default but I find that a hanging CLI command + /// with no interaction is a bit annoying. + /// + /// This will be used to initialize the terminal screen state before + /// cloning. This data can switch to alt screen if it wants. The time + /// to read this is not part of the benchmark. + data: ?[]const u8 = null, +}; + +pub const Mode = enum { + /// The baseline mode copies the screen by value. + noop, + + /// Full clone + clone, + + /// RenderState rather than a screen clone. + render, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*ScreenClone { + const ptr = try alloc.create(ScreenClone); + errdefer alloc.destroy(ptr); + + ptr.* = .{ + .opts = opts, + .terminal = try .init(alloc, .{ + .rows = opts.@"terminal-rows", + .cols = opts.@"terminal-cols", + }), + }; + + return ptr; +} + +pub fn destroy(self: *ScreenClone, alloc: Allocator) void { + self.terminal.deinit(alloc); + alloc.destroy(self); +} + +pub fn benchmark(self: *ScreenClone) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .clone => stepClone, + .render => stepRender, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // Always reset our terminal state + self.terminal.fullReset(); + + // Force a style on every single row, which + var s = self.terminal.vtStream(); + defer s.deinit(); + s.nextSlice("\x1b[48;2;20;40;60m") catch unreachable; + for (0..self.terminal.rows - 1) |_| s.nextSlice("hello\r\n") catch unreachable; + s.nextSlice("hello") catch unreachable; + + // Setup our terminal state + const data_f: std.fs.File = (options.dataFile( + self.opts.data, + ) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }) orelse return; + + var stream = self.terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var f_reader = data_f.reader(&read_buf); + const r = &f_reader.interface; + + var buf: [4096]u8 = undefined; + while (true) { + const n = r.readSliceShort(&buf) catch { + log.warn("error reading data file err={?}", .{f_reader.err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + stream.nextSlice(buf[0..n]) catch |err| { + log.warn("error processing data file chunk err={}", .{err}); + return error.BenchmarkFailed; + }; + } +} + +fn teardown(ptr: *anyopaque) void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + _ = self; +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // We loop because its so fast that a single benchmark run doesn't + // properly capture our speeds. + for (0..1000) |_| { + const s: terminalpkg.Screen = self.terminal.screens.active.*; + std.mem.doNotOptimizeAway(s); + } +} + +fn stepClone(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // We loop because its so fast that a single benchmark run doesn't + // properly capture our speeds. + for (0..1000) |_| { + const s: *terminalpkg.Screen = self.terminal.screens.active; + const copy = s.clone( + s.alloc, + .{ .viewport = .{} }, + null, + ) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(copy); + + // Note: we purposely do not free memory because we don't want + // to benchmark that. We'll free when the benchmark exits. + } +} + +fn stepRender(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // We do this once out of the loop because a significant slowdown + // on the first run is allocation. After that first run, even with + // a full rebuild, it is much faster. Let's ignore that first run + // slowdown. + const alloc = self.terminal.screens.active.alloc; + var state: terminalpkg.RenderState = .empty; + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + + // We loop because its so fast that a single benchmark run doesn't + // properly capture our speeds. + for (0..1000) |_| { + // Forces a full rebuild because it thinks our screen changed + state.screen = .alternate; + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(state); + + // Note: we purposely do not free memory because we don't want + // to benchmark that. We'll free when the benchmark exits. + } +} diff --git a/src/benchmark/TerminalParser.zig b/src/benchmark/TerminalParser.zig index f13b44552..e00081763 100644 --- a/src/benchmark/TerminalParser.zig +++ b/src/benchmark/TerminalParser.zig @@ -75,7 +75,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { // the benchmark results and... I know writing this that we // aren't currently IO bound. const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index 0a993c42b..7cf28217f 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -114,7 +114,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { // aren't currently IO bound. const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); const r = &f_reader.interface; diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 3b1c905eb..816ecd3f6 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -8,6 +8,7 @@ const cli = @import("../cli.zig"); pub const Action = enum { @"codepoint-width", @"grapheme-break", + @"screen-clone", @"terminal-parser", @"terminal-stream", @"is-symbol", @@ -22,6 +23,7 @@ pub const Action = enum { /// See TerminalStream for an example. pub fn Struct(comptime action: Action) type { return switch (action) { + .@"screen-clone" => @import("ScreenClone.zig"), .@"terminal-stream" => @import("TerminalStream.zig"), .@"codepoint-width" => @import("CodepointWidth.zig"), .@"grapheme-break" => @import("GraphemeBreak.zig"), diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index 3a59125fc..5673044f2 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -4,6 +4,7 @@ pub const CApi = @import("CApi.zig"); pub const TerminalStream = @import("TerminalStream.zig"); pub const CodepointWidth = @import("CodepointWidth.zig"); pub const GraphemeBreak = @import("GraphemeBreak.zig"); +pub const ScreenClone = @import("ScreenClone.zig"); pub const TerminalParser = @import("TerminalParser.zig"); pub const IsSymbol = @import("IsSymbol.zig"); diff --git a/src/build/GhosttyBench.zig b/src/build/GhosttyBench.zig index c9cd5dd33..27dda8809 100644 --- a/src/build/GhosttyBench.zig +++ b/src/build/GhosttyBench.zig @@ -2,7 +2,6 @@ const GhosttyBench = @This(); const std = @import("std"); -const Config = @import("Config.zig"); const SharedDeps = @import("SharedDeps.zig"); steps: []*std.Build.Step.Compile, diff --git a/src/build/GhosttyDist.zig b/src/build/GhosttyDist.zig index 092322689..600aa4883 100644 --- a/src/build/GhosttyDist.zig +++ b/src/build/GhosttyDist.zig @@ -170,11 +170,11 @@ pub const Resource = struct { /// Returns true if the dist path exists at build time. pub fn exists(self: *const Resource, b: *std.Build) bool { - if (std.fs.accessAbsolute(b.pathFromRoot(self.dist), .{})) { + if (b.build_root.handle.access(self.dist, .{})) { // If we have a ".git" directory then we're a git checkout // and we never want to use the dist path. This shouldn't happen // so show a warning to the user. - if (std.fs.accessAbsolute(b.pathFromRoot(".git"), .{})) { + if (b.build_root.handle.access(".git", .{})) { std.log.warn( "dist resource '{s}' should not be in a git checkout", .{self.dist}, diff --git a/src/build/GhosttyFrameData.zig b/src/build/GhosttyFrameData.zig index 7193162bd..8469759f9 100644 --- a/src/build/GhosttyFrameData.zig +++ b/src/build/GhosttyFrameData.zig @@ -3,8 +3,6 @@ const GhosttyFrameData = @This(); const std = @import("std"); -const Config = @import("Config.zig"); -const SharedDeps = @import("SharedDeps.zig"); const DistResource = @import("GhosttyDist.zig").Resource; /// The output path for the compressed framedata zig file diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index d1ab5d1ba..aae8ace19 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -3,11 +3,7 @@ const GhosttyLibVt = @This(); const std = @import("std"); const assert = std.debug.assert; const RunStep = std.Build.Step.Run; -const Config = @import("Config.zig"); const GhosttyZig = @import("GhosttyZig.zig"); -const SharedDeps = @import("SharedDeps.zig"); -const LibtoolStep = @import("LibtoolStep.zig"); -const LipoStep = @import("LipoStep.zig"); /// The step that generates the file. step: *std.Build.Step, diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 1ac8fe2a9..6f857655b 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -1,15 +1,14 @@ const GhosttyResources = @This(); const std = @import("std"); -const builtin = @import("builtin"); const assert = std.debug.assert; -const buildpkg = @import("main.zig"); const Config = @import("Config.zig"); const RunStep = std.Build.Step.Run; +const SharedDeps = @import("SharedDeps.zig"); steps: []*std.Build.Step, -pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { +pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !GhosttyResources { var steps: std.ArrayList(*std.Build.Step) = .empty; errdefer steps.deinit(b.allocator); @@ -26,6 +25,8 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { }); build_data_exe.linkLibC(); + deps.help_strings.addImport(build_data_exe); + // Terminfo terminfo: { const os_tag = cfg.target.result.os.tag; diff --git a/src/build/GhosttyWebdata.zig b/src/build/GhosttyWebdata.zig index 145bb91fa..e29b20c25 100644 --- a/src/build/GhosttyWebdata.zig +++ b/src/build/GhosttyWebdata.zig @@ -3,7 +3,6 @@ const GhosttyWebdata = @This(); const std = @import("std"); -const Config = @import("Config.zig"); const SharedDeps = @import("SharedDeps.zig"); steps: []*std.Build.Step, diff --git a/src/build/UnicodeTables.zig b/src/build/UnicodeTables.zig index aba3e8f24..17a839eaf 100644 --- a/src/build/UnicodeTables.zig +++ b/src/build/UnicodeTables.zig @@ -1,7 +1,6 @@ const UnicodeTables = @This(); const std = @import("std"); -const Config = @import("Config.zig"); /// The exe. props_exe: *std.Build.Step.Compile, diff --git a/src/build/uucode_config.zig b/src/build/uucode_config.zig index 277e3cb49..2fadbdb78 100644 --- a/src/build/uucode_config.zig +++ b/src/build/uucode_config.zig @@ -19,23 +19,23 @@ fn computeWidth( _ = backing; _ = tracking; - // Emoji modifiers are technically width 0 because they're joining - // points. But we handle joining via grapheme break and don't use width - // there. If a emoji modifier is standalone, we want it to take up - // two columns. - if (data.is_emoji_modifier) { - assert(data.wcwidth == 0); - data.wcwidth = 2; - return; + // This condition is to get the previous behavior of uucode's `wcwidth`, + // returning the width of a code point in a grapheme cluster but with the + // exception to treat emoji modifiers as width 2 so they can be displayed + // in isolation. PRs to follow will take advantage of the new uucode + // `wcwidth_standalone` vs `wcwidth_zero_in_grapheme` split. + if (data.wcwidth_zero_in_grapheme and !data.is_emoji_modifier) { + data.width = 0; + } else { + data.width = @min(2, data.wcwidth_standalone); } - - data.width = @intCast(@min(2, @max(0, data.wcwidth))); } const width = config.Extension{ .inputs = &.{ + "wcwidth_standalone", + "wcwidth_zero_in_grapheme", "is_emoji_modifier", - "wcwidth", }, .compute = &computeWidth, .fields = &.{ @@ -90,10 +90,7 @@ pub const tables = [_]config.Table{ width.field("width"), d.field("grapheme_break"), is_symbol.field("is_symbol"), - d.field("is_emoji_modifier"), - d.field("is_emoji_modifier_base"), - d.field("is_emoji_vs_text"), - d.field("is_emoji_vs_emoji"), + d.field("is_emoji_vs_base"), }, }, }; diff --git a/src/build/webgen/main_actions.zig b/src/build/webgen/main_actions.zig index 85357b972..b0de6537d 100644 --- a/src/build/webgen/main_actions.zig +++ b/src/build/webgen/main_actions.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const help_strings = @import("help_strings"); const helpgen_actions = @import("../../input/helpgen_actions.zig"); pub fn main() !void { diff --git a/src/build_config.zig b/src/build_config.zig index 0d294c69e..c19f7372b 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -9,7 +9,6 @@ const assert = std.debug.assert; const apprt = @import("apprt.zig"); const font = @import("font/main.zig"); const rendererpkg = @import("renderer.zig"); -const WasmTarget = @import("os/wasm/target.zig").Target; const BuildConfig = @import("build/Config.zig"); pub const ReleaseChannel = BuildConfig.ReleaseChannel; diff --git a/src/cli/args.zig b/src/cli/args.zig index 76026fbf2..43a15ca06 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -1,6 +1,6 @@ const std = @import("std"); const mem = std.mem; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const diags = @import("diagnostics.zig"); diff --git a/src/cli/boo.zig b/src/cli/boo.zig index f96fd6282..2834eadbd 100644 --- a/src/cli/boo.zig +++ b/src/cli/boo.zig @@ -3,7 +3,6 @@ const builtin = @import("builtin"); const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; const Allocator = std.mem.Allocator; -const help_strings = @import("help_strings"); const vaxis = @import("vaxis"); const framedata = @import("framedata").compressed; diff --git a/src/cli/diagnostics.zig b/src/cli/diagnostics.zig index 2af8bb4f8..7f4dcc45e 100644 --- a/src/cli/diagnostics.zig +++ b/src/cli/diagnostics.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const build_config = @import("../build_config.zig"); diff --git a/src/cli/edit_config.zig b/src/cli/edit_config.zig index 37f961a44..056aecc0d 100644 --- a/src/cli/edit_config.zig +++ b/src/cli/edit_config.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const args = @import("args.zig"); const Allocator = std.mem.Allocator; const Action = @import("ghostty.zig").Action; diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 63184ddfb..45a80723e 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -1,11 +1,9 @@ const std = @import("std"); -const inputpkg = @import("../input.zig"); const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; const Config = @import("../config/Config.zig"); const themepkg = @import("../config/theme.zig"); const tui = @import("tui.zig"); -const internal_os = @import("../os/main.zig"); const global_state = &@import("../global.zig").state; const vaxis = @import("vaxis"); @@ -180,7 +178,13 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { return 0; } + var theme_config = try Config.default(gpa_alloc); + defer theme_config.deinit(); for (themes.items) |theme| { + try theme_config.loadFile(theme_config._arena.?.allocator(), theme.path); + if (!shouldIncludeTheme(opts.color, theme_config)) { + continue; + } if (opts.path) try stdout.print("{s} ({t}) {s}\n", .{ theme.theme, theme.location, theme.path }) else diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index fe043569f..62620ecb0 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -5,7 +5,7 @@ const DiskCache = @This(); const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const internal_os = @import("../../os/main.zig"); const xdg = internal_os.xdg; diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index 9434e9771..d3ee658af 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -1,7 +1,6 @@ const std = @import("std"); const fs = std.fs; const Allocator = std.mem.Allocator; -const xdg = @import("../os/xdg.zig"); const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; pub const Entry = @import("ssh-cache/Entry.zig"); diff --git a/src/cli/validate_config.zig b/src/cli/validate_config.zig index 55d861402..5586cf29f 100644 --- a/src/cli/validate_config.zig +++ b/src/cli/validate_config.zig @@ -3,7 +3,6 @@ const Allocator = std.mem.Allocator; const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; const Config = @import("../config.zig").Config; -const cli = @import("../cli.zig"); pub const Options = struct { /// The path of the config file to validate. If this isn't specified, diff --git a/src/config/CApi.zig b/src/config/CApi.zig index bdc59797a..a970a8d33 100644 --- a/src/config/CApi.zig +++ b/src/config/CApi.zig @@ -1,6 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; -const cli = @import("../cli.zig"); const inputpkg = @import("../input.zig"); const state = &@import("../global.zig").state; const c = @import("../main_c.zig"); diff --git a/src/config/ClipboardCodepointMap.zig b/src/config/ClipboardCodepointMap.zig index 354db10d9..fbe539127 100644 --- a/src/config/ClipboardCodepointMap.zig +++ b/src/config/ClipboardCodepointMap.zig @@ -4,7 +4,7 @@ const ClipboardCodepointMap = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; // To ease our usage later, we map it directly to formatter entries. diff --git a/src/config/Config.zig b/src/config/Config.zig index 6469c333e..82e81a01f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -13,7 +13,7 @@ const Config = @This(); const std = @import("std"); const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const global_state = &@import("../global.zig").state; @@ -29,8 +29,6 @@ const formatterpkg = @import("formatter.zig"); const themepkg = @import("theme.zig"); const url = @import("url.zig"); const Key = @import("key.zig").Key; -const KeyValue = @import("key.zig").Value; -const ErrorList = @import("ErrorList.zig"); const MetricModifier = fontpkg.Metrics.Modifier; const help_strings = @import("help_strings"); pub const Command = @import("command.zig").Command; @@ -978,6 +976,35 @@ palette: Palette = .{}, /// Available since: 1.1.0 @"split-divider-color": ?Color = null, +/// The foreground and background color for search matches. This only applies +/// to non-focused search matches, also known as candidate matches. +/// +/// Valid values: +/// +/// - Hex (`#RRGGBB` or `RRGGBB`) +/// - Named X11 color +/// - "cell-foreground" to match the cell foreground color +/// - "cell-background" to match the cell background color +/// +/// The default value is black text on a golden yellow background. +@"search-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, +@"search-background": TerminalColor = .{ .color = .{ .r = 0xFF, .g = 0xE0, .b = 0x82 } }, + +/// The foreground and background color for the currently selected search match. +/// This is the focused match that will be jumped to when using next/previous +/// search navigation. +/// +/// Valid values: +/// +/// - Hex (`#RRGGBB` or `RRGGBB`) +/// - Named X11 color +/// - "cell-foreground" to match the cell foreground color +/// - "cell-background" to match the cell background color +/// +/// The default value is black text on a soft peach background. +@"search-selected-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, +@"search-selected-background": TerminalColor = .{ .color = .{ .r = 0xF2, .g = 0xA5, .b = 0x7E } }, + /// The command to run, usually a shell. If this is not an absolute path, it'll /// be looked up in the `PATH`. If this is not set, a default will be looked up /// from your system. The rules for the default lookup are: @@ -1765,7 +1792,7 @@ keybind: Keybinds = .{}, /// Note: any font available on the system may be used, this font is not /// required to be a fixed-width font. /// -/// Available since: 1.1.0 (on GTK) +/// Available since: 1.0.0 on macOS, 1.1.0 on GTK @"window-title-font-family": ?[:0]const u8 = null, /// The text that will be displayed in the subtitle of the window. Valid values: @@ -5203,6 +5230,7 @@ pub const ColorList = struct { ) Allocator.Error!Self { return .{ .colors = try self.colors.clone(alloc), + .colors_c = try self.colors_c.clone(alloc), }; } @@ -5281,6 +5309,26 @@ pub const ColorList = struct { try p.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); try std.testing.expectEqualSlices(u8, "a = #000000,#ffffff\n", buf.written()); } + + test "clone" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var source: Self = .{}; + try source.parseCLI(alloc, "#ff0000,#00ff00,#0000ff"); + + const cloned = try source.clone(alloc); + + try testing.expect(source.equal(cloned)); + try testing.expectEqual(source.colors_c.items.len, cloned.colors_c.items.len); + for (source.colors_c.items, cloned.colors_c.items) |src_c, clone_c| { + try testing.expectEqual(src_c.r, clone_c.r); + try testing.expectEqual(src_c.g, clone_c.g); + try testing.expectEqual(src_c.b, clone_c.b); + } + } }; /// Palette is the 256 color palette for 256-color mode. This is still @@ -6050,6 +6098,20 @@ pub const Keybinds = struct { .{ .jump_to_prompt = 1 }, ); + // Search + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .ctrl = true, .shift = true } }, + .start_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .escape } }, + .end_search, + .{ .performable = true }, + ); + // Inspector, matching Chromium try self.set.put( alloc, @@ -6353,6 +6415,38 @@ pub const Keybinds = struct { .{ .jump_to_prompt = 1 }, ); + // Search + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true } }, + .start_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } }, + .end_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .escape } }, + .end_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true } }, + .{ .navigate_search = .next }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true, .shift = true } }, + .{ .navigate_search = .previous }, + .{ .performable = true }, + ); + // Inspector, matching Chromium try self.set.put( alloc, diff --git a/src/config/command.zig b/src/config/command.zig index e0cdc641b..7e16ad5c7 100644 --- a/src/config/command.zig +++ b/src/config/command.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const formatterpkg = @import("formatter.zig"); diff --git a/src/config/conditional.zig b/src/config/conditional.zig index 5d5d204c5..fdc285a22 100644 --- a/src/config/conditional.zig +++ b/src/config/conditional.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; /// Conditionals in Ghostty configuration are based on a static, typed diff --git a/src/config/edit.zig b/src/config/edit.zig index 6087106e7..8cedc47a5 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -1,9 +1,8 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const internal_os = @import("../os/main.zig"); const file_load = @import("file_load.zig"); /// The path to the configuration that should be opened for editing. diff --git a/src/config/file_load.zig b/src/config/file_load.zig index 8dbefeea8..7885de32a 100644 --- a/src/config/file_load.zig +++ b/src/config/file_load.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const internal_os = @import("../os/main.zig"); diff --git a/src/config/io.zig b/src/config/io.zig index 9d9a127e8..a1e433b6a 100644 --- a/src/config/io.zig +++ b/src/config/io.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const string = @import("string.zig"); diff --git a/src/config/path.zig b/src/config/path.zig index aeba69b94..ebcd084d2 100644 --- a/src/config/path.zig +++ b/src/config/path.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; diff --git a/src/config/theme.zig b/src/config/theme.zig index b1188a5c4..7ba6e5885 100644 --- a/src/config/theme.zig +++ b/src/config/theme.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const global_state = &@import("../global.zig").state; const internal_os = @import("../os/main.zig"); diff --git a/src/datastruct/blocking_queue.zig b/src/datastruct/blocking_queue.zig index c95b6b96a..3185d98d1 100644 --- a/src/datastruct/blocking_queue.zig +++ b/src/datastruct/blocking_queue.zig @@ -2,8 +2,6 @@ //! between threads. const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; /// Returns a blocking queue implementation for type T. diff --git a/src/datastruct/cache_table.zig b/src/datastruct/cache_table.zig index fbfb30d71..491723989 100644 --- a/src/datastruct/cache_table.zig +++ b/src/datastruct/cache_table.zig @@ -1,7 +1,7 @@ const fastmem = @import("../fastmem.zig"); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; /// An associative data structure used for efficiently storing and /// retrieving values which are able to be recomputed if necessary. diff --git a/src/datastruct/circ_buf.zig b/src/datastruct/circ_buf.zig index 646a00940..0caa9e85d 100644 --- a/src/datastruct/circ_buf.zig +++ b/src/datastruct/circ_buf.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const fastmem = @import("../fastmem.zig"); @@ -91,15 +91,24 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { self.full = self.head == self.tail; } - /// Append a slice to the buffer. If the buffer cannot fit the - /// entire slice then an error will be returned. It is up to the - /// caller to rotate the circular buffer if they want to overwrite - /// the oldest data. - pub fn appendSlice( + /// Append a single value to the buffer, assuming there is capacity. + pub fn appendAssumeCapacity(self: *Self, v: T) void { + assert(!self.full); + self.storage[self.head] = v; + self.head += 1; + if (self.head >= self.storage.len) self.head = 0; + self.full = self.head == self.tail; + } + + /// Append a slice to the buffer. + pub fn appendSliceAssumeCapacity( self: *Self, slice: []const T, - ) Allocator.Error!void { - const storage = self.getPtrSlice(self.len(), slice.len); + ) void { + const storage = self.getPtrSlice( + self.len(), + slice.len, + ); fastmem.copy(T, storage[0], slice[0..storage[0].len]); fastmem.copy(T, storage[1], slice[storage[0].len..]); } @@ -456,7 +465,7 @@ test "CircBuf append slice" { var buf = try Buf.init(alloc, 5); defer buf.deinit(alloc); - try buf.appendSlice("hello"); + buf.appendSliceAssumeCapacity("hello"); { var it = buf.iterator(.forward); try testing.expect(it.next().?.* == 'h'); @@ -486,7 +495,7 @@ test "CircBuf append slice with wrap" { try testing.expect(!buf.full); try testing.expectEqual(@as(usize, 2), buf.len()); - try buf.appendSlice("AB"); + buf.appendSliceAssumeCapacity("AB"); { var it = buf.iterator(.forward); try testing.expect(it.next().?.* == 0); diff --git a/src/datastruct/lru.zig b/src/datastruct/lru.zig index 1c6df69ce..83d2cf8ef 100644 --- a/src/datastruct/lru.zig +++ b/src/datastruct/lru.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; /// Create a HashMap for a key type that can be automatically hashed. diff --git a/src/datastruct/main.zig b/src/datastruct/main.zig index 14ee0e504..64a29269e 100644 --- a/src/datastruct/main.zig +++ b/src/datastruct/main.zig @@ -13,6 +13,7 @@ pub const BlockingQueue = blocking_queue.BlockingQueue; pub const CacheTable = cache_table.CacheTable; pub const CircBuf = circ_buf.CircBuf; pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList; +pub const MessageData = @import("message_data.zig").MessageData; pub const SegmentedPool = segmented_pool.SegmentedPool; pub const SplitTree = split_tree.SplitTree; diff --git a/src/datastruct/message_data.zig b/src/datastruct/message_data.zig new file mode 100644 index 000000000..3e5cdae66 --- /dev/null +++ b/src/datastruct/message_data.zig @@ -0,0 +1,124 @@ +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; + +/// Creates a union that can be used to accommodate data that fit within an array, +/// are a stable pointer, or require deallocation. This is helpful for thread +/// messaging utilities. +pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type { + return union(enum) { + pub const Self = @This(); + + pub const Small = struct { + pub const Max = small_size; + pub const Array = [Max]Elem; + pub const Len = std.math.IntFittingRange(0, small_size); + data: Array = undefined, + len: Len = 0, + }; + + pub const Alloc = struct { + alloc: Allocator, + data: []Elem, + }; + + pub const Stable = []const Elem; + + /// A small write where the data fits into this union size. + small: Small, + + /// A stable pointer so we can just pass the slice directly through. + /// This is useful i.e. for const data. + stable: Stable, + + /// Allocated and must be freed with the provided allocator. This + /// should be rarely used. + alloc: Alloc, + + /// Initializes the union for a given data type. This will + /// attempt to fit into a small value if possible, otherwise + /// will allocate and put into alloc. + /// + /// This can't and will never detect stable pointers. + pub fn init(alloc: Allocator, data: anytype) !Self { + switch (@typeInfo(@TypeOf(data))) { + .pointer => |info| { + assert(info.size == .slice); + assert(info.child == Elem); + + // If it fits in our small request, do that. + if (data.len <= Small.Max) { + var buf: Small.Array = undefined; + @memcpy(buf[0..data.len], data); + return Self{ + .small = .{ + .data = buf, + .len = @intCast(data.len), + }, + }; + } + + // Otherwise, allocate + const buf = try alloc.dupe(Elem, data); + errdefer alloc.free(buf); + return Self{ + .alloc = .{ + .alloc = alloc, + .data = buf, + }, + }; + }, + + else => unreachable, + } + } + + pub fn deinit(self: Self) void { + switch (self) { + .small, .stable => {}, + .alloc => |v| v.alloc.free(v.data), + } + } + + /// Returns a const slice of the data pointed to by this request. + pub fn slice(self: *const Self) []const Elem { + return switch (self.*) { + .small => |*v| v.data[0..v.len], + .stable => |v| v, + .alloc => |v| v.data, + }; + } + }; +} + +test "MessageData init small" { + const testing = std.testing; + const alloc = testing.allocator; + + const Data = MessageData(u8, 10); + const input = "hello!"; + const io = try Data.init(alloc, @as([]const u8, input)); + try testing.expect(io == .small); +} + +test "MessageData init alloc" { + const testing = std.testing; + const alloc = testing.allocator; + + const Data = MessageData(u8, 10); + const input = "hello! " ** 100; + const io = try Data.init(alloc, @as([]const u8, input)); + try testing.expect(io == .alloc); + io.alloc.alloc.free(io.alloc.data); +} + +test "MessageData small fits non-u8 sized data" { + const testing = std.testing; + const alloc = testing.allocator; + + const len = 500; + const Data = MessageData(u8, len); + const input: []const u8 = "X" ** len; + const io = try Data.init(alloc, input); + try testing.expect(io == .small); +} diff --git a/src/datastruct/segmented_pool.zig b/src/datastruct/segmented_pool.zig index 8a91ed745..328eb2398 100644 --- a/src/datastruct/segmented_pool.zig +++ b/src/datastruct/segmented_pool.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const testing = std.testing; diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index eb371187c..be24187f6 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const build_config = @import("../build_config.zig"); const ArenaAllocator = std.heap.ArenaAllocator; const Allocator = std.mem.Allocator; diff --git a/src/extra/fish.zig b/src/extra/fish.zig index 7ffc23093..12343c62f 100644 --- a/src/extra/fish.zig +++ b/src/extra/fish.zig @@ -3,6 +3,7 @@ const std = @import("std"); const Config = @import("../config/Config.zig"); const Action = @import("../cli.zig").ghostty.Action; +const help_strings = @import("help_strings"); /// A fish completions configuration that contains all the available commands /// and options. @@ -81,6 +82,15 @@ fn writeCompletions(writer: *std.Io.Writer) !void { else => {}, } } + + if (@hasDecl(help_strings.Config, field.name)) { + const help = @field(help_strings.Config, field.name); + const desc = getDescription(help); + try writer.writeAll(" -d \""); + try writer.writeAll(desc); + try writer.writeAll("\""); + } + try writer.writeAll("\n"); } @@ -143,3 +153,54 @@ fn writeCompletions(writer: *std.Io.Writer) !void { } } } + +fn getDescription(comptime help: []const u8) []const u8 { + var out: [help.len * 2]u8 = undefined; + var len: usize = 0; + var prev_was_space = false; + + for (help, 0..) |c, i| { + switch (c) { + '.' => { + out[len] = '.'; + len += 1; + + if (i + 1 >= help.len) break; + const next = help[i + 1]; + if (next == ' ' or next == '\n') break; + }, + '\n' => { + if (!prev_was_space and len > 0) { + out[len] = ' '; + len += 1; + prev_was_space = true; + } + }, + '"' => { + out[len] = '\\'; + out[len + 1] = '"'; + len += 2; + prev_was_space = false; + }, + else => { + out[len] = c; + len += 1; + prev_was_space = (c == ' '); + }, + } + } + + return out[0..len]; +} + +test "getDescription" { + const testing = std.testing; + + const input = "First sentence with \"quotes\"\nand newlines. Second sentence."; + const expected = "First sentence with \\\"quotes\\\" and newlines."; + + comptime { + const result = getDescription(input); + try testing.expectEqualStrings(expected, result); + } +} diff --git a/src/extra/sublime.zig b/src/extra/sublime.zig index 4af589b4f..e0deb2fa9 100644 --- a/src/extra/sublime.zig +++ b/src/extra/sublime.zig @@ -1,4 +1,3 @@ -const std = @import("std"); const Config = @import("../config/Config.zig"); const Template = struct { diff --git a/src/fastmem.zig b/src/fastmem.zig index d4a0a7750..a21f84c58 100644 --- a/src/fastmem.zig +++ b/src/fastmem.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; /// Same as @memmove but prefers libc memmove if it is /// available because it is generally much faster?. diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index e2d9a5de2..0648c0edf 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -16,7 +16,7 @@ const Atlas = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const testing = std.testing; const fastmem = @import("../fastmem.zig"); diff --git a/src/font/CodepointMap.zig b/src/font/CodepointMap.zig index 5b174f129..564bf013f 100644 --- a/src/font/CodepointMap.zig +++ b/src/font/CodepointMap.zig @@ -4,7 +4,7 @@ const CodepointMap = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const discovery = @import("discovery.zig"); diff --git a/src/font/Collection.zig b/src/font/Collection.zig index b587245aa..412098f10 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -16,7 +16,6 @@ const Collection = @This(); const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const config = @import("../config.zig"); const comparison = @import("../datastruct/comparison.zig"); diff --git a/src/font/DeferredFace.zig b/src/font/DeferredFace.zig index 290a01d74..e818cca30 100644 --- a/src/font/DeferredFace.zig +++ b/src/font/DeferredFace.zig @@ -7,7 +7,6 @@ const DeferredFace = @This(); const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const fontconfig = @import("fontconfig"); const macos = @import("macos"); diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index ec89763ea..a72cb7bee 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -1,6 +1,7 @@ const Metrics = @This(); const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; /// Recommended cell width and height for a monospace grid using this font. cell_width: u32, @@ -47,8 +48,9 @@ face_width: f64, /// The unrounded face height, used in scaling calculations. face_height: f64, -/// The vertical bearing of face within the pixel-rounded -/// and possibly height-adjusted cell +/// The offset from the bottom of the cell to the bottom +/// of the face's bounding box, based on the rounded and +/// potentially adjusted cell height. face_y: f64, /// Minimum acceptable values for some fields to prevent modifiers @@ -223,25 +225,68 @@ pub const FaceMetrics = struct { /// /// For any nullable options that are not provided, estimates will be used. pub fn calc(face: FaceMetrics) Metrics { - // We use the ceiling of the provided cell width and height to ensure - // that the cell is large enough for the provided size, since we cast - // it to an integer later. + // These are the unrounded advance width and line height values, + // which are retained separately from the rounded cell width and + // height values (below), for calculations that need to know how + // much error there is between the design dimensions of the font + // and the pixel dimensions of our cells. const face_width = face.cell_width; const face_height = face.lineHeight(); - const cell_width = @ceil(face_width); - const cell_height = @ceil(face_height); + + // The cell width and height values need to be integers since they + // represent pixel dimensions of the grid cells in the terminal. + // + // We use @round for the cell width to limit the difference from + // the "true" width value to no more than 0.5px. This is a better + // approximation of the authorial intent of the font than ceiling + // would be, and makes the apparent spacing match better between + // low and high DPI displays. + // + // This does mean that it's possible for a glyph to overflow the + // edge of the cell by a pixel if it has no side bearings, but in + // reality such glyphs are generally meant to connect to adjacent + // glyphs in some way so it's not really an issue. + // + // The same is true for the height. Some fonts are poorly authored + // and have a descender on a normal glyph that extends right up to + // the descent value of the face, and this can result in the glyph + // overflowing the bottom of the cell by a pixel, which isn't good + // but if we try to prevent it by increasing the cell height then + // we get line heights that are too large for most users and even + // more inconsistent across DPIs. + // + // Users who experience such cell-height overflows should: + // + // 1. Nag the font author to either redesign the glyph to not go + // so low, or else adjust the descent value in the metadata. + // + // 2. Add an `adjust-cell-height` entry to their config to give + // the cell enough room for the glyph. + const cell_width = @round(face_width); + const cell_height = @round(face_height); // We split our line gap in two parts, and put half of it on the top // of the cell and the other half on the bottom, so that our text never // bumps up against either edge of the cell vertically. const half_line_gap = face.line_gap / 2; - // Unlike all our other metrics, `cell_baseline` is relative to the - // BOTTOM of the cell. + // NOTE: Unlike all our other metrics, `cell_baseline` is + // relative to the BOTTOM of the cell rather than the top. const face_baseline = half_line_gap - face.descent; - const cell_baseline = @round(face_baseline); + // We calculate the baseline by trying to center the face vertically + // in the pixel-rounded cell height, so that before rounding it will + // be an even distance from the top and bottom of the cell, meaning + // it either sticks out the same amount or is inset the same amount, + // depending on whether the cell height was rounded up or down from + // the line height. We do this by adding half the difference between + // the cell height and the face height. + const cell_baseline = @round(face_baseline - (cell_height - face_height) / 2); - // We keep track of the vertical bearing of the face in the cell + // We keep track of the offset from the bottom of the cell + // to the bottom of the face's "true" bounding box, which at + // this point, since nothing has been scaled yet, is equivalent + // to the offset between the baseline we draw at (cell_baseline) + // and the one the font wants (face_baseline). const face_y = cell_baseline - face_baseline; // We calculate a top_to_baseline to make following calculations simpler. @@ -311,29 +356,48 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { // here is to center the baseline so that text is vertically // centered in the cell. if (comptime tag == .cell_height) { - // We split the difference in half because we want to - // center the baseline in the cell. If the difference - // is odd, one more pixel is added/removed on top than - // on the bottom. - if (new > original) { - const diff = new - original; - const diff_bottom = diff / 2; - const diff_top = diff - diff_bottom; - self.face_y += @floatFromInt(diff_bottom); - self.cell_baseline +|= diff_bottom; - self.underline_position +|= diff_top; - self.strikethrough_position +|= diff_top; - self.overline_position +|= @as(i32, @intCast(diff_top)); - } else { - const diff = original - new; - const diff_bottom = diff / 2; - const diff_top = diff - diff_bottom; - self.face_y -= @floatFromInt(diff_bottom); - self.cell_baseline -|= diff_bottom; - self.underline_position -|= diff_top; - self.strikethrough_position -|= diff_top; - self.overline_position -|= @as(i32, @intCast(diff_top)); - } + const original_f64: f64 = @floatFromInt(original); + const new_f64: f64 = @floatFromInt(new); + const diff = new_f64 - original_f64; + const half_diff = diff / 2.0; + + // If the diff is even, the number of pixels we add + // will be the same for the top and the bottom, but + // if the diff is odd then we want to add the extra + // pixel to the edge of the cell that needs it most. + // + // How much the edge "needs it" depends on whether + // the face is higher or lower than it should be to + // be perfectly centered in the cell. + // + // If the face were perfectly centered then face_y + // would be equal to half of the difference between + // the cell height and the face height. + const position_with_respect_to_center = + self.face_y - (original_f64 - self.face_height) / 2; + + const diff_top, const diff_bottom = + if (position_with_respect_to_center > 0) + // The baseline is higher than it should be, so we + // add the extra to the top, or if it's a negative + // diff it gets added to the bottom because of how + // floor and ceil work. + .{ @ceil(half_diff), @floor(half_diff) } + else + // The baseline is lower than it should be, so we + // add the extra to the bottom, or vice versa for + // negative diffs. + .{ @floor(half_diff), @ceil(half_diff) }; + + // The cell baseline and face_y values are relative to the + // bottom of the cell so we add the bottom diff to them. + addFloatToInt(&self.cell_baseline, diff_bottom); + self.face_y += diff_bottom; + + // These are all relative to the top of the cell. + addFloatToInt(&self.underline_position, diff_top); + addFloatToInt(&self.strikethrough_position, diff_top); + self.overline_position +|= @as(i32, @intFromFloat(diff_top)); } }, inline .icon_height => { @@ -351,6 +415,21 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { self.clamp(); } +/// Helper function for adding an f64 to a u32. +/// +/// Performs saturating addition or subtraction +/// depending on the sign of the provided float. +/// +/// The f64 is asserted to have an integer value. +inline fn addFloatToInt(int: *u32, float: f64) void { + assert(@floor(float) == float); + int.* = + if (float >= 0.0) + int.* +| @as(u32, @intFromFloat(float)) + else + int.* -| @as(u32, @intFromFloat(-float)); +} + /// Clamp all metrics to their allowable range. fn clamp(self: *Metrics) void { inline for (std.meta.fields(Metrics)) |field| { @@ -570,7 +649,9 @@ test "Metrics: adjust cell height smaller" { defer set.deinit(alloc); // We choose numbers such that the subtracted number of pixels is odd, // as that's the case that could most easily have off-by-one errors. - // Here we're removing 25 pixels: 12 on the bottom, 13 on top. + // Here we're removing 25 pixels: 13 on the bottom, 12 on top, split + // that way because we're simulating a face that's 0.33px higher than + // it "should" be (due to rounding). try set.put(alloc, .cell_height, .{ .percent = 0.75 }); var m: Metrics = init(); @@ -580,14 +661,15 @@ test "Metrics: adjust cell height smaller" { m.strikethrough_position = 30; m.overline_position = 0; m.cell_height = 100; + m.face_height = 99.67; m.cursor_height = 100; m.apply(set); - try testing.expectEqual(-11.67, m.face_y); + try testing.expectEqual(-12.67, m.face_y); try testing.expectEqual(@as(u32, 75), m.cell_height); - try testing.expectEqual(@as(u32, 38), m.cell_baseline); - try testing.expectEqual(@as(u32, 42), m.underline_position); - try testing.expectEqual(@as(u32, 17), m.strikethrough_position); - try testing.expectEqual(@as(i32, -13), m.overline_position); + try testing.expectEqual(@as(u32, 37), m.cell_baseline); + try testing.expectEqual(@as(u32, 43), m.underline_position); + try testing.expectEqual(@as(u32, 18), m.strikethrough_position); + try testing.expectEqual(@as(i32, -12), m.overline_position); // Cursor height is separate from cell height and does not follow it. try testing.expectEqual(@as(u32, 100), m.cursor_height); } @@ -600,7 +682,9 @@ test "Metrics: adjust cell height larger" { defer set.deinit(alloc); // We choose numbers such that the added number of pixels is odd, // as that's the case that could most easily have off-by-one errors. - // Here we're adding 75 pixels: 37 on the bottom, 38 on top. + // Here we're adding 75 pixels: 37 on the bottom, 38 on top, split + // that way because we're simulating a face that's 0.33px higher + // than it "should" be (due to rounding). try set.put(alloc, .cell_height, .{ .percent = 1.75 }); var m: Metrics = init(); @@ -610,6 +694,7 @@ test "Metrics: adjust cell height larger" { m.strikethrough_position = 30; m.overline_position = 0; m.cell_height = 100; + m.face_height = 99.67; m.cursor_height = 100; m.apply(set); try testing.expectEqual(37.33, m.face_y); diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 3fd9cf204..52aedefc6 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -19,7 +19,7 @@ const SharedGrid = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const renderer = @import("../renderer.zig"); const font = @import("main.zig"); diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig index 4512e23cc..b832139b3 100644 --- a/src/font/SharedGridSet.zig +++ b/src/font/SharedGridSet.zig @@ -11,7 +11,7 @@ const SharedGridSet = @This(); const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const font = @import("main.zig"); diff --git a/src/font/discovery.zig b/src/font/discovery.zig index 390465916..c419d36a6 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -1,7 +1,6 @@ const std = @import("std"); -const builtin = @import("builtin"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const fontconfig = @import("fontconfig"); const macos = @import("macos"); const opentype = @import("opentype.zig"); @@ -845,15 +844,20 @@ pub const CoreText = struct { // limitation because we may have used that to filter but we // don't want it anymore because it'll restrict the characters // available. - //const desc = self.list.getValueAtIndex(macos.text.FontDescriptor, self.i); const desc = desc: { - const original = self.list[self.i]; - - // For some reason simply copying the attributes and recreating - // the descriptor removes the charset restriction. This is tested. - const attrs = original.copyAttributes(); + // We create a copy, overwriting the character set attribute. + const attrs = try macos.foundation.MutableDictionary.create(0); defer attrs.release(); - break :desc try macos.text.FontDescriptor.createWithAttributes(@ptrCast(attrs)); + + attrs.setValue( + macos.text.FontAttribute.character_set.key(), + macos.c.kCFNull, + ); + + break :desc try macos.text.FontDescriptor.createCopyWithAttributes( + self.list[self.i], + @ptrCast(attrs), + ); }; defer desc.release(); diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 9e7bc4d5d..1d1333882 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const macos = @import("macos"); const harfbuzz = @import("harfbuzz"); @@ -367,9 +367,16 @@ pub const Face = struct { // We don't do this if the glyph has a stretch constraint, // since in that case the position was already calculated with the // new cell width in mind. - if ((constraint.size != .stretch) and (metrics.face_width < cell_width)) { + if (constraint.size != .stretch) { // We add half the difference to re-center. - x += (cell_width - metrics.face_width) / 2; + const dx = (cell_width - metrics.face_width) / 2; + x += dx; + if (dx < 0) { + // For negative diff (cell narrower than advance), we remove the + // integer part and only keep the fractional adjustment needed + // for consistent subpixel positioning. + x -= @trunc(dx); + } } // If this is a bitmap glyph, it will always render as full pixels, diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 95f05881b..a6ef52c39 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -9,14 +9,13 @@ const builtin = @import("builtin"); const freetype = @import("freetype"); const harfbuzz = @import("harfbuzz"); const stb = @import("../../stb/main.zig"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const font = @import("../main.zig"); const Glyph = font.Glyph; const Library = font.Library; const opentype = @import("../opentype.zig"); -const fastmem = @import("../../fastmem.zig"); const quirks = @import("../../quirks.zig"); const config = @import("../../config.zig"); @@ -376,11 +375,15 @@ pub const Face = struct { // If we're gonna be rendering this glyph in monochrome, // then we should use the monochrome hinter as well, or // else it won't look very good at all. - .target_mono = self.load_flags.monochrome, - - // Otherwise we select hinter based on the `light` flag. - .target_normal = !self.load_flags.light and !self.load_flags.monochrome, - .target_light = self.load_flags.light and !self.load_flags.monochrome, + // + // Otherwise if the user asked for light hinting we + // use that, otherwise we just use the normal target. + .target = if (self.load_flags.monochrome) + .mono + else if (self.load_flags.light) + .light + else + .normal, // NO_SVG set to true because we don't currently support rendering // SVG glyphs under FreeType, since that requires bundling another diff --git a/src/font/face/web_canvas.zig b/src/font/face/web_canvas.zig index 7ea2f0426..b4f9f5d5d 100644 --- a/src/font/face/web_canvas.zig +++ b/src/font/face/web_canvas.zig @@ -1,6 +1,5 @@ const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const js = @import("zig-js"); diff --git a/src/font/library.zig b/src/font/library.zig index 43aa101b7..dce6dbd5a 100644 --- a/src/font/library.zig +++ b/src/font/library.zig @@ -2,7 +2,6 @@ //! library implementation(s) require per-process. const std = @import("std"); const Allocator = std.mem.Allocator; -const builtin = @import("builtin"); const options = @import("main.zig").options; const freetype = @import("freetype"); const font = @import("main.zig"); diff --git a/src/font/opentype/head.zig b/src/font/opentype/head.zig index b4ee3ffd4..38284d9cf 100644 --- a/src/font/opentype/head.zig +++ b/src/font/opentype/head.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const sfnt = @import("sfnt.zig"); /// Font Header Table diff --git a/src/font/opentype/hhea.zig b/src/font/opentype/hhea.zig index 300f29c7a..b2b3f3e20 100644 --- a/src/font/opentype/hhea.zig +++ b/src/font/opentype/hhea.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const sfnt = @import("sfnt.zig"); /// Horizontal Header Table diff --git a/src/font/opentype/os2.zig b/src/font/opentype/os2.zig index a18538d5f..1cd11f35e 100644 --- a/src/font/opentype/os2.zig +++ b/src/font/opentype/os2.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const sfnt = @import("sfnt.zig"); pub const FSSelection = packed struct(sfnt.uint16) { diff --git a/src/font/opentype/post.zig b/src/font/opentype/post.zig index ff56a5013..8031a0a4d 100644 --- a/src/font/opentype/post.zig +++ b/src/font/opentype/post.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const sfnt = @import("sfnt.zig"); /// PostScript Table diff --git a/src/font/opentype/sfnt.zig b/src/font/opentype/sfnt.zig index d97d9e2d5..9373cda03 100644 --- a/src/font/opentype/sfnt.zig +++ b/src/font/opentype/sfnt.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; /// 8-bit unsigned integer. diff --git a/src/font/opentype/svg.zig b/src/font/opentype/svg.zig index ff8eeed49..b4d9ccaa2 100644 --- a/src/font/opentype/svg.zig +++ b/src/font/opentype/svg.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const font = @import("../main.zig"); /// SVG glyphs description table. diff --git a/src/font/shape.zig b/src/font/shape.zig index dd0f3dcc5..c96c8df7f 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -1,4 +1,4 @@ -const builtin = @import("builtin"); +const std = @import("std"); const options = @import("main.zig").options; const run = @import("shaper/run.zig"); const feature = @import("shaper/feature.zig"); @@ -72,17 +72,11 @@ pub const RunOptions = struct { /// cached values may be updated during shaping. grid: *SharedGrid, - /// The terminal screen to shape. - screen: *const terminal.Screen, + /// The cells for the row to shape. + cells: std.MultiArrayList(terminal.RenderState.Cell).Slice = .empty, - /// The row within the screen to shape. This row must exist within - /// screen; it is not validated. - row: terminal.Pin, - - /// The selection boundaries. This is used to break shaping on - /// selection boundaries. This can be disabled by setting this to - /// null. - selection: ?terminal.Selection = null, + /// The x boundaries of the selection in this row. + selection: ?[2]u16 = null, /// The cursor position within this row. This is used to break shaping /// on cursor boundaries. This can be disabled by setting this to diff --git a/src/font/shaper/Cache.zig b/src/font/shaper/Cache.zig index bcc0a1d93..2696985a4 100644 --- a/src/font/shaper/Cache.zig +++ b/src/font/shaper/Cache.zig @@ -11,7 +11,6 @@ pub const Cache = @This(); const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const font = @import("../main.zig"); const CacheTable = @import("../../datastruct/main.zig").CacheTable; diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index d73b191b8..97cb5cd89 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -1,13 +1,11 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const macos = @import("macos"); -const trace = @import("tracy").trace; const font = @import("../main.zig"); const os = @import("../../os/main.zig"); const terminal = @import("../../terminal/main.zig"); -const config = @import("../../config.zig"); const Feature = font.shape.Feature; const FeatureList = font.shape.FeatureList; const default_features = font.shape.default_features; @@ -392,6 +390,12 @@ pub const Shaper = struct { self.cell_buf.clearRetainingCapacity(); try self.cell_buf.ensureTotalCapacity(self.alloc, line.getGlyphCount()); + // CoreText, despite our insistence with an enforced embedding level, + // may sometimes output runs that are non-monotonic. In order to fix + // this, we check the run status for each run and if any aren't ltr + // we set this to true, which indicates that we must sort our buffer. + var non_ltr: bool = false; + // CoreText may generate multiple runs even though our input to // CoreText is already split into runs by our own run iterator. // The runs as far as I can tell are always sequential to each @@ -401,6 +405,9 @@ pub const Shaper = struct { for (0..runs.getCount()) |i| { const ctrun = runs.getValueAtIndex(macos.text.Run, i); + const status = ctrun.getStatus(); + if (status.non_monotonic or status.right_to_left) non_ltr = true; + // Get our glyphs and positions const glyphs = ctrun.getGlyphsPtr() orelse try ctrun.getGlyphs(alloc); const advances = ctrun.getAdvancesPtr() orelse try ctrun.getAdvances(alloc); @@ -441,6 +448,25 @@ pub const Shaper = struct { } } + // If our buffer contains some non-ltr sections we need to sort it :/ + if (non_ltr) { + // This is EXCEPTIONALLY rare. Only happens for languages with + // complex shaping which we don't even really support properly + // right now, so are very unlikely to be used heavily by users + // of Ghostty. + @branchHint(.cold); + std.mem.sort( + font.shape.Cell, + self.cell_buf.items, + {}, + struct { + fn lt(_: void, a: font.shape.Cell, b: font.shape.Cell) bool { + return a.x < b.x; + } + }.lt, + ); + } + return self.cell_buf.items; } @@ -597,17 +623,25 @@ test "run iterator" { defer testdata.deinit(); { - // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("ABCD"); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABCD"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -616,15 +650,21 @@ test "run iterator" { // Spaces should be part of a run { - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("ABCD EFG"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABCD EFG"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -633,16 +673,22 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("A😃D"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A😃D"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -652,16 +698,22 @@ test "run iterator" { // Bad ligatures for (&[_][]const u8{ "fl", "fi", "st" }) |bad| { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(bad); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(bad); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -678,14 +730,18 @@ test "run iterator: empty cells with background set" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); - try screen.testWriteString("A"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // Set red background + try s.nextSlice("\x1b[48;2;255;0;0m"); + try s.nextSlice("A"); // Get our first row { - const list_cell = screen.pages.getCell(.{ .active = .{ .x = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 1 } }).?; const cell = list_cell.cell; cell.* = .{ .content_tag = .bg_color_rgb, @@ -693,7 +749,7 @@ test "run iterator: empty cells with background set" { }; } { - const list_cell = screen.pages.getCell(.{ .active = .{ .x = 2 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 2 } }).?; const cell = list_cell.cell; cell.* = .{ .content_tag = .bg_color_rgb, @@ -701,12 +757,15 @@ test "run iterator: empty cells with background set" { }; } + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); { const run = (try it.next(alloc)).?; @@ -731,16 +790,22 @@ test "shape" { buf_idx += try std.unicode.utf8Encode(0x1F3FD, buf[buf_idx..]); // Medium skin tone // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -764,16 +829,22 @@ test "shape nerd fonts" { buf_idx += try std.unicode.utf8Encode(' ', buf[buf_idx..]); // space // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -791,15 +862,21 @@ test "shape inconsolata ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(">="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(">="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -814,15 +891,21 @@ test "shape inconsolata ligs" { } { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("==="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("==="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -845,15 +928,21 @@ test "shape monaspace ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("==="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("==="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -877,15 +966,21 @@ test "shape left-replaced lig in last run" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("!=="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("!=="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -909,15 +1004,21 @@ test "shape left-replaced lig in early run" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("!==X"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("!==X"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); const run = (try it.next(alloc)).?; @@ -938,15 +1039,21 @@ test "shape U+3C9 with JB Mono" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("\u{03C9} foo"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("\u{03C9} foo"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var run_count: usize = 0; @@ -969,15 +1076,21 @@ test "shape emoji width" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("👍"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("👍"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -998,10 +1111,10 @@ test "shape emoji width long" { defer testdata.deinit(); // Make a screen and add a long emoji sequence to it. - var screen = try terminal.Screen.init(alloc, .{ .cols = 30, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); - var page = screen.pages.pages.first.?.data; + var page = t.screens.active.pages.pages.first.?.data; var row = page.getRow(1); const cell = &row.cells.ptr(page.memory)[0]; cell.* = .{ @@ -1020,12 +1133,15 @@ test "shape emoji width long" { graphemes[0..], ); + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, + .cells = state.row_data.get(1).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1050,16 +1166,22 @@ test "shape variation selector VS15" { buf_idx += try std.unicode.utf8Encode(0xFE0E, buf[buf_idx..]); // ZWJ to force text // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1083,16 +1205,22 @@ test "shape variation selector VS16" { buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // ZWJ to force color // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1111,18 +1239,24 @@ test "shape with empty cells in between" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 30, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("A"); - screen.cursorRight(5); - try screen.testWriteString("B"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A"); + try s.nextSlice("\x1b[5C"); // 5 spaces forward + try s.nextSlice("B"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1149,16 +1283,22 @@ test "shape Chinese characters" { buf_idx += try std.unicode.utf8Encode('a', buf[buf_idx..]); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 30, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1174,6 +1314,57 @@ test "shape Chinese characters" { try testing.expectEqual(@as(usize, 1), count); } +// This test exists because the string it uses causes CoreText to output a +// non-monotonic run, which we need to handle by sorting the resulting buffer. +test "shape Devanagari string" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports devanagari for this to work, if we can't + // find Arial Unicode MS, which is a system font on macOS, we just skip + // the test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Arial Unicode MS", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("अपार्टमेंट"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + + const run = try it.next(alloc); + try testing.expect(run != null); + const cells = try shaper.shape(run.?); + + try testing.expectEqual(@as(usize, 8), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 1), cells[1].x); + try testing.expectEqual(@as(u16, 2), cells[2].x); + try testing.expectEqual(@as(u16, 3), cells[3].x); + try testing.expectEqual(@as(u16, 4), cells[4].x); + try testing.expectEqual(@as(u16, 5), cells[5].x); + try testing.expectEqual(@as(u16, 5), cells[6].x); + try testing.expectEqual(@as(u16, 6), cells[7].x); + + try testing.expect(try it.next(alloc) == null); +} + test "shape box glyphs" { const testing = std.testing; const alloc = testing.allocator; @@ -1187,16 +1378,22 @@ test "shape box glyphs" { buf_idx += try std.unicode.utf8Encode(0x2501, buf[buf_idx..]); // // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1219,9 +1416,16 @@ test "shape selection boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("a1b2c3d4e5"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("a1b2c3d4e5"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Full line selection { @@ -1229,13 +1433,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 0, @intCast(t.cols - 1) }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1251,13 +1450,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 2, @intCast(t.cols - 1) }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1273,13 +1467,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 0, 3 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1295,13 +1484,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 1, 3 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1317,13 +1501,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 1, 1 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1342,9 +1521,16 @@ test "shape cursor boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("a1b2c3d4e5"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("a1b2c3d4e5"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // No cursor is full line { @@ -1352,8 +1538,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1370,8 +1555,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 0, }); var count: usize = 0; @@ -1387,8 +1571,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1406,8 +1589,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 1, }); var count: usize = 0; @@ -1423,8 +1605,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1441,8 +1622,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 9, }); var count: usize = 0; @@ -1458,8 +1638,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1479,9 +1658,16 @@ test "shape cursor boundary and colored emoji" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 3, .rows = 10, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("👍🏼"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("👍🏼"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // No cursor is full line { @@ -1489,8 +1675,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1506,8 +1691,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 0, }); var count: usize = 0; @@ -1522,8 +1706,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1537,8 +1720,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 1, }); var count: usize = 0; @@ -1553,8 +1735,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1574,15 +1755,21 @@ test "shape cell attribute change" { // Plain >= should shape into 1 run { - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(">="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(">="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1594,17 +1781,23 @@ test "shape cell attribute change" { // Bold vs regular should split { - var screen = try terminal.Screen.init(alloc, .{ .cols = 3, .rows = 10, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(">"); - try screen.setAttribute(.{ .bold = {} }); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(">"); + try s.nextSlice("\x1b[1m"); // Bold + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1616,18 +1809,26 @@ test "shape cell attribute change" { // Changing fg color should split { - var screen = try terminal.Screen.init(alloc, .{ .cols = 3, .rows = 10, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); - try screen.testWriteString(">"); - try screen.setAttribute(.{ .direct_color_fg = .{ .r = 3, .g = 2, .b = 1 } }); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // RGB 1, 2, 3 + try s.nextSlice("\x1b[38;2;1;2;3m"); + try s.nextSlice(">"); + // RGB 3, 2, 1 + try s.nextSlice("\x1b[38;2;3;2;1m"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1639,18 +1840,26 @@ test "shape cell attribute change" { // Changing bg color should NOT split { - var screen = try terminal.Screen.init(alloc, .{ .cols = 3, .rows = 10, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); - try screen.testWriteString(">"); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 3, .g = 2, .b = 1 } }); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // RGB 1, 2, 3 bg + try s.nextSlice("\x1b[48;2;1;2;3m"); + try s.nextSlice(">"); + // RGB 3, 2, 1 bg + try s.nextSlice("\x1b[48;2;3;2;1m"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1662,17 +1871,24 @@ test "shape cell attribute change" { // Same bg color should not split { - var screen = try terminal.Screen.init(alloc, .{ .cols = 3, .rows = 10, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); - try screen.testWriteString(">"); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // RGB 1, 2, 3 bg + try s.nextSlice("\x1b[48;2;1;2;3m"); + try s.nextSlice(">"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1700,17 +1916,22 @@ test "shape high plane sprite font codepoint" { var testdata = try testShaper(alloc); defer testdata.deinit(); - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + var s = t.vtStream(); + defer s.deinit(); // U+1FB70: Vertical One Eighth Block-2 - try screen.testWriteString("\u{1FB70}"); + try s.nextSlice("\u{1FB70}"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); // We should get one run const run = (try it.next(alloc)).?; @@ -1862,3 +2083,50 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { .lib = lib, }; } + +/// Return a fully initialized shaper by discovering a named font on the system. +fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestShaper { + var lib = try Library.init(alloc); + errdefer lib.deinit(); + + var c = Collection.init(); + c.load_options = .{ .library = lib }; + + // Discover and add our font to the collection. + { + var disco = font.Discover.init(); + defer disco.deinit(); + var disco_it = try disco.discover(alloc, .{ + .family = font_req, + .size = 12, + .monospace = false, + }); + defer disco_it.deinit(); + var face: font.DeferredFace = (try disco_it.next()).?; + errdefer face.deinit(); + _ = try c.add( + alloc, + try face.load(lib, .{ .size = .{ .points = 12 } }), + .{ + .style = .regular, + .fallback = false, + .size_adjustment = .none, + }, + ); + } + + const grid_ptr = try alloc.create(SharedGrid); + errdefer alloc.destroy(grid_ptr); + grid_ptr.* = try .init(alloc, .{ .collection = c }); + errdefer grid_ptr.*.deinit(alloc); + + var shaper = try Shaper.init(alloc, .{}); + errdefer shaper.deinit(); + + return TestShaper{ + .alloc = alloc, + .shaper = shaper, + .grid = grid_ptr, + .lib = lib, + }; +} diff --git a/src/font/shaper/feature.zig b/src/font/shaper/feature.zig index 40770376b..5bd73f97f 100644 --- a/src/font/shaper/feature.zig +++ b/src/font/shaper/feature.zig @@ -1,6 +1,5 @@ const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const log = std.log.scoped(.font_shaper); diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 83de69cfe..e4a9301e8 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1,10 +1,9 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const harfbuzz = @import("harfbuzz"); const font = @import("../main.zig"); const terminal = @import("../../terminal/main.zig"); -const config = @import("../../config.zig"); const Feature = font.shape.Feature; const FeatureList = font.shape.FeatureList; const default_features = font.shape.default_features; @@ -207,16 +206,22 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("ABCD"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABCD"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -225,15 +230,21 @@ test "run iterator" { // Spaces should be part of a run { - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("ABCD EFG"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABCD EFG"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -242,16 +253,22 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("A😃D"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A😃D"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |_| { @@ -273,14 +290,17 @@ test "run iterator: empty cells with background set" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); - try screen.testWriteString("A"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // Set red background and write A + try s.nextSlice("\x1b[48;2;255;0;0mA"); // Get our first row { - const list_cell = screen.pages.getCell(.{ .active = .{ .x = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 1 } }).?; const cell = list_cell.cell; cell.* = .{ .content_tag = .bg_color_rgb, @@ -288,7 +308,7 @@ test "run iterator: empty cells with background set" { }; } { - const list_cell = screen.pages.getCell(.{ .active = .{ .x = 2 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 2 } }).?; const cell = list_cell.cell; cell.* = .{ .content_tag = .bg_color_rgb, @@ -296,12 +316,15 @@ test "run iterator: empty cells with background set" { }; } + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); { const run = (try it.next(alloc)).?; @@ -327,16 +350,22 @@ test "shape" { buf_idx += try std.unicode.utf8Encode(0x1F3FD, buf[buf_idx..]); // Medium skin tone // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -355,15 +384,21 @@ test "shape inconsolata ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(">="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(">="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -378,15 +413,21 @@ test "shape inconsolata ligs" { } { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("==="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("==="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -409,15 +450,21 @@ test "shape monaspace ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("==="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("==="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -443,15 +490,21 @@ test "shape arabic forced LTR" { var testdata = try testShaperWithFont(alloc, .arabic); defer testdata.deinit(); - var screen = try terminal.Screen.init(alloc, .{ .cols = 120, .rows = 30, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(@embedFile("testdata/arabic.txt")); + var t = try terminal.Terminal.init(alloc, .{ .cols = 120, .rows = 30 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(@embedFile("testdata/arabic.txt")); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -478,15 +531,21 @@ test "shape emoji width" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("👍"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("👍"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -509,13 +568,13 @@ test "shape emoji width long" { defer testdata.deinit(); // Make a screen and add a long emoji sequence to it. - var screen = try terminal.Screen.init( + var t = try terminal.Terminal.init( alloc, .{ .cols = 30, .rows = 3 }, ); - defer screen.deinit(); + defer t.deinit(alloc); - var page = screen.pages.pages.first.?.data; + var page = t.screens.active.pages.pages.first.?.data; var row = page.getRow(1); const cell = &row.cells.ptr(page.memory)[0]; cell.* = .{ @@ -534,12 +593,15 @@ test "shape emoji width long" { graphemes[0..], ); + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, + .cells = state.row_data.get(1).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -566,16 +628,22 @@ test "shape variation selector VS15" { buf_idx += try std.unicode.utf8Encode(0xFE0E, buf[buf_idx..]); // ZWJ to force text // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -601,16 +669,22 @@ test "shape variation selector VS16" { buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // ZWJ to force color // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -631,21 +705,27 @@ test "shape with empty cells in between" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init( + var t = try terminal.Terminal.init( alloc, .{ .cols = 30, .rows = 3 }, ); - defer screen.deinit(); - try screen.testWriteString("A"); - screen.cursorRight(5); - try screen.testWriteString("B"); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A"); + try s.nextSlice("\x1b[5C"); + try s.nextSlice("B"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -672,19 +752,25 @@ test "shape Chinese characters" { buf_idx += try std.unicode.utf8Encode('a', buf[buf_idx..]); // Make a screen with some data - var screen = try terminal.Screen.init( + var t = try terminal.Terminal.init( alloc, .{ .cols = 30, .rows = 3 }, ); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -713,16 +799,22 @@ test "shape box glyphs" { buf_idx += try std.unicode.utf8Encode(0x2501, buf[buf_idx..]); // // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get our run iterator var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -746,9 +838,16 @@ test "shape selection boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("a1b2c3d4e5"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("a1b2c3d4e5"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Full line selection { @@ -756,13 +855,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 0, 9 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -778,13 +872,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 2, 9 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -800,13 +889,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 0, 3 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -822,13 +906,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 1, 3 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -844,13 +923,8 @@ test "shape selection boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - .selection = terminal.Selection.init( - screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, - screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, - false, - ), + .cells = state.row_data.get(0).cells.slice(), + .selection = .{ 1, 1 }, }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -869,9 +943,16 @@ test "shape cursor boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString("a1b2c3d4e5"); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("a1b2c3d4e5"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // No cursor is full line { @@ -879,8 +960,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -897,8 +977,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 0, }); var count: usize = 0; @@ -914,8 +993,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -933,8 +1011,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 1, }); var count: usize = 0; @@ -950,8 +1027,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -968,8 +1044,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 9, }); var count: usize = 0; @@ -985,8 +1060,7 @@ test "shape cursor boundary" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1006,12 +1080,19 @@ test "shape cursor boundary and colored emoji" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init( + var t = try terminal.Terminal.init( alloc, .{ .cols = 3, .rows = 10 }, ); - defer screen.deinit(); - try screen.testWriteString("👍🏼"); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("👍🏼"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // No cursor is full line { @@ -1019,8 +1100,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1036,8 +1116,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 0, }); var count: usize = 0; @@ -1052,8 +1131,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1067,8 +1145,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), .cursor_x = 1, }); var count: usize = 0; @@ -1083,8 +1160,7 @@ test "shape cursor boundary and colored emoji" { var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1104,15 +1180,21 @@ test "shape cell attribute change" { // Plain >= should shape into 1 run { - var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); - defer screen.deinit(); - try screen.testWriteString(">="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(">="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1124,20 +1206,23 @@ test "shape cell attribute change" { // Bold vs regular should split { - var screen = try terminal.Screen.init( - alloc, - .{ .cols = 3, .rows = 10 }, - ); - defer screen.deinit(); - try screen.testWriteString(">"); - try screen.setAttribute(.{ .bold = {} }); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(">"); + try s.nextSlice("\x1b[1m"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1149,21 +1234,26 @@ test "shape cell attribute change" { // Changing fg color should split { - var screen = try terminal.Screen.init( - alloc, - .{ .cols = 3, .rows = 10 }, - ); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); - try screen.testWriteString(">"); - try screen.setAttribute(.{ .direct_color_fg = .{ .r = 3, .g = 2, .b = 1 } }); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // RGB 1, 2, 3 + try s.nextSlice("\x1b[38;2;1;2;3m"); + try s.nextSlice(">"); + // RGB 3, 2, 1 + try s.nextSlice("\x1b[38;2;3;2;1m"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1175,21 +1265,26 @@ test "shape cell attribute change" { // Changing bg color should not split { - var screen = try terminal.Screen.init( - alloc, - .{ .cols = 3, .rows = 10 }, - ); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); - try screen.testWriteString(">"); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 3, .g = 2, .b = 1 } }); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // RGB 1, 2, 3 bg + try s.nextSlice("\x1b[48;2;1;2;3m"); + try s.nextSlice(">"); + // RGB 3, 2, 1 bg + try s.nextSlice("\x1b[48;2;3;2;1m"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1201,20 +1296,24 @@ test "shape cell attribute change" { // Same bg color should not split { - var screen = try terminal.Screen.init( - alloc, - .{ .cols = 3, .rows = 10 }, - ); - defer screen.deinit(); - try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); - try screen.testWriteString(">"); - try screen.testWriteString("="); + var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // RGB 1, 2, 3 bg + try s.nextSlice("\x1b[48;2;1;2;3m"); + try s.nextSlice(">"); + try s.nextSlice("="); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); var shaper = &testdata.shaper; var it = shaper.runIterator(.{ .grid = testdata.grid, - .screen = &screen, - .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cells = state.row_data.get(0).cells.slice(), }); var count: usize = 0; while (try it.next(alloc)) |run| { diff --git a/src/font/shaper/noop.zig b/src/font/shaper/noop.zig index 8723071d7..e5a08653f 100644 --- a/src/font/shaper/noop.zig +++ b/src/font/shaper/noop.zig @@ -1,7 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const trace = @import("tracy").trace; const font = @import("../main.zig"); const Face = font.Face; const Collection = font.Collection; diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index da3c51cee..85c5c410b 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const font = @import("../main.zig"); const shape = @import("../shape.zig"); @@ -45,7 +45,10 @@ pub const RunIterator = struct { i: usize = 0, pub fn next(self: *RunIterator, alloc: Allocator) !?TextRun { - const cells = self.opts.row.cells(.all); + const slice = &self.opts.cells; + const cells: []const terminal.page.Cell = slice.items(.raw); + const graphemes: []const []const u21 = slice.items(.grapheme); + const styles: []const terminal.Style = slice.items(.style); // Trim the right side of a row that might be empty const max: usize = max: { @@ -60,10 +63,8 @@ pub const RunIterator = struct { // Invisible cells don't have any glyphs rendered, // so we explicitly skip them in the shaping process. while (self.i < max and - self.opts.row.style(&cells[self.i]).flags.invisible) - { - self.i += 1; - } + (cells[self.i].hasStyling() and + styles[self.i].flags.invisible)) self.i += 1; // We're over at the max if (self.i >= max) return null; @@ -78,7 +79,7 @@ pub const RunIterator = struct { var hasher = Hasher.init(0); // Let's get our style that we'll expect for the run. - const style = self.opts.row.style(&cells[self.i]); + const style: terminal.Style = if (cells[self.i].hasStyling()) styles[self.i] else .{}; // Go through cell by cell and accumulate while we build our run. var j: usize = self.i; @@ -88,21 +89,14 @@ pub const RunIterator = struct { // with identical content but different starting positions in the // row produce the same hash, enabling cache reuse. const cluster = j - self.i; - const cell = &cells[j]; + const cell: *const terminal.page.Cell = &cells[j]; // If we have a selection and we're at a boundary point, then // we break the run here. - if (self.opts.selection) |unordered_sel| { + if (self.opts.selection) |bounds| { if (j > self.i) { - const sel = unordered_sel.ordered(self.opts.screen, .forward); - const start_x = sel.start().x; - const end_x = sel.end().x; - - if (start_x > 0 and - j == start_x) break; - - if (end_x > 0 and - j == end_x + 1) break; + if (bounds[0] > 0 and j == bounds[0]) break; + if (bounds[1] > 0 and j == bounds[1] + 1) break; } } @@ -148,7 +142,7 @@ pub const RunIterator = struct { // The style is different. We allow differing background // styles but any other change results in a new run. const c1 = comparableStyle(style); - const c2 = comparableStyle(self.opts.row.style(&cells[j])); + const c2 = comparableStyle(if (cell.hasStyling()) styles[j] else .{}); if (!c1.eql(c2)) break; } @@ -168,7 +162,7 @@ pub const RunIterator = struct { const presentation: ?font.Presentation = if (cell.hasGrapheme()) p: { // We only check the FIRST codepoint because I believe the // presentation format must be directly adjacent to the codepoint. - const cps = self.opts.row.grapheme(cell) orelse break :p null; + const cps = graphemes[j]; assert(cps.len > 0); if (cps[0] == 0xFE0E) break :p .text; if (cps[0] == 0xFE0F) break :p .emoji; @@ -227,6 +221,7 @@ pub const RunIterator = struct { if (try self.indexForCell( alloc, cell, + graphemes[j], font_style, presentation, )) |idx| break :font_info .{ .idx = idx }; @@ -279,8 +274,7 @@ pub const RunIterator = struct { @intCast(cluster), ); if (cell.hasGrapheme()) { - const cps = self.opts.row.grapheme(cell).?; - for (cps) |cp| { + for (graphemes[j]) |cp| { // Do not send presentation modifiers if (cp == 0xFE0E or cp == 0xFE0F) continue; try self.addCodepoint(&hasher, cp, @intCast(cluster)); @@ -300,7 +294,7 @@ pub const RunIterator = struct { // Move our cursor. Must defer since we use self.i below. defer self.i = j; - return TextRun{ + return .{ .hash = hasher.final(), .offset = @intCast(self.i), .cells = @intCast(j - self.i), @@ -324,7 +318,8 @@ pub const RunIterator = struct { fn indexForCell( self: *RunIterator, alloc: Allocator, - cell: *terminal.Cell, + cell: *const terminal.Cell, + graphemes: []const u21, style: font.Style, presentation: ?font.Presentation, ) !?font.Collection.Index { @@ -355,12 +350,14 @@ pub const RunIterator = struct { // If this is a grapheme, we need to find a font that supports // all of the codepoints in the grapheme. - const cps = self.opts.row.grapheme(cell) orelse return primary; - var candidates: std.ArrayList(font.Collection.Index) = try .initCapacity(alloc, cps.len + 1); + var candidates: std.ArrayList(font.Collection.Index) = try .initCapacity( + alloc, + graphemes.len + 1, + ); defer candidates.deinit(alloc); candidates.appendAssumeCapacity(primary); - for (cps) |cp| { + for (graphemes) |cp| { // Ignore Emoji ZWJs if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; @@ -383,7 +380,7 @@ pub const RunIterator = struct { // We need to find a candidate that has ALL of our codepoints for (candidates.items) |idx| { if (!self.opts.grid.hasCodepoint(idx, primary_cp, presentation)) continue; - for (cps) |cp| { + for (graphemes) |cp| { // Ignore Emoji ZWJs if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; if (!self.opts.grid.hasCodepoint(idx, cp, null)) break; diff --git a/src/font/shaper/web_canvas.zig b/src/font/shaper/web_canvas.zig index e0f0e1a00..c8334ec9d 100644 --- a/src/font/shaper/web_canvas.zig +++ b/src/font/shaper/web_canvas.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const font = @import("../main.zig"); const terminal = @import("../../terminal/main.zig"); diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index 5442890bf..94bfa2f0b 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -13,8 +13,7 @@ const Face = @This(); const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const wuffs = @import("wuffs"); const z2d = @import("z2d"); @@ -30,6 +29,7 @@ metrics: font.Metrics, pub const DrawFnError = Allocator.Error || + z2d.Path.Error || z2d.painter.FillError || z2d.painter.StrokeError || error{ diff --git a/src/font/sprite/canvas.zig b/src/font/sprite/canvas.zig index a77b90a56..19d27eb45 100644 --- a/src/font/sprite/canvas.zig +++ b/src/font/sprite/canvas.zig @@ -1,7 +1,7 @@ //! This exposes primitives to draw 2D graphics and export the graphic to //! a font atlas. const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const z2d = @import("z2d"); const font = @import("../main.zig"); diff --git a/src/font/sprite/draw/block.zig b/src/font/sprite/draw/block.zig index 571f25a79..1731d2f50 100644 --- a/src/font/sprite/draw/block.zig +++ b/src/font/sprite/draw/block.zig @@ -6,11 +6,8 @@ //! const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const z2d = @import("z2d"); - const common = @import("common.zig"); const Shade = common.Shade; const Quads = common.Quads; @@ -18,7 +15,6 @@ const Alignment = common.Alignment; const fill = common.fill; const font = @import("../../main.zig"); -const Sprite = @import("../../sprite.zig").Sprite; // Utility names for common fractions const one_eighth: f64 = 0.125; diff --git a/src/font/sprite/draw/box.zig b/src/font/sprite/draw/box.zig index f14e5a3f9..cc6e694d4 100644 --- a/src/font/sprite/draw/box.zig +++ b/src/font/sprite/draw/box.zig @@ -12,11 +12,9 @@ //! const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const z2d = @import("z2d"); - const common = @import("common.zig"); const Thickness = common.Thickness; const Shade = common.Shade; @@ -30,7 +28,6 @@ const hlineMiddle = common.hlineMiddle; const vlineMiddle = common.vlineMiddle; const font = @import("../../main.zig"); -const Sprite = @import("../../sprite.zig").Sprite; /// Specification of a traditional intersection-style line/box-drawing char, /// which can have a different style of line from each edge to the center. diff --git a/src/font/sprite/draw/braille.zig b/src/font/sprite/draw/braille.zig index c756ff369..fb2d54748 100644 --- a/src/font/sprite/draw/braille.zig +++ b/src/font/sprite/draw/braille.zig @@ -23,7 +23,7 @@ //! const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const font = @import("../../main.zig"); diff --git a/src/font/sprite/draw/branch.zig b/src/font/sprite/draw/branch.zig index ac7220390..034f1e398 100644 --- a/src/font/sprite/draw/branch.zig +++ b/src/font/sprite/draw/branch.zig @@ -16,7 +16,6 @@ //! const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const common = @import("common.zig"); diff --git a/src/font/sprite/draw/common.zig b/src/font/sprite/draw/common.zig index 67b9dc778..290c44965 100644 --- a/src/font/sprite/draw/common.zig +++ b/src/font/sprite/draw/common.zig @@ -4,13 +4,9 @@ //! rather than being single-use. const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const z2d = @import("z2d"); - const font = @import("../../main.zig"); -const Sprite = @import("../../sprite.zig").Sprite; const log = std.log.scoped(.sprite_font); diff --git a/src/font/sprite/draw/geometric_shapes.zig b/src/font/sprite/draw/geometric_shapes.zig index d95a4fd2f..f6402cf05 100644 --- a/src/font/sprite/draw/geometric_shapes.zig +++ b/src/font/sprite/draw/geometric_shapes.zig @@ -15,8 +15,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const z2d = @import("z2d"); - const common = @import("common.zig"); const Thickness = common.Thickness; const Corner = common.Corner; diff --git a/src/font/sprite/draw/powerline.zig b/src/font/sprite/draw/powerline.zig index 24fce454b..8658d8553 100644 --- a/src/font/sprite/draw/powerline.zig +++ b/src/font/sprite/draw/powerline.zig @@ -11,8 +11,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const z2d = @import("z2d"); - const common = @import("common.zig"); const Thickness = common.Thickness; const Shade = common.Shade; diff --git a/src/font/sprite/draw/special.zig b/src/font/sprite/draw/special.zig index e41cac487..8cad9ceba 100644 --- a/src/font/sprite/draw/special.zig +++ b/src/font/sprite/draw/special.zig @@ -6,8 +6,6 @@ //! having names that exactly match the enum fields in Sprite. const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const font = @import("../../main.zig"); const Sprite = font.sprite.Sprite; @@ -20,11 +18,19 @@ pub fn underline( metrics: font.Metrics, ) !void { _ = cp; - _ = height; + + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + const y = @min( + metrics.underline_position, + height +| canvas.padding_y -| metrics.underline_thickness, + ); canvas.rect(.{ .x = 0, - .y = @intCast(metrics.underline_position), + .y = @intCast(y), .width = @intCast(width), .height = @intCast(metrics.underline_thickness), }, .on); @@ -38,20 +44,28 @@ pub fn underline_double( metrics: font.Metrics, ) !void { _ = cp; - _ = height; + + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + const y = @min( + metrics.underline_position, + height +| canvas.padding_y -| 2 * metrics.underline_thickness, + ); // We place one underline above the underline position, and one below // by one thickness, creating a "negative" underline where the single // underline would be placed. canvas.rect(.{ .x = 0, - .y = @intCast(metrics.underline_position -| metrics.underline_thickness), + .y = @intCast(y -| metrics.underline_thickness), .width = @intCast(width), .height = @intCast(metrics.underline_thickness), }, .on); canvas.rect(.{ .x = 0, - .y = @intCast(metrics.underline_position +| metrics.underline_thickness), + .y = @intCast(y +| metrics.underline_thickness), .width = @intCast(width), .height = @intCast(metrics.underline_thickness), }, .on); @@ -65,29 +79,57 @@ pub fn underline_dotted( metrics: font.Metrics, ) !void { _ = cp; - _ = height; - // TODO: Rework this now that we can go out of bounds, just - // make sure that adjacent versions of this glyph align. - const dot_width = @max(metrics.underline_thickness, 3); - const dot_count = @max((width / dot_width) / 2, 1); - const gap_width = std.math.divCeil( - u32, - width -| (dot_count * dot_width), - dot_count, - ) catch return error.MathError; - var i: u32 = 0; - while (i < dot_count) : (i += 1) { - // Ensure we never go out of bounds for the rect - const x = @min(i * (dot_width + gap_width), width - 1); - const rect_width = @min(width - x, dot_width); - canvas.rect(.{ - .x = @intCast(x), - .y = @intCast(metrics.underline_position), - .width = @intCast(rect_width), - .height = @intCast(metrics.underline_thickness), - }, .on); + var ctx = canvas.getContext(); + defer ctx.deinit(); + + const float_width: f64 = @floatFromInt(width); + const float_height: f64 = @floatFromInt(height); + const float_pos: f64 = @floatFromInt(metrics.underline_position); + const float_thick: f64 = @floatFromInt(metrics.underline_thickness); + + // The diameter will be sqrt2 * the usual underline thickness + // since otherwise dotted underlines look somewhat anemic. + const radius = std.math.sqrt1_2 * float_thick; + + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + const padding: f64 = @floatFromInt(canvas.padding_y); + const y = @min( + // The center of the underline stem. + float_pos + 0.5 * float_thick, + // The lowest we can go on the canvas and not get clipped. + float_height + padding - @ceil(radius), + ); + + const dot_count: f64 = @max( + @min( + // We should try to have enough dots that the + // space between them matches their diameter. + @ceil(float_width / (4 * radius)), + // And not enough that the space between + // each dot is less than their radius. + @floor(float_width / (3 * radius)), + // And definitely not enough that the space + // between them is less than a single pixel. + @floor(float_width / (2 * radius + 1)), + ), + // And we must have at least one dot per cell. + 1.0, + ); + + // What we essentially do is divide the cell in to + // dot_count areas with a dot centered in each one. + var x: f64 = (float_width / dot_count) / 2; + for (0..@as(usize, @intFromFloat(dot_count))) |_| { + try ctx.arc(x, y, radius, 0.0, std.math.tau); + try ctx.closePath(); + x += float_width / dot_count; } + + try ctx.fill(); } pub fn underline_dashed( @@ -98,19 +140,25 @@ pub fn underline_dashed( metrics: font.Metrics, ) !void { _ = cp; - _ = height; + + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + const y = @min( + metrics.underline_position, + height +| canvas.padding_y -| metrics.underline_thickness, + ); const dash_width = width / 3 + 1; const dash_count = (width / dash_width) + 1; var i: u32 = 0; while (i < dash_count) : (i += 2) { - // Ensure we never go out of bounds for the rect - const x = @min(i * dash_width, width - 1); - const rect_width = @min(width - x, dash_width); + const x = i * dash_width; canvas.rect(.{ .x = @intCast(x), - .y = @intCast(metrics.underline_position), - .width = @intCast(rect_width), + .y = @intCast(y), + .width = @intCast(dash_width), .height = @intCast(metrics.underline_thickness), }, .on); } @@ -124,105 +172,66 @@ pub fn underline_curly( metrics: font.Metrics, ) !void { _ = cp; - _ = height; - // TODO: Rework this using z2d, this is pretty cool code and all but - // it doesn't need to be highly optimized and z2d path drawing - // code would be clearer and nicer to have. + var ctx = canvas.getContext(); + defer ctx.deinit(); const float_width: f64 = @floatFromInt(width); + const float_height: f64 = @floatFromInt(height); + const float_pos: f64 = @floatFromInt(metrics.underline_position); + // Because of we way we draw the undercurl, we end up making it around 1px // thicker than it should be, to fix this we just reduce the thickness by 1. // // We use a minimum thickness of 0.414 because this empirically produces // the nicest undercurls at 1px underline thickness; thinner tends to look // too thin compared to straight underlines and has artefacting. - const float_thick: f64 = @max( - 0.414, - @as(f64, @floatFromInt(metrics.underline_thickness -| 1)), + ctx.line_width = @floatFromInt(metrics.underline_thickness); + + // Rounded caps, adjacent underlines will have these overlap and so not be + // visible, but it makes the ends look cleaner. + ctx.line_cap_mode = .round; + + // Empirically this looks good. + const amplitude = float_width / std.math.pi; + + // Make sure we don't exceed the drawable area. This can still be outside + // of the cell by some amount (one quarter of the height), but we don't + // want underlines to disappear for fonts with bad metadata or when users + // set their underline position way too low. + const padding: f64 = @floatFromInt(canvas.padding_y); + const top: f64 = @min( + float_pos, + // The lowest we can draw this and not get clipped. + float_height + padding - amplitude - ctx.line_width, ); + const bottom = top + amplitude; - // Calculate the wave period for a single character - // `2 * pi...` = 1 peak per character - // `4 * pi...` = 2 peaks per character - const wave_period = 2 * std.math.pi / float_width; + // Curvature multiplier. + // To my eye, 0.4 creates a nice smooth wiggle. + const r = 0.4; - // The full amplitude of the wave can be from the bottom to the - // underline position. We also calculate our mid y point of the wave - const half_amplitude = 1.0 / wave_period; - const y_mid: f64 = half_amplitude + float_thick * 0.5 + 1; + const center = 0.5 * float_width; - // Offset to move the undercurl up slightly. - const y_off: u32 = @intFromFloat(half_amplitude * 0.5); - - // This is used in calculating the offset curve estimate below. - const offset_factor = @min(1.0, float_thick * 0.5 * wave_period) * @min( - 1.0, - half_amplitude * wave_period, + // We create a single cycle of a wave that peaks at the center of the cell. + try ctx.moveTo(0, bottom); + try ctx.curveTo( + center * r, + bottom, + center - center * r, + top, + center, + top, ); - - // follow Xiaolin Wu's antialias algorithm to draw the curve - var x: u32 = 0; - while (x < width) : (x += 1) { - // We sample the wave function at the *middle* of each - // pixel column, to ensure that it renders symmetrically. - const t: f64 = (@as(f64, @floatFromInt(x)) + 0.5) * wave_period; - // Use the slope at this location to add thickness to - // the line on this column, counteracting the thinning - // caused by the slope. - // - // This is not the exact offset curve for a sine wave, - // but it's a decent enough approximation. - // - // How did I derive this? I stared at Desmos and fiddled - // with numbers for an hour until it was good enough. - const t_u: f64 = t + std.math.pi; - const slope_factor_u: f64 = - (@sin(t_u) * @sin(t_u) * offset_factor) / - ((1.0 + @cos(t_u / 2) * @cos(t_u / 2) * 2) * wave_period); - const slope_factor_l: f64 = - (@sin(t) * @sin(t) * offset_factor) / - ((1.0 + @cos(t / 2) * @cos(t / 2) * 2) * wave_period); - - const cosx: f64 = @cos(t); - // This will be the center of our stroke. - const y: f64 = y_mid + half_amplitude * cosx; - - // The upper pixel and lower pixel are - // calculated relative to the center. - const y_u: f64 = y - float_thick * 0.5 - slope_factor_u; - const y_l: f64 = y + float_thick * 0.5 + slope_factor_l; - const y_upper: u32 = @intFromFloat(@floor(y_u)); - const y_lower: u32 = @intFromFloat(@ceil(y_l)); - const alpha_u: u8 = @intFromFloat( - @round(255 * (1.0 - @abs(y_u - @floor(y_u)))), - ); - const alpha_l: u8 = @intFromFloat( - @round(255 * (1.0 - @abs(y_l - @ceil(y_l)))), - ); - - // upper and lower bounds - canvas.pixel( - @intCast(x), - @intCast(metrics.underline_position +| y_upper -| y_off), - @enumFromInt(alpha_u), - ); - canvas.pixel( - @intCast(x), - @intCast(metrics.underline_position +| y_lower -| y_off), - @enumFromInt(alpha_l), - ); - - // fill between upper and lower bound - var y_fill: u32 = y_upper + 1; - while (y_fill < y_lower) : (y_fill += 1) { - canvas.pixel( - @intCast(x), - @intCast(metrics.underline_position +| y_fill -| y_off), - .on, - ); - } - } + try ctx.curveTo( + center + center * r, + top, + float_width - center * r, + bottom, + float_width, + bottom, + ); + try ctx.stroke(); } pub fn strikethrough( @@ -253,9 +262,18 @@ pub fn overline( _ = cp; _ = height; + // We can go beyond the top of the cell a bit, but we + // want to be sure never to exceed the height of the + // canvas, which extends a quarter cell above the top + // of the cell. + const y = @max( + metrics.overline_position, + -@as(i32, @intCast(canvas.padding_y)), + ); + canvas.rect(.{ .x = 0, - .y = @intCast(metrics.overline_position), + .y = y, .width = @intCast(width), .height = @intCast(metrics.overline_thickness), }, .on); @@ -335,11 +353,19 @@ pub fn cursor_underline( metrics: font.Metrics, ) !void { _ = cp; - _ = height; + + // We can go beyond the height of the cell a bit, but + // we want to be sure never to exceed the height of the + // canvas, which extends a quarter cell below the cell + // height. + const y = @min( + metrics.underline_position, + height +| canvas.padding_y -| metrics.underline_thickness, + ); canvas.rect(.{ .x = 0, - .y = @intCast(metrics.underline_position), + .y = @intCast(y), .width = @intCast(width), .height = @intCast(metrics.cursor_thickness), }, .on); diff --git a/src/font/sprite/draw/symbols_for_legacy_computing.zig b/src/font/sprite/draw/symbols_for_legacy_computing.zig index 164aa1ac3..d99fc8702 100644 --- a/src/font/sprite/draw/symbols_for_legacy_computing.zig +++ b/src/font/sprite/draw/symbols_for_legacy_computing.zig @@ -21,9 +21,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; - -const z2d = @import("z2d"); +const assert = @import("../../../quirks.zig").inlineAssert; const common = @import("common.zig"); const Thickness = common.Thickness; diff --git a/src/font/sprite/draw/symbols_for_legacy_computing_supplement.zig b/src/font/sprite/draw/symbols_for_legacy_computing_supplement.zig index f43949eb9..bd91d3925 100644 --- a/src/font/sprite/draw/symbols_for_legacy_computing_supplement.zig +++ b/src/font/sprite/draw/symbols_for_legacy_computing_supplement.zig @@ -49,9 +49,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; - -const z2d = @import("z2d"); +const assert = @import("../../../quirks.zig").inlineAssert; const common = @import("common.zig"); const Thickness = common.Thickness; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 94868c2c1..1e7db3592 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -4,7 +4,7 @@ const Binding = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const build_config = @import("../build_config.zig"); const uucode = @import("uucode"); const EntryFormatter = @import("../config/formatter.zig").EntryFormatter; @@ -332,6 +332,25 @@ pub const Action = union(enum) { /// to 14.5 points. set_font_size: f32, + /// Start a search for the given text. If the text is empty, then + /// the search is canceled. A canceled search will not disable any GUI + /// elements showing search. For that, the explicit end_search binding + /// should be used. + /// + /// If a previous search is active, it is replaced. + search: []const u8, + + /// Navigate the search results. If there is no active search, this + /// is not performed. + navigate_search: NavigateSearch, + + /// Start a search if it isn't started already. This doesn't set any + /// search terms, but opens the UI for searching. + start_search, + + /// End the current search if any and hide any GUI elements. + end_search, + /// Clear the screen and all scrollback. clear_screen, @@ -822,6 +841,11 @@ pub const Action = union(enum) { } }; + pub const NavigateSearch = enum { + previous, + next, + }; + pub const AdjustSelection = enum { left, right, @@ -1152,6 +1176,10 @@ pub const Action = union(enum) { .esc, .text, .cursor_key, + .search, + .navigate_search, + .start_search, + .end_search, .reset, .copy_to_clipboard, .copy_url_to_clipboard, diff --git a/src/input/KeymapDarwin.zig b/src/input/KeymapDarwin.zig index 53c305ab1..a8702730e 100644 --- a/src/input/KeymapDarwin.zig +++ b/src/input/KeymapDarwin.zig @@ -17,7 +17,6 @@ const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const macos = @import("macos"); const codes = @import("keycodes.zig").entries; -const Key = @import("key.zig").Key; const Mods = @import("key.zig").Mods; /// The current input source that is selected for the keyboard. This can diff --git a/src/input/command.zig b/src/input/command.zig index f38295a4f..72fb7f4ee 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -1,6 +1,5 @@ const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const Action = @import("Binding.zig").Action; @@ -163,6 +162,28 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Paste the contents of the selection clipboard.", }}, + .start_search => comptime &.{.{ + .action = .start_search, + .title = "Start Search", + .description = "Start a search if one isn't already active.", + }}, + + .end_search => comptime &.{.{ + .action = .end_search, + .title = "End Search", + .description = "End the current search if any and hide any GUI elements.", + }}, + + .navigate_search => comptime &.{ .{ + .action = .{ .navigate_search = .next }, + .title = "Next Search Result", + .description = "Navigate to the next search result, if any.", + }, .{ + .action = .{ .navigate_search = .previous }, + .title = "Previous Search Result", + .description = "Navigate to the previous search result, if any.", + } }, + .increase_font_size => comptime &.{.{ .action = .{ .increase_font_size = 1 }, .title = "Increase Font Size", @@ -605,6 +626,7 @@ fn actionCommands(action: Action.Key) []const Command { .esc, .cursor_key, .set_font_size, + .search, .scroll_to_row, .scroll_page_fractional, .scroll_page_lines, diff --git a/src/input/kitty.zig b/src/input/kitty.zig index 7ebbd7757..e5789cc40 100644 --- a/src/input/kitty.zig +++ b/src/input/kitty.zig @@ -1,4 +1,3 @@ -const std = @import("std"); const key = @import("key.zig"); /// A single entry in the kitty keymap data. There are only ~100 entries diff --git a/src/input/paste.zig b/src/input/paste.zig index 29787c385..111a783f3 100644 --- a/src/input/paste.zig +++ b/src/input/paste.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const Terminal = @import("../terminal/Terminal.zig"); pub const Options = struct { diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 3f9888841..86a7b473c 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -4,7 +4,7 @@ const Inspector = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const cimgui = @import("cimgui"); diff --git a/src/inspector/cell.zig b/src/inspector/cell.zig index b2dc59fef..2f72556bd 100644 --- a/src/inspector/cell.zig +++ b/src/inspector/cell.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const cimgui = @import("cimgui"); const terminal = @import("../terminal/main.zig"); diff --git a/src/inspector/cursor.zig b/src/inspector/cursor.zig index 37ec412e9..756898252 100644 --- a/src/inspector/cursor.zig +++ b/src/inspector/cursor.zig @@ -1,4 +1,3 @@ -const std = @import("std"); const cimgui = @import("cimgui"); const terminal = @import("../terminal/main.zig"); diff --git a/src/inspector/page.zig b/src/inspector/page.zig index 0b8609d5a..7da469e21 100644 --- a/src/inspector/page.zig +++ b/src/inspector/page.zig @@ -1,9 +1,7 @@ const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const cimgui = @import("cimgui"); const terminal = @import("../terminal/main.zig"); -const inspector = @import("main.zig"); const units = @import("units.zig"); pub fn render(page: *const terminal.Page) void { diff --git a/src/lib/union.zig b/src/lib/union.zig index 9fe5e999c..924d0e864 100644 --- a/src/lib/union.zig +++ b/src/lib/union.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const testing = std.testing; const Target = @import("target.zig").Target; diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 41fd1c71e..03a883e20 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -26,6 +26,7 @@ pub const point = terminal.point; pub const color = terminal.color; pub const device_status = terminal.device_status; pub const formatter = terminal.formatter; +pub const highlight = terminal.highlight; pub const kitty = terminal.kitty; pub const modes = terminal.modes; pub const page = terminal.page; @@ -47,6 +48,7 @@ pub const PageList = terminal.PageList; pub const Parser = terminal.Parser; pub const Pin = PageList.Pin; pub const Point = point.Point; +pub const RenderState = terminal.RenderState; pub const Screen = terminal.Screen; pub const ScreenSet = terminal.ScreenSet; pub const Selection = terminal.Selection; diff --git a/src/main_bench.zig b/src/main_bench.zig index 2314dc2ed..9804f51ef 100644 --- a/src/main_bench.zig +++ b/src/main_bench.zig @@ -1,5 +1,3 @@ -const std = @import("std"); -const builtin = @import("builtin"); const benchmark = @import("benchmark/main.zig"); pub const main = benchmark.cli.main; diff --git a/src/main_c.zig b/src/main_c.zig index d3fb753ef..9d48f376d 100644 --- a/src/main_c.zig +++ b/src/main_c.zig @@ -8,7 +8,7 @@ // it could be expanded to be general purpose in the future. const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("quirks.zig").inlineAssert; const posix = std.posix; const builtin = @import("builtin"); const build_config = @import("build_config.zig"); diff --git a/src/main_gen.zig b/src/main_gen.zig index b988819f8..3342bc2e9 100644 --- a/src/main_gen.zig +++ b/src/main_gen.zig @@ -1,5 +1,3 @@ -const std = @import("std"); -const builtin = @import("builtin"); const synthetic = @import("synthetic/main.zig"); pub const main = synthetic.cli.main; diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 77b7f3ef4..261e0ad7d 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -6,14 +6,8 @@ const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const posix = std.posix; const build_config = @import("build_config.zig"); -const options = @import("build_options"); -const glslang = @import("glslang"); const macos = @import("macos"); -const oni = @import("oniguruma"); const cli = @import("cli.zig"); -const internal_os = @import("os/main.zig"); -const fontconfig = @import("fontconfig"); -const harfbuzz = @import("harfbuzz"); const renderer = @import("renderer.zig"); const apprt = @import("apprt.zig"); diff --git a/src/os/TempDir.zig b/src/os/TempDir.zig index f2e9992c4..2ddf18da3 100644 --- a/src/os/TempDir.zig +++ b/src/os/TempDir.zig @@ -3,7 +3,6 @@ const TempDir = @This(); const std = @import("std"); -const builtin = @import("builtin"); const testing = std.testing; const Dir = std.fs.Dir; const allocTmpDir = @import("file.zig").allocTmpDir; diff --git a/src/os/args.zig b/src/os/args.zig index a531a418b..9ef5bba40 100644 --- a/src/os/args.zig +++ b/src/os/args.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const objc = @import("objc"); const macos = @import("macos"); diff --git a/src/os/cgroup.zig b/src/os/cgroup.zig index 4b5ccc4d3..a55732ca3 100644 --- a/src/os/cgroup.zig +++ b/src/os/cgroup.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const linux = std.os.linux; const posix = std.posix; const Allocator = std.mem.Allocator; diff --git a/src/os/flatpak.zig b/src/os/flatpak.zig index 7bd84bc27..78692089e 100644 --- a/src/os/flatpak.zig +++ b/src/os/flatpak.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const posix = std.posix; diff --git a/src/os/homedir.zig b/src/os/homedir.zig index f3d6e4498..0868a4fa5 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; const passwd = @import("passwd.zig"); const posix = std.posix; const objc = @import("objc"); diff --git a/src/os/locale.zig b/src/os/locale.zig index 92a63741f..742e1629b 100644 --- a/src/os/locale.zig +++ b/src/os/locale.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const macos = @import("macos"); const objc = @import("objc"); const internal_os = @import("main.zig"); diff --git a/src/os/macos.zig b/src/os/macos.zig index 100d0fe44..fcd1c3e5a 100644 --- a/src/os/macos.zig +++ b/src/os/macos.zig @@ -1,7 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const objc = @import("objc"); const Allocator = std.mem.Allocator; diff --git a/src/os/mouse.zig b/src/os/mouse.zig index fa39882c7..d68bb226f 100644 --- a/src/os/mouse.zig +++ b/src/os/mouse.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; const objc = @import("objc"); const log = std.log.scoped(.os); diff --git a/src/os/wasm/log.zig b/src/os/wasm/log.zig index 1aac8c4e7..faa885c6e 100644 --- a/src/os/wasm/log.zig +++ b/src/os/wasm/log.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); const wasm = @import("../wasm.zig"); // Use the correct implementation diff --git a/src/os/xdg.zig b/src/os/xdg.zig index e120ed857..a813b0a98 100644 --- a/src/os/xdg.zig +++ b/src/os/xdg.zig @@ -3,7 +3,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const posix = std.posix; const homedir = @import("homedir.zig"); diff --git a/src/quirks.zig b/src/quirks.zig index e3288afb6..5129923d2 100644 --- a/src/quirks.zig +++ b/src/quirks.zig @@ -27,3 +27,20 @@ pub fn disableDefaultFontFeatures(face: *const font.Face) bool { // error.OutOfMemory => return false, // }; } + +/// We use our own assert function instead of `std.debug.assert`. +/// +/// The only difference between this and the one in +/// the stdlib is that this version is marked inline. +/// +/// The reason for this is that, despite the promises of the doc comment +/// on the stdlib function, the function call to `std.debug.assert` isn't +/// always optimized away in `ReleaseFast` mode, at least in Zig 0.15.2. +/// +/// In the majority of places, the overhead from calling an empty function +/// is negligible, but we have some asserts inside tight loops and hotpaths +/// that cause significant overhead (as much as 15-20%) when they don't get +/// optimized out. +pub inline fn inlineAssert(ok: bool) void { + if (!ok) unreachable; +} diff --git a/src/renderer.zig b/src/renderer.zig index f09f717c4..2d37ddd4c 100644 --- a/src/renderer.zig +++ b/src/renderer.zig @@ -7,8 +7,6 @@ //! APIs. The renderers in this package assume that the renderer is already //! setup (OpenGL has a context, Vulkan has a surface, etc.) -const std = @import("std"); -const builtin = @import("builtin"); const build_config = @import("build_config.zig"); const cursor = @import("renderer/cursor.zig"); diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index f4201edcc..168f54c2b 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -2,7 +2,7 @@ pub const Metal = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const objc = @import("objc"); diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 673f79501..da577f957 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -2,7 +2,6 @@ pub const OpenGL = @This(); const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const gl = @import("opengl"); diff --git a/src/renderer/Options.zig b/src/renderer/Options.zig index 85ff8e310..948b31d2d 100644 --- a/src/renderer/Options.zig +++ b/src/renderer/Options.zig @@ -3,7 +3,6 @@ const apprt = @import("../apprt.zig"); const font = @import("../font/main.zig"); const renderer = @import("../renderer.zig"); -const Config = @import("../config.zig").Config; /// The derived configuration for this renderer implementation. config: renderer.Renderer.DerivedConfig, diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index fd9d0f51a..c1b377b3d 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -4,7 +4,6 @@ pub const Thread = @This(); const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; const xev = @import("../global.zig").xev; const crash = @import("../crash/main.zig"); const internal_os = @import("../os/main.zig"); @@ -451,6 +450,22 @@ fn drainMailbox(self: *Thread) !void { self.startDrawTimer(); }, + .search_viewport_matches => |v| { + // Note we don't free the new value because we expect our + // allocators to match. + if (self.renderer.search_matches) |*m| m.arena.deinit(); + self.renderer.search_matches = v; + self.renderer.search_matches_dirty = true; + }, + + .search_selected_match => |v| { + // Note we don't free the new value because we expect our + // allocators to match. + if (self.renderer.search_selected_match) |*m| m.arena.deinit(); + self.renderer.search_selected_match = v; + self.renderer.search_matches_dirty = true; + }, + .inspector => |v| self.flags.has_inspector = v, .macos_display_id => |v| { diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 1e371b07e..9e5802ea5 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const font = @import("../font/main.zig"); const terminal = @import("../terminal/main.zig"); const renderer = @import("../renderer.zig"); @@ -255,8 +255,12 @@ pub fn isSymbol(cp: u21) bool { /// Returns the appropriate `constraint_width` for /// the provided cell when rendering its glyph(s). -pub fn constraintWidth(cell_pin: terminal.Pin) u2 { - const cell = cell_pin.rowAndCell().cell; +pub fn constraintWidth( + raw_slice: []const terminal.page.Cell, + x: usize, + cols: usize, +) u2 { + const cell = raw_slice[x]; const cp = cell.codepoint(); const grid_width = cell.gridWidth(); @@ -271,20 +275,14 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { if (!isSymbol(cp)) return grid_width; // If we are at the end of the screen it must be constrained to one cell. - if (cell_pin.x == cell_pin.node.data.size.cols - 1) return 1; + if (x == cols - 1) return 1; // If we have a previous cell and it was a symbol then we need // to also constrain. This is so that multiple PUA glyphs align. // This does not apply if the previous symbol is a graphics // element such as a block element or Powerline glyph. - if (cell_pin.x > 0) { - const prev_cp = prev_cp: { - var copy = cell_pin; - copy.x -= 1; - const prev_cell = copy.rowAndCell().cell; - break :prev_cp prev_cell.codepoint(); - }; - + if (x > 0) { + const prev_cp = raw_slice[x - 1].codepoint(); if (isSymbol(prev_cp) and !isGraphicsElement(prev_cp)) { return 1; } @@ -292,15 +290,8 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { // If the next cell is whitespace, then we // allow the glyph to be up to two cells wide. - const next_cp = next_cp: { - var copy = cell_pin; - copy.x += 1; - const next_cell = copy.rowAndCell().cell; - break :next_cp next_cell.codepoint(); - }; - if (next_cp == 0 or isSpace(next_cp)) { - return 2; - } + const next_cp = raw_slice[x + 1].codepoint(); + if (next_cp == 0 or isSpace(next_cp)) return 2; // Otherwise, this has to be 1 cell wide. return 1; @@ -524,108 +515,171 @@ test "Cell constraint widths" { const testing = std.testing; const alloc = testing.allocator; - var s = try terminal.Screen.init(alloc, .{ .cols = 4, .rows = 1, .max_scrollback = 0 }); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 4, + .rows = 1, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); defer s.deinit(); + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + // for each case, the numbers in the comment denote expected // constraint widths for the symbol-containing cells // symbol->nothing: 2 { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // symbol->character: 1 { - try s.testWriteString("z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice("z"); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // symbol->space: 2 { - try s.testWriteString(" z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(" z"); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // symbol->no-break space: 1 { - try s.testWriteString("\u{00a0}z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice("\u{00a0}z"); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // symbol->end of row: 1 { - try s.testWriteString(" "); - const p3 = s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p3)); - s.reset(); + t.fullReset(); + try s.nextSlice(" "); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 3, + state.cols, + )); } // character->symbol: 2 { - try s.testWriteString("z"); - const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p1)); - s.reset(); + t.fullReset(); + try s.nextSlice("z"); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 1, + state.cols, + )); } // symbol->symbol: 1,1 { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p0)); - try testing.expectEqual(1, constraintWidth(p1)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 1, + state.cols, + )); } // symbol->space->symbol: 2,2 { - try s.testWriteString(" "); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const p2 = s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - try testing.expectEqual(2, constraintWidth(p2)); - s.reset(); + t.fullReset(); + try s.nextSlice(" "); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 2, + state.cols, + )); } // symbol->powerline: 1 (dedicated test because powerline is special-cased in cellpkg) { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // powerline->symbol: 2 (dedicated test because powerline is special-cased in cellpkg) { - try s.testWriteString(""); - const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p1)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 1, + state.cols, + )); } // powerline->nothing: 2 (dedicated test because powerline is special-cased in cellpkg) { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // powerline->space: 2 (dedicated test because powerline is special-cased in cellpkg) { - try s.testWriteString(" z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(" z"); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } } diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index ee79ead29..bfa92f31d 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -1,6 +1,5 @@ const std = @import("std"); const terminal = @import("../terminal/main.zig"); -const State = @import("State.zig"); /// Available cursor styles for drawing that renderers must support. /// This is a superset of terminal cursor styles since the renderer supports @@ -26,64 +25,65 @@ pub const Style = enum { } }; +pub const StyleOptions = struct { + preedit: bool = false, + focused: bool = false, + blink_visible: bool = false, +}; + /// Returns the cursor style to use for the current render state or null /// if a cursor should not be rendered at all. pub fn style( - state: *State, - focused: bool, - blink_visible: bool, + state: *const terminal.RenderState, + opts: StyleOptions, ) ?Style { // Note the order of conditionals below is important. It represents // a priority system of how we determine what state overrides cursor // visibility and style. - // The cursor is only at the bottom of the viewport. If we aren't - // at the bottom, we never render the cursor. The cursor x/y is by - // viewport so if we are above the viewport, we'll end up rendering - // the cursor in some random part of the screen. - if (!state.terminal.screens.active.viewportIsBottom()) return null; + // The cursor must be visible in the viewport to be rendered. + if (state.cursor.viewport == null) return null; // If we are in preedit, then we always show the block cursor. We do // this even if the cursor is explicitly not visible because it shows // an important editing state to the user. - if (state.preedit != null) return .block; + if (opts.preedit) return .block; + + // If we're at a password input its always a lock. + if (state.cursor.password_input) return .lock; // If the cursor is explicitly not visible by terminal mode, we don't render. - if (!state.terminal.modes.get(.cursor_visible)) return null; + if (!state.cursor.visible) return null; // If we're not focused, our cursor is always visible so that // we can show the hollow box. - if (!focused) return .block_hollow; + if (!opts.focused) return .block_hollow; // If the cursor is blinking and our blink state is not visible, // then we don't show the cursor. - if (state.terminal.modes.get(.cursor_blinking) and !blink_visible) { - return null; - } + if (state.cursor.blinking and !opts.blink_visible) return null; // Otherwise, we use whatever style the terminal wants. - return .fromTerminal(state.terminal.screens.active.cursor.cursor_style); + return .fromTerminal(state.cursor.visual_style); } test "cursor: default uses configured style" { const testing = std.testing; const alloc = testing.allocator; - var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); + var term: terminal.Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); term.screens.active.cursor.cursor_style = .bar; term.modes.set(.cursor_blinking, true); - var state: State = .{ - .mutex = undefined, - .terminal = &term, - .preedit = null, - }; + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &term); - try testing.expect(style(&state, true, true) == .bar); - try testing.expect(style(&state, false, true) == .block_hollow); - try testing.expect(style(&state, false, false) == .block_hollow); - try testing.expect(style(&state, true, false) == null); + try testing.expect(style(&state, .{ .preedit = false, .focused = true, .blink_visible = true }) == .bar); + try testing.expect(style(&state, .{ .preedit = false, .focused = false, .blink_visible = true }) == .block_hollow); + try testing.expect(style(&state, .{ .preedit = false, .focused = false, .blink_visible = false }) == .block_hollow); + try testing.expect(style(&state, .{ .preedit = false, .focused = true, .blink_visible = false }) == null); } test "cursor: blinking disabled" { @@ -95,16 +95,14 @@ test "cursor: blinking disabled" { term.screens.active.cursor.cursor_style = .bar; term.modes.set(.cursor_blinking, false); - var state: State = .{ - .mutex = undefined, - .terminal = &term, - .preedit = null, - }; + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &term); - try testing.expect(style(&state, true, true) == .bar); - try testing.expect(style(&state, true, false) == .bar); - try testing.expect(style(&state, false, true) == .block_hollow); - try testing.expect(style(&state, false, false) == .block_hollow); + try testing.expect(style(&state, .{ .focused = true, .blink_visible = true }) == .bar); + try testing.expect(style(&state, .{ .focused = true, .blink_visible = false }) == .bar); + try testing.expect(style(&state, .{ .focused = false, .blink_visible = true }) == .block_hollow); + try testing.expect(style(&state, .{ .focused = false, .blink_visible = false }) == .block_hollow); } test "cursor: explicitly not visible" { @@ -117,16 +115,14 @@ test "cursor: explicitly not visible" { term.modes.set(.cursor_visible, false); term.modes.set(.cursor_blinking, false); - var state: State = .{ - .mutex = undefined, - .terminal = &term, - .preedit = null, - }; + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &term); - try testing.expect(style(&state, true, true) == null); - try testing.expect(style(&state, true, false) == null); - try testing.expect(style(&state, false, true) == null); - try testing.expect(style(&state, false, false) == null); + try testing.expect(style(&state, .{ .focused = true, .blink_visible = true }) == null); + try testing.expect(style(&state, .{ .focused = true, .blink_visible = false }) == null); + try testing.expect(style(&state, .{ .focused = false, .blink_visible = true }) == null); + try testing.expect(style(&state, .{ .focused = false, .blink_visible = false }) == null); } test "cursor: always block with preedit" { @@ -135,25 +131,24 @@ test "cursor: always block with preedit" { var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); - var state: State = .{ - .mutex = undefined, - .terminal = &term, - .preedit = .{}, - }; + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &term); // In any bool state - try testing.expect(style(&state, false, false) == .block); - try testing.expect(style(&state, true, false) == .block); - try testing.expect(style(&state, true, true) == .block); - try testing.expect(style(&state, false, true) == .block); + try testing.expect(style(&state, .{ .preedit = true, .focused = false, .blink_visible = false }) == .block); + try testing.expect(style(&state, .{ .preedit = true, .focused = true, .blink_visible = false }) == .block); + try testing.expect(style(&state, .{ .preedit = true, .focused = true, .blink_visible = true }) == .block); + try testing.expect(style(&state, .{ .preedit = true, .focused = false, .blink_visible = true }) == .block); // If we're scrolled though, then we don't show the cursor. for (0..100) |_| try term.index(); try term.scrollViewport(.{ .top = {} }); + try state.update(alloc, &term); // In any bool state - try testing.expect(style(&state, false, false) == null); - try testing.expect(style(&state, true, false) == null); - try testing.expect(style(&state, true, true) == null); - try testing.expect(style(&state, false, true) == null); + try testing.expect(style(&state, .{ .preedit = true, .focused = false, .blink_visible = false }) == null); + try testing.expect(style(&state, .{ .preedit = true, .focused = true, .blink_visible = false }) == null); + try testing.expect(style(&state, .{ .preedit = true, .focused = true, .blink_visible = true }) == null); + try testing.expect(style(&state, .{ .preedit = true, .focused = false, .blink_visible = true }) == null); } diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 912dcc457..8c55da602 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -5,6 +5,7 @@ const wuffs = @import("wuffs"); const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); const font = @import("../font/main.zig"); +const inputpkg = @import("../input.zig"); const os = @import("../os/main.zig"); const terminal = @import("../terminal/main.zig"); const renderer = @import("../renderer.zig"); @@ -15,12 +16,13 @@ const cellpkg = @import("cell.zig"); const noMinContrast = cellpkg.noMinContrast; const constraintWidth = cellpkg.constraintWidth; const isCovering = cellpkg.isCovering; +const rowNeverExtendBg = @import("row.zig").neverExtendBg; const imagepkg = @import("image.zig"); const Image = imagepkg.Image; const ImageMap = imagepkg.ImageMap; const ImagePlacementList = std.ArrayListUnmanaged(imagepkg.Placement); const shadertoy = @import("shadertoy.zig"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const Terminal = terminal.Terminal; @@ -120,17 +122,22 @@ pub fn Renderer(comptime GraphicsAPI: type) type { scrollbar: terminal.Scrollbar, scrollbar_dirty: bool, + /// The most recent viewport matches so that we can render search + /// matches in the visible frame. This is provided asynchronously + /// from the search thread so we have the dirty flag to also note + /// if we need to rebuild our cells to include search highlights. + /// + /// Note that the selections MAY BE INVALID (point to PageList nodes + /// that do not exist anymore). These must be validated prior to use. + search_matches: ?renderer.Message.SearchMatches, + search_selected_match: ?renderer.Message.SearchMatch, + search_matches_dirty: bool, + /// The current set of cells to render. This is rebuilt on every frame /// but we keep this around so that we don't reallocate. Each set of /// cells goes into a separate shader. cells: cellpkg.Contents, - /// The last viewport that we based our rebuild off of. If this changes, - /// then we do a full rebuild of the cells. The pointer values in this pin - /// are NOT SAFE to read because they may be modified, freed, etc from the - /// termio thread. We treat the pointers as integers for comparison only. - cells_viewport: ?terminal.Pin = null, - /// Set to true after rebuildCells is called. This can be used /// to determine if any possible changes have been made to the /// cells for the draw call. @@ -207,6 +214,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Our shader pipelines. shaders: Shaders, + /// The render state we update per loop. + terminal_state: terminal.RenderState = .empty, + + /// The number of frames since the last terminal state reset. + /// We reset the terminal state after ~100,000 frames (about 10 to + /// 15 minutes at 120Hz) to prevent wasted memory buildup from + /// a large screen. + terminal_state_frame_count: usize = 0, + + const HighlightTag = enum(u8) { + search_match, + search_match_selected, + }; + /// Swap chain which maintains multiple copies of the state needed to /// render a frame, so that we can start building the next frame while /// the previous frame is still being processed on the GPU. @@ -522,6 +543,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { foreground: terminal.color.RGB, selection_background: ?configpkg.Config.TerminalColor, selection_foreground: ?configpkg.Config.TerminalColor, + search_background: configpkg.Config.TerminalColor, + search_foreground: configpkg.Config.TerminalColor, + search_selected_background: configpkg.Config.TerminalColor, + search_selected_foreground: configpkg.Config.TerminalColor, bold_color: ?configpkg.BoldColor, faint_opacity: u8, min_contrast: f32, @@ -593,6 +618,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .selection_background = config.@"selection-background", .selection_foreground = config.@"selection-foreground", + .search_background = config.@"search-background", + .search_foreground = config.@"search-foreground", + .search_selected_background = config.@"search-selected-background", + .search_selected_foreground = config.@"search-selected-foreground", .custom_shaders = custom_shaders, .bg_image = bg_image, @@ -667,6 +696,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .focused = true, .scrollbar = .zero, .scrollbar_dirty = false, + .search_matches = null, + .search_selected_match = null, + .search_matches_dirty = false, // Render state .cells = .{}, @@ -738,6 +770,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } pub fn deinit(self: *Self) void { + self.terminal_state.deinit(self.alloc); + if (self.search_selected_match) |*m| m.arena.deinit(); + if (self.search_matches) |*m| m.arena.deinit(); self.swap_chain.deinit(); if (DisplayLink != void) { @@ -935,8 +970,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } /// Mark the full screen as dirty so that we redraw everything. - pub fn markDirty(self: *Self) void { - self.cells_viewport = null; + pub inline fn markDirty(self: *Self) void { + self.terminal_state.dirty = .full; } /// Called when we get an updated display ID for our display link. @@ -1042,7 +1077,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Force a full rebuild, because cached rows may still reference // an outdated atlas from the old grid and this can cause garbage // to be rendered. - self.cells_viewport = null; + self.markDirty(); } /// Update uniforms that are based on the font grid. @@ -1061,21 +1096,29 @@ pub fn Renderer(comptime GraphicsAPI: type) type { state: *renderer.State, cursor_blink_visible: bool, ) !void { + // We fully deinit and reset the terminal state every so often + // so that a particularly large terminal state doesn't cause + // the renderer to hold on to retained memory. + // + // Frame count is ~12 minutes at 120Hz. + const max_terminal_state_frame_count = 100_000; + if (self.terminal_state_frame_count >= max_terminal_state_frame_count) { + self.terminal_state.deinit(self.alloc); + self.terminal_state = .empty; + } + self.terminal_state_frame_count += 1; + + // Create an arena for all our temporary allocations while rebuilding + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const arena_alloc = arena.allocator(); + // Data we extract out of the critical area. const Critical = struct { - bg: terminal.color.RGB, - fg: terminal.color.RGB, - screen: terminal.Screen, - screen_type: terminal.ScreenSet.Key, + links: terminal.RenderState.CellSet, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, - cursor_color: ?terminal.color.RGB, - cursor_style: ?renderer.CursorStyle, - color_palette: terminal.color.Palette, scrollbar: terminal.Scrollbar, - - /// If true, rebuild the full screen. - full_rebuild: bool, }; // Update all our data as tightly as possible within the mutex. @@ -1084,8 +1127,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // const start_micro = std.time.microTimestamp(); // defer { // const end = std.time.Instant.now() catch unreachable; - // // "[updateFrame critical time] \t" - // std.log.err("[updateFrame critical time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us}); + // std.log.err("[updateFrame critical time] start={}\tduration={} us", .{ start_micro, end.since(start) / std.time.ns_per_us }); // } state.mutex.lock(); @@ -1097,6 +1139,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { return; } + // Update our terminal state + try self.terminal_state.update(self.alloc, state.terminal); + + // If our terminal state is dirty at all we need to redo + // the viewport search. + if (self.terminal_state.dirty != .false) { + state.terminal.flags.search_viewport_dirty = true; + } + // Get our scrollbar out of the terminal. We synchronize // the scrollbar read with frame data updates because this // naturally limits the number of calls to this method (it @@ -1104,47 +1155,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // cross-thread mailbox message within the IO path. const scrollbar = state.terminal.screens.active.pages.scrollbar(); - // Get our bg/fg, swap them if reversed. - const RGB = terminal.color.RGB; - const bg: RGB, const fg: RGB = colors: { - const bg = state.terminal.colors.background.get().?; - const fg = state.terminal.colors.foreground.get().?; - break :colors if (state.terminal.modes.get(.reverse_colors)) - .{ fg, bg } - else - .{ bg, fg }; - }; - - // Get the viewport pin so that we can compare it to the current. - const viewport_pin = state.terminal.screens.active.pages.pin(.{ .viewport = .{} }).?; - - // We used to share terminal state, but we've since learned through - // analysis that it is faster to copy the terminal state than to - // hold the lock while rebuilding GPU cells. - var screen_copy = try state.terminal.screens.active.clone( - self.alloc, - .{ .viewport = .{} }, - null, - ); - errdefer screen_copy.deinit(); - - // Whether to draw our cursor or not. - const cursor_style = if (state.terminal.flags.password_input) - .lock - else - renderer.cursorStyle( - state, - self.focused, - cursor_blink_visible, - ); - // Get our preedit state const preedit: ?renderer.State.Preedit = preedit: { - if (cursor_style == null) break :preedit null; const p = state.preedit orelse break :preedit null; - break :preedit try p.clone(self.alloc); + break :preedit try p.clone(arena_alloc); }; - errdefer if (preedit) |p| p.deinit(self.alloc); // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path. // We only do this if the Kitty image state is dirty meaning only if @@ -1159,81 +1174,101 @@ pub fn Renderer(comptime GraphicsAPI: type) type { try self.prepKittyGraphics(state.terminal); } - // If we have any terminal dirty flags set then we need to rebuild - // the entire screen. This can be optimized in the future. - const full_rebuild: bool = rebuild: { - { - const Int = @typeInfo(terminal.Terminal.Dirty).@"struct".backing_integer.?; - const v: Int = @bitCast(state.terminal.flags.dirty); - if (v > 0) break :rebuild true; - } - { - const Int = @typeInfo(terminal.Screen.Dirty).@"struct".backing_integer.?; - const v: Int = @bitCast(state.terminal.screens.active.dirty); - if (v > 0) break :rebuild true; - } + // Get our OSC8 links we're hovering if we have a mouse. + // This requires terminal state because of URLs. + const links: terminal.RenderState.CellSet = osc8: { + // If our mouse isn't hovering, we have no links. + const vp = state.mouse.point orelse break :osc8 .empty; - // If our viewport changed then we need to rebuild the entire - // screen because it means we scrolled. If we have no previous - // viewport then we must rebuild. - const prev_viewport = self.cells_viewport orelse break :rebuild true; - if (!prev_viewport.eql(viewport_pin)) break :rebuild true; + // If the right mods aren't pressed, then we can't match. + if (!state.mouse.mods.equal(inputpkg.ctrlOrSuper(.{}))) + break :osc8 .empty; - break :rebuild false; + break :osc8 self.terminal_state.linkCells( + arena_alloc, + vp, + ) catch |err| { + log.warn("error searching for OSC8 links err={}", .{err}); + break :osc8 .empty; + }; }; - // Reset the dirty flags in the terminal and screen. We assume - // that our rebuild will be successful since so we optimize for - // success and reset while we hold the lock. This is much easier - // than coordinating row by row or as changes are persisted. - state.terminal.flags.dirty = .{}; - state.terminal.screens.active.dirty = .{}; - { - var it = state.terminal.screens.active.pages.pageIterator( - .right_down, - .{ .screen = .{} }, - null, - ); - while (it.next()) |chunk| { - var dirty_set = chunk.node.data.dirtyBitSet(); - dirty_set.unsetAll(); - } - } - - // Update our viewport pin - self.cells_viewport = viewport_pin; - break :critical .{ - .bg = bg, - .fg = fg, - .screen = screen_copy, - .screen_type = state.terminal.screens.active_key, + .links = links, .mouse = state.mouse, .preedit = preedit, - .cursor_color = state.terminal.colors.cursor.get(), - .cursor_style = cursor_style, - .color_palette = state.terminal.colors.palette.current, .scrollbar = scrollbar, - .full_rebuild = full_rebuild, }; }; - defer { - critical.screen.deinit(); - if (critical.preedit) |p| p.deinit(self.alloc); + + // Outside the critical area we can update our links to contain + // our regex results. + self.config.links.renderCellMap( + arena_alloc, + &critical.links, + &self.terminal_state, + state.mouse.point, + state.mouse.mods, + ) catch |err| { + log.warn("error searching for regex links err={}", .{err}); + }; + + // Clear our highlight state and update. + if (self.search_matches_dirty or self.terminal_state.dirty != .false) { + self.search_matches_dirty = false; + + // Clear the prior highlights + const row_data = self.terminal_state.row_data.slice(); + var any_dirty: bool = false; + for ( + row_data.items(.highlights), + row_data.items(.dirty), + ) |*highlights, *dirty| { + if (highlights.items.len > 0) { + highlights.clearRetainingCapacity(); + dirty.* = true; + any_dirty = true; + } + } + if (any_dirty and self.terminal_state.dirty == .false) { + self.terminal_state.dirty = .partial; + } + + // NOTE: The order below matters. Highlights added earlier + // will take priority. + + if (self.search_selected_match) |m| { + self.terminal_state.updateHighlightsFlattened( + self.alloc, + @intFromEnum(HighlightTag.search_match_selected), + &.{m.match}, + ) catch |err| { + // Not a critical error, we just won't show highlights. + log.warn("error updating search selected highlight err={}", .{err}); + }; + } + + if (self.search_matches) |m| { + self.terminal_state.updateHighlightsFlattened( + self.alloc, + @intFromEnum(HighlightTag.search_match), + m.matches, + ) catch |err| { + // Not a critical error, we just won't show highlights. + log.warn("error updating search highlights err={}", .{err}); + }; + } } // Build our GPU cells try self.rebuildCells( - critical.full_rebuild, - &critical.screen, - critical.screen_type, - critical.mouse, critical.preedit, - critical.cursor_style, - &critical.color_palette, - critical.bg, - critical.fg, - critical.cursor_color, + renderer.cursorStyle(&self.terminal_state, .{ + .preedit = critical.preedit != null, + .focused = self.focused, + .blink_visible = cursor_blink_visible, + }), + &critical.links, ); // Notify our shaper we're done for the frame. For some shapers, @@ -1255,9 +1290,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Update our background color self.uniforms.bg_color = .{ - critical.bg.r, - critical.bg.g, - critical.bg.b, + self.terminal_state.colors.background.r, + self.terminal_state.colors.background.g, + self.terminal_state.colors.background.b, @intFromFloat(@round(self.config.background_opacity * 255.0)), }; } @@ -2089,7 +2124,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (bg_image_config_changed) self.updateBgImageBuffer(); // Reset our viewport to force a rebuild, in case of a font change. - self.cells_viewport = null; + self.markDirty(); const blending_changed = old_blending != config.blending; @@ -2315,17 +2350,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// memory and doesn't touch the GPU. fn rebuildCells( self: *Self, - wants_rebuild: bool, - screen: *terminal.Screen, - screen_type: terminal.ScreenSet.Key, - mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, - color_palette: *const terminal.color.Palette, - background: terminal.color.RGB, - foreground: terminal.color.RGB, - terminal_cursor_color: ?terminal.color.RGB, + links: *const terminal.RenderState.CellSet, ) !void { + const state: *terminal.RenderState = &self.terminal_state; + defer state.dirty = .false; + self.draw_mutex.lock(); defer self.draw_mutex.unlock(); @@ -2337,21 +2368,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us}); // } - _ = screen_type; // we might use this again later so not deleting it yet - - // Create an arena for all our temporary allocations while rebuilding - var arena = ArenaAllocator.init(self.alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); - - // Create our match set for the links. - var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( - arena_alloc, - screen, - mouse_pt, - mouse.mods, - ) else .{}; - // Determine our x/y range for preedit. We don't want to render anything // here because we will render the preedit separately. const preedit_range: ?struct { @@ -2359,22 +2375,31 @@ pub fn Renderer(comptime GraphicsAPI: type) type { x: [2]terminal.size.CellCountInt, cp_offset: usize, } = if (preedit) |preedit_v| preedit: { - const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1); + // We base the preedit on the position of the cursor in the + // viewport. If the cursor isn't visible in the viewport we + // don't show it. + const cursor_vp = state.cursor.viewport orelse + break :preedit null; + + const range = preedit_v.range( + cursor_vp.x, + state.cols - 1, + ); break :preedit .{ - .y = screen.cursor.y, + .y = @intCast(cursor_vp.y), .x = .{ range.start, range.end }, .cp_offset = range.cp_offset, }; } else null; const grid_size_diff = - self.cells.size.rows != screen.pages.rows or - self.cells.size.columns != screen.pages.cols; + self.cells.size.rows != state.rows or + self.cells.size.columns != state.cols; if (grid_size_diff) { var new_size = self.cells.size; - new_size.rows = screen.pages.rows; - new_size.columns = screen.pages.cols; + new_size.rows = state.rows; + new_size.columns = state.cols; try self.cells.resize(self.alloc, new_size); // Update our uniforms accordingly, otherwise @@ -2382,8 +2407,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.uniforms.grid_size = .{ new_size.columns, new_size.rows }; } - const rebuild = wants_rebuild or grid_size_diff; - + const rebuild = state.dirty == .full or grid_size_diff; if (rebuild) { // If we are doing a full rebuild, then we clear the entire cell buffer. self.cells.reset(); @@ -2405,45 +2429,49 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - // We rebuild the cells row-by-row because we - // do font shaping and dirty tracking by row. - var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null); + // Get our row data from our state + const row_data = state.row_data.slice(); + const row_raws = row_data.items(.raw); + const row_cells = row_data.items(.cells); + const row_dirty = row_data.items(.dirty); + const row_selection = row_data.items(.selection); + const row_highlights = row_data.items(.highlights); + // If our cell contents buffer is shorter than the screen viewport, // we render the rows that fit, starting from the bottom. If instead // the viewport is shorter than the cell contents buffer, we align // the top of the viewport with the top of the contents buffer. - var y: terminal.size.CellCountInt = @min( - screen.pages.rows, + const row_len: usize = @min( + state.rows, self.cells.size.rows, ); - while (row_it.next()) |row| { - // The viewport may have more rows than our cell contents, - // so we need to break from the loop early if we hit y = 0. - if (y == 0) break; - - y -= 1; + for ( + 0.., + row_raws[0..row_len], + row_cells[0..row_len], + row_dirty[0..row_len], + row_selection[0..row_len], + row_highlights[0..row_len], + ) |y_usize, row, *cells, *dirty, selection, highlights| { + const y: terminal.size.CellCountInt = @intCast(y_usize); if (!rebuild) { // Only rebuild if we are doing a full rebuild or this row is dirty. - if (!row.isDirty()) continue; + if (!dirty.*) continue; // Clear the cells if the row is dirty self.cells.clear(y); } - // True if we want to do font shaping around the cursor. - // We want to do font shaping as long as the cursor is enabled. - const shape_cursor = screen.viewportIsBottom() and - y == screen.cursor.y; + // Unmark the dirty state in our render state. + dirty.* = false; - // We need to get this row's selection, if - // there is one, for proper run splitting. - const row_selection = sel: { - const sel = screen.selection orelse break :sel null; - const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse - break :sel null; - break :sel sel.containedRow(screen, pin) orelse null; - }; + // If our viewport is wider than our cell contents buffer, + // we still only process cells up to the width of the buffer. + const cells_slice = cells.slice(); + const cells_len = @min(cells_slice.len, self.cells.size.columns); + const cells_raw = cells_slice.items(.raw); + const cells_style = cells_slice.items(.style); // On primary screen, we still apply vertical padding // extension under certain conditions we feel are safe. @@ -2456,14 +2484,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Apply heuristics for padding extension. .extend => if (y == 0) { - self.uniforms.padding_extend.up = !row.neverExtendBg( - color_palette, - background, + self.uniforms.padding_extend.up = !rowNeverExtendBg( + row, + cells_raw, + cells_style, + &state.colors.palette, + state.colors.background, ); } else if (y == self.cells.size.rows - 1) { - self.uniforms.padding_extend.down = !row.neverExtendBg( - color_palette, - background, + self.uniforms.padding_extend.down = !rowNeverExtendBg( + row, + cells_raw, + cells_style, + &state.colors.palette, + state.colors.background, ); }, } @@ -2471,10 +2505,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Iterator of runs for shaping. var run_iter_opts: font.shape.RunOptions = .{ .grid = self.font_grid, - .screen = screen, - .row = row, - .selection = row_selection, - .cursor_x = if (shape_cursor) screen.cursor.x else null, + .cells = cells_slice, + .selection = if (selection) |s| s else null, + + // We want to do font shaping as long as the cursor is + // visible on this viewport. + .cursor_x = cursor_x: { + const vp = state.cursor.viewport orelse break :cursor_x null; + if (vp.y != y) break :cursor_x null; + break :cursor_x vp.x; + }, }; run_iter_opts.applyBreakConfig(self.config.font_shaping_break); var run_iter = self.font_shaper.runIterator(run_iter_opts); @@ -2482,13 +2522,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { var shaper_cells: ?[]const font.shape.Cell = null; var shaper_cells_i: usize = 0; - const row_cells_all = row.cells(.all); - - // If our viewport is wider than our cell contents buffer, - // we still only process cells up to the width of the buffer. - const row_cells = row_cells_all[0..@min(row_cells_all.len, self.cells.size.columns)]; - - for (row_cells, 0..) |*cell, x| { + for ( + 0.., + cells_raw[0..cells_len], + cells_style[0..cells_len], + ) |x, *cell, *managed_style| { // If this cell falls within our preedit range then we // skip this because preedits are setup separately. if (preedit_range) |range| preedit: { @@ -2521,7 +2559,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.font_shaper_cache.get(run) orelse cache: { // Otherwise we have to shape them. - const cells = try self.font_shaper.shape(run); + const new_cells = try self.font_shaper.shape(run); // Try to cache them. If caching fails for any reason we // continue because it is just a performance optimization, @@ -2529,7 +2567,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.font_shaper_cache.put( self.alloc, run, - cells, + new_cells, ) catch |err| { log.warn( "error caching font shaping results err={}", @@ -2540,75 +2578,100 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // The cells we get from direct shaping are always owned // by the shaper and valid until the next shaping call so // we can safely use them. - break :cache cells; + break :cache new_cells; }; - const cells = shaper_cells.?; - // Advance our index until we reach or pass // our current x position in the shaper cells. - while (run.offset + cells[shaper_cells_i].x < x) { + const shaper_cells_unwrapped = shaper_cells.?; + while (run.offset + shaper_cells_unwrapped[shaper_cells_i].x < x) { shaper_cells_i += 1; } } const wide = cell.wide; - - const style = row.style(cell); - - const cell_pin: terminal.Pin = cell: { - var copy = row; - copy.x = @intCast(x); - break :cell copy; - }; + const style: terminal.Style = if (cell.hasStyling()) + managed_style.* + else + .{}; // True if this cell is selected - const selected: bool = if (screen.selection) |sel| - sel.contains(screen, .{ - .node = row.node, - .y = row.y, - .x = @intCast( - // Spacer tails should show the selection - // state of the wide cell they belong to. - if (wide == .spacer_tail) - x -| 1 - else - x, - ), - }) - else - false; + const selected: enum { + false, + selection, + search, + search_selected, + } = selected: { + // Order below matters for precedence. + + // Selection should take the highest precedence. + const x_compare = if (wide == .spacer_tail) + x -| 1 + else + x; + if (selection) |sel| { + if (x_compare >= sel[0] and + x_compare <= sel[1]) break :selected .selection; + } + + // If we're highlighted, then we're selected. In the + // future we want to use a different style for this + // but this to get started. + for (highlights.items) |hl| { + if (x_compare >= hl.range[0] and + x_compare <= hl.range[1]) + { + const tag: HighlightTag = @enumFromInt(hl.tag); + break :selected switch (tag) { + .search_match => .search, + .search_match_selected => .search_selected, + }; + } + } + + break :selected .false; + }; // The `_style` suffixed values are the colors based on // the cell style (SGR), before applying any additional // configuration, inversions, selections, etc. - const bg_style = style.bg(cell, color_palette); + const bg_style = style.bg( + cell, + &state.colors.palette, + ); const fg_style = style.fg(.{ - .default = foreground, - .palette = color_palette, + .default = state.colors.foreground, + .palette = &state.colors.palette, .bold = self.config.bold_color, }); // The final background color for the cell. - const bg = bg: { - if (selected) { - // If we have an explicit selection background color - // specified int he config, use that - if (self.config.selection_background) |v| { - break :bg switch (v) { - .color => |color| color.toTerminalRGB(), - .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, - .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, - }; - } + const bg = switch (selected) { + // If we have an explicit selection background color + // specified in the config, use that. + // + // If no configuration, then our selection background + // is our foreground color. + .selection => if (self.config.selection_background) |v| switch (v) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + } else state.colors.foreground, - // If no configuration, then our selection background - // is our foreground color. - break :bg foreground; - } + .search => switch (self.config.search_background) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + }, + + .search_selected => switch (self.config.search_selected_background) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + }, // Not selected - break :bg if (style.flags.inverse != isCovering(cell.codepoint())) + .false => if (style.flags.inverse != isCovering(cell.codepoint())) // Two cases cause us to invert (use the fg color as the bg) // - The "inverse" style flag. // - A "covering" glyph; we use fg for bg in that @@ -2620,35 +2683,42 @@ pub fn Renderer(comptime GraphicsAPI: type) type { fg_style else // Otherwise they cancel out. - bg_style; + bg_style, }; const fg = fg: { // Our happy-path non-selection background color // is our style or our configured defaults. - const final_bg = bg_style orelse background; + const final_bg = bg_style orelse state.colors.background; // Whether we need to use the bg color as our fg color: // - Cell is selected, inverted, and set to cell-foreground // - Cell is selected, not inverted, and set to cell-background // - Cell is inverted and not selected - if (selected) { - // Use the selection foreground if set - if (self.config.selection_foreground) |v| { - break :fg switch (v) { - .color => |color| color.toTerminalRGB(), - .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, - .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, - }; - } + break :fg switch (selected) { + .selection => if (self.config.selection_foreground) |v| switch (v) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + } else state.colors.background, - break :fg background; - } + .search => switch (self.config.search_foreground) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + }, - break :fg if (style.flags.inverse) - final_bg - else - fg_style; + .search_selected => switch (self.config.search_selected_foreground) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + }, + + .false => if (style.flags.inverse) + final_bg + else + fg_style, + }; }; // Foreground alpha for this cell. @@ -2656,7 +2726,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Set the cell's background color. { - const rgb = bg orelse background; + const rgb = bg orelse state.colors.background; // Determine our background alpha. If we have transparency configured // then this is dynamic depending on some situations. This is all @@ -2666,7 +2736,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const default: u8 = 255; // Cells that are selected should be fully opaque. - if (selected) break :bg_alpha default; + if (selected != .false) break :bg_alpha default; // Cells that are reversed should be fully opaque. if (style.flags.inverse) break :bg_alpha default; @@ -2707,13 +2777,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Give links a single underline, unless they already have // an underline, in which case use a double underline to // distinguish them. - const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin)) - if (style.flags.underline == .single) - .double - else - .single - else - style.flags.underline; + const underline: terminal.Attribute.Underline = underline: { + if (links.contains(.{ + .x = @intCast(x), + .y = @intCast(y), + })) { + break :underline if (style.flags.underline == .single) + .double + else + .single; + } + break :underline style.flags.underline; + }; // We draw underlines first so that they layer underneath text. // This improves readability when a colored underline is used @@ -2722,7 +2797,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { @intCast(x), @intCast(y), underline, - style.underlineColor(color_palette) orelse fg, + style.underlineColor(&state.colors.palette) orelse fg, alpha, ) catch |err| { log.warn( @@ -2753,7 +2828,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.font_shaper_cache.get(run) orelse cache: { // Otherwise we have to shape them. - const cells = try self.font_shaper.shape(run); + const new_cells = try self.font_shaper.shape(run); // Try to cache them. If caching fails for any reason we // continue because it is just a performance optimization, @@ -2761,7 +2836,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.font_shaper_cache.put( self.alloc, run, - cells, + new_cells, ) catch |err| { log.warn( "error caching font shaping results err={}", @@ -2772,32 +2847,34 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // The cells we get from direct shaping are always owned // by the shaper and valid until the next shaping call so // we can safely use them. - break :cache cells; + break :cache new_cells; }; - const cells = shaper_cells orelse break :glyphs; + const shaped_cells = shaper_cells orelse break :glyphs; // If there are no shaper cells for this run, ignore it. // This can occur for runs of empty cells, and is fine. - if (cells.len == 0) break :glyphs; + if (shaped_cells.len == 0) break :glyphs; // If we encounter a shaper cell to the left of the current // cell then we have some problems. This logic relies on x // position monotonically increasing. - assert(run.offset + cells[shaper_cells_i].x >= x); + assert(run.offset + shaped_cells[shaper_cells_i].x >= x); // NOTE: An assumption is made here that a single cell will never // be present in more than one shaper run. If that assumption is // violated, this logic breaks. - while (shaper_cells_i < cells.len and run.offset + cells[shaper_cells_i].x == x) : ({ + while (shaper_cells_i < shaped_cells.len and + run.offset + shaped_cells[shaper_cells_i].x == x) : ({ shaper_cells_i += 1; }) { self.addGlyph( @intCast(x), @intCast(y), - cell_pin, - cells[shaper_cells_i], + state.cols, + cells_raw, + shaped_cells[shaper_cells_i], shaper_run.?, fg, alpha, @@ -2827,65 +2904,91 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Setup our cursor rendering information. cursor: { - // By default, we don't handle cursor inversion on the shader. + // Clear our cursor by default. self.cells.setCursor(null, null); self.uniforms.cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16), }; + // If the cursor isn't visible on the viewport, don't show + // a cursor. Otherwise, get our cursor cell, because we may + // need it for styling. + const cursor_vp = state.cursor.viewport orelse break :cursor; + const cursor_style: terminal.Style = cursor_style: { + const cells = state.row_data.items(.cells); + const cell = cells[cursor_vp.y].get(cursor_vp.x); + break :cursor_style if (cell.raw.hasStyling()) + cell.style + else + .{}; + }; + // If we have preedit text, we don't setup a cursor if (preedit != null) break :cursor; - // Prepare the cursor cell contents. + // If there isn't a cursor visual style requested then + // we don't render a cursor. const style = cursor_style_ orelse break :cursor; + + // Determine the cursor color. const cursor_color = cursor_color: { // If an explicit cursor color was set by OSC 12, use that. - if (terminal_cursor_color) |v| break :cursor_color v; + if (state.colors.cursor) |v| break :cursor_color v; // Use our configured color if specified if (self.config.cursor_color) |v| switch (v) { .color => |color| break :cursor_color color.toTerminalRGB(), + inline .@"cell-foreground", .@"cell-background", => |_, tag| { - const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - const fg_style = sty.fg(.{ - .default = foreground, - .palette = color_palette, + const fg_style = cursor_style.fg(.{ + .default = state.colors.foreground, + .palette = &state.colors.palette, .bold = self.config.bold_color, }); - const bg_style = sty.bg( - screen.cursor.page_cell, - color_palette, - ) orelse background; + const bg_style = cursor_style.bg( + &state.cursor.cell, + &state.colors.palette, + ) orelse state.colors.background; break :cursor_color switch (tag) { .color => unreachable, - .@"cell-foreground" => if (sty.flags.inverse) bg_style else fg_style, - .@"cell-background" => if (sty.flags.inverse) fg_style else bg_style, + .@"cell-foreground" => if (cursor_style.flags.inverse) + bg_style + else + fg_style, + .@"cell-background" => if (cursor_style.flags.inverse) + fg_style + else + bg_style, }; }, }; - break :cursor_color foreground; + break :cursor_color state.colors.foreground; }; - self.addCursor(screen, style, cursor_color); + self.addCursor( + &state.cursor, + style, + cursor_color, + ); // If the cursor is visible then we set our uniforms. - if (style == .block and screen.viewportIsBottom()) { - const wide = screen.cursor.page_cell.wide; + if (style == .block) { + const wide = state.cursor.cell.wide; self.uniforms.cursor_pos = .{ // If we are a spacer tail of a wide cell, our cursor needs // to move back one cell. The saturate is to ensure we don't // overflow but this shouldn't happen with well-formed input. switch (wide) { - .narrow, .spacer_head, .wide => screen.cursor.x, - .spacer_tail => screen.cursor.x -| 1, + .narrow, .spacer_head, .wide => cursor_vp.x, + .spacer_tail => cursor_vp.x -| 1, }, - screen.cursor.y, + @intCast(cursor_vp.y), }; self.uniforms.bools.cursor_wide = switch (wide) { @@ -2901,24 +3004,29 @@ pub fn Renderer(comptime GraphicsAPI: type) type { break :blk txt.color.toTerminalRGB(); } - const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - const fg_style = sty.fg(.{ - .default = foreground, - .palette = color_palette, + const fg_style = cursor_style.fg(.{ + .default = state.colors.foreground, + .palette = &state.colors.palette, .bold = self.config.bold_color, }); - const bg_style = sty.bg( - screen.cursor.page_cell, - color_palette, - ) orelse background; + const bg_style = cursor_style.bg( + &state.cursor.cell, + &state.colors.palette, + ) orelse state.colors.background; break :blk switch (txt) { // If the cell is reversed, use the opposite cell color instead. - .@"cell-foreground" => if (sty.flags.inverse) bg_style else fg_style, - .@"cell-background" => if (sty.flags.inverse) fg_style else bg_style, + .@"cell-foreground" => if (cursor_style.flags.inverse) + bg_style + else + fg_style, + .@"cell-background" => if (cursor_style.flags.inverse) + fg_style + else + bg_style, else => unreachable, }; - } else background; + } else state.colors.background; self.uniforms.cursor_color = .{ uniform_color.r, @@ -2937,8 +3045,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.addPreeditCell( cp, .{ .x = x, .y = range.y }, - background, - foreground, + state.colors.background, + state.colors.foreground, ) catch |err| { log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{ x, @@ -3068,15 +3176,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, x: terminal.size.CellCountInt, y: terminal.size.CellCountInt, - cell_pin: terminal.Pin, + cols: usize, + cell_raws: []const terminal.page.Cell, shaper_cell: font.shape.Cell, shaper_run: font.shape.TextRun, color: terminal.color.RGB, alpha: u8, ) !void { - const rac = cell_pin.rowAndCell(); - const cell = rac.cell; - + const cell = cell_raws[x]; const cp = cell.codepoint(); // Render @@ -3096,7 +3203,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (cellpkg.isSymbol(cp)) .{ .size = .fit, } else .none, - .constraint_width = constraintWidth(cell_pin), + .constraint_width = constraintWidth( + cell_raws, + x, + cols, + ), }, ); @@ -3125,22 +3236,24 @@ pub fn Renderer(comptime GraphicsAPI: type) type { fn addCursor( self: *Self, - screen: *terminal.Screen, + cursor_state: *const terminal.RenderState.Cursor, cursor_style: renderer.CursorStyle, cursor_color: terminal.color.RGB, ) void { + const cursor_vp = cursor_state.viewport orelse return; + // Add the cursor. We render the cursor over the wide character if // we're on the wide character tail. const wide, const x = cell: { // The cursor goes over the screen cursor position. - const cell = screen.cursor.page_cell; - if (cell.wide != .spacer_tail or screen.cursor.x == 0) - break :cell .{ cell.wide == .wide, screen.cursor.x }; + if (!cursor_vp.wide_tail) break :cell .{ + cursor_state.cell.wide == .wide, + cursor_vp.x, + }; - // If we're part of a wide character, we move the cursor back to - // the actual character. - const prev_cell = screen.cursorCellLeft(1); - break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 }; + // If we're part of a wide character, we move the cursor back + // to the actual character. + break :cell .{ true, cursor_vp.x - 1 }; }; const alpha: u8 = if (!self.focused) 255 else alpha: { @@ -3199,7 +3312,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.cells.setCursor(.{ .atlas = .grayscale, .bools = .{ .is_cursor_glyph = true }, - .grid_pos = .{ x, screen.cursor.y }, + .grid_pos = .{ x, cursor_vp.y }, .color = .{ cursor_color.r, cursor_color.g, cursor_color.b, alpha }, .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, .glyph_size = .{ render.glyph.width, render.glyph.height }, diff --git a/src/renderer/image.zig b/src/renderer/image.zig index d89c46730..7089f5a8b 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const wuffs = @import("wuffs"); const Renderer = @import("../renderer.zig").Renderer; diff --git a/src/renderer/link.zig b/src/renderer/link.zig index 39283cf5f..74df3e596 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -1,7 +1,7 @@ const std = @import("std"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const oni = @import("oniguruma"); -const configpkg = @import("../config.zig"); const inputpkg = @import("../input.zig"); const terminal = @import("../terminal/main.zig"); const point = terminal.point; @@ -54,354 +54,105 @@ pub const Set = struct { alloc.free(self.links); } - /// Returns the matchset for the viewport state. The matchset is the - /// full set of matching links for the visible viewport. A link - /// only matches if it is also in the correct state (i.e. hovered - /// if necessary). - /// - /// This is not a particularly efficient operation. This should be - /// called sparingly. - pub fn matchSet( - self: *const Set, - alloc: Allocator, - screen: *Screen, - mouse_vp_pt: point.Coordinate, - mouse_mods: inputpkg.Mods, - ) !MatchSet { - // Convert the viewport point to a screen point. - const mouse_pin = screen.pages.pin(.{ - .viewport = mouse_vp_pt, - }) orelse return .{}; - - // This contains our list of matches. The matches are stored - // as selections which contain the start and end points of - // the match. There is no way to map these back to the link - // configuration right now because we don't need to. - var matches: std.ArrayList(terminal.Selection) = .empty; - defer matches.deinit(alloc); - - // If our mouse is over an OSC8 link, then we can skip the regex - // matches below since OSC8 takes priority. - try self.matchSetFromOSC8( - alloc, - &matches, - screen, - mouse_pin, - mouse_mods, - ); - - // If we have no matches then we can try the regex matches. - if (matches.items.len == 0) { - try self.matchSetFromLinks( - alloc, - &matches, - screen, - mouse_pin, - mouse_mods, - ); - } - - return .{ .matches = try matches.toOwnedSlice(alloc) }; - } - - fn matchSetFromOSC8( - self: *const Set, - alloc: Allocator, - matches: *std.ArrayList(terminal.Selection), - screen: *Screen, - mouse_pin: terminal.Pin, - mouse_mods: inputpkg.Mods, - ) !void { - // If the right mods aren't pressed, then we can't match. - if (!mouse_mods.equal(inputpkg.ctrlOrSuper(.{}))) return; - - // Check if the cell the mouse is over is an OSC8 hyperlink - const mouse_cell = mouse_pin.rowAndCell().cell; - if (!mouse_cell.hyperlink) return; - - // Get our hyperlink entry - const page: *terminal.Page = &mouse_pin.node.data; - const link_id = page.lookupHyperlink(mouse_cell) orelse { - log.warn("failed to find hyperlink for cell", .{}); - return; - }; - const link = page.hyperlink_set.get(page.memory, link_id); - - // If our link has an implicit ID (no ID set explicitly via OSC8) - // then we use an alternate matching technique that iterates forward - // and backward until it finds boundaries. - if (link.id == .implicit) { - const uri = link.uri.offset.ptr(page.memory)[0..link.uri.len]; - return try self.matchSetFromOSC8Implicit( - alloc, - matches, - mouse_pin, - uri, - ); - } - - // Go through every row and find matching hyperlinks for the given ID. - // Note the link ID is not the same as the OSC8 ID parameter. But - // we hash hyperlinks by their contents which should achieve the same - // thing so we can use the ID as a key. - var current: ?terminal.Selection = null; - var row_it = screen.pages.getTopLeft(.viewport).rowIterator(.right_down, null); - while (row_it.next()) |row_pin| { - const row = row_pin.rowAndCell().row; - - // If the row doesn't have any hyperlinks then we're done - // building our matching selection. - if (!row.hyperlink) { - if (current) |sel| { - try matches.append(alloc, sel); - current = null; - } - - continue; - } - - // We have hyperlinks, look for our own matching hyperlink. - for (row_pin.cells(.right), 0..) |*cell, x| { - const match = match: { - if (cell.hyperlink) { - if (row_pin.node.data.lookupHyperlink(cell)) |cell_link_id| { - break :match cell_link_id == link_id; - } - } - break :match false; - }; - - // If we have a match, extend our selection or start a new - // selection. - if (match) { - const cell_pin = row_pin.right(x); - if (current) |*sel| { - sel.endPtr().* = cell_pin; - } else { - current = .init( - cell_pin, - cell_pin, - false, - ); - } - - continue; - } - - // No match, if we have a current selection then complete it. - if (current) |sel| { - try matches.append(alloc, sel); - current = null; - } - } - } - } - - /// Match OSC8 links around the mouse pin for an OSC8 link with an - /// implicit ID. This only matches cells with the same URI directly - /// around the mouse pin. - fn matchSetFromOSC8Implicit( - self: *const Set, - alloc: Allocator, - matches: *std.ArrayList(terminal.Selection), - mouse_pin: terminal.Pin, - uri: []const u8, - ) !void { - _ = self; - - // Our selection starts with just our pin. - var sel = terminal.Selection.init(mouse_pin, mouse_pin, false); - - // Expand it to the left. - var it = mouse_pin.cellIterator(.left_up, null); - while (it.next()) |cell_pin| { - const page: *terminal.Page = &cell_pin.node.data; - const rac = cell_pin.rowAndCell(); - const cell = rac.cell; - - // If this cell isn't a hyperlink then we've found a boundary - if (!cell.hyperlink) break; - - const link_id = page.lookupHyperlink(cell) orelse { - log.warn("failed to find hyperlink for cell", .{}); - break; - }; - const link = page.hyperlink_set.get(page.memory, link_id); - - // If this link has an explicit ID then we found a boundary - if (link.id != .implicit) break; - - // If this link has a different URI then we found a boundary - const cell_uri = link.uri.offset.ptr(page.memory)[0..link.uri.len]; - if (!std.mem.eql(u8, uri, cell_uri)) break; - - sel.startPtr().* = cell_pin; - } - - // Expand it to the right - it = mouse_pin.cellIterator(.right_down, null); - while (it.next()) |cell_pin| { - const page: *terminal.Page = &cell_pin.node.data; - const rac = cell_pin.rowAndCell(); - const cell = rac.cell; - - // If this cell isn't a hyperlink then we've found a boundary - if (!cell.hyperlink) break; - - const link_id = page.lookupHyperlink(cell) orelse { - log.warn("failed to find hyperlink for cell", .{}); - break; - }; - const link = page.hyperlink_set.get(page.memory, link_id); - - // If this link has an explicit ID then we found a boundary - if (link.id != .implicit) break; - - // If this link has a different URI then we found a boundary - const cell_uri = link.uri.offset.ptr(page.memory)[0..link.uri.len]; - if (!std.mem.eql(u8, uri, cell_uri)) break; - - sel.endPtr().* = cell_pin; - } - - try matches.append(alloc, sel); - } - /// Fills matches with the matches from regex link matches. - fn matchSetFromLinks( + pub fn renderCellMap( self: *const Set, alloc: Allocator, - matches: *std.ArrayList(terminal.Selection), - screen: *Screen, - mouse_pin: terminal.Pin, + result: *terminal.RenderState.CellSet, + render_state: *const terminal.RenderState, + mouse_viewport: ?point.Coordinate, mouse_mods: inputpkg.Mods, ) !void { - // Iterate over all the visible lines. - var lineIter = screen.lineIterator(screen.pages.pin(.{ - .viewport = .{}, - }) orelse return); - while (lineIter.next()) |line_sel| { - const strmap: terminal.StringMap = strmap: { - var strmap: terminal.StringMap = undefined; - const str = screen.selectionString(alloc, .{ - .sel = line_sel, - .trim = false, - .map = &strmap, - }) catch |err| { - log.warn( - "failed to build string map for link checking err={}", - .{err}, - ); - continue; - }; - alloc.free(str); - break :strmap strmap; - }; - defer strmap.deinit(alloc); + // Fast path, not very likely since we have default links. + if (self.links.len == 0) return; + + // Convert our render state to a string + byte map. + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + var map: terminal.RenderState.StringMap = .empty; + defer map.deinit(alloc); + try render_state.string(&builder.writer, .{ + .alloc = alloc, + .map = &map, + }); + + const str = builder.writer.buffered(); + + // Go through each link and see if we have any matches. + for (self.links) |*link| { + // Determine if our highlight conditions are met. We use a + // switch here instead of an if so that we can get a compile + // error if any other conditions are added. + switch (link.highlight) { + .always => {}, + .always_mods => |v| if (!mouse_mods.equal(v)) continue, + + // We check the hover points later. + .hover => if (mouse_viewport == null) continue, + .hover_mods => |v| { + if (mouse_viewport == null) continue; + if (!mouse_mods.equal(v)) continue; + }, + } + + var offset: usize = 0; + while (offset < str.len) { + var region = link.regex.search( + str[offset..], + .{}, + ) catch |err| switch (err) { + error.Mismatch => break, + else => return err, + }; + defer region.deinit(); + + // We have a match! + const offset_start: usize = @intCast(region.starts()[0]); + const offset_end: usize = @intCast(region.ends()[0]); + const start = offset + offset_start; + const end = offset + offset_end; + + // Increment our offset by the number of bytes in the match. + // We defer this so that we can return the match before + // modifying the offset. + defer offset = end; - // Go through each link and see if we have any matches. - for (self.links) |link| { - // Determine if our highlight conditions are met. We use a - // switch here instead of an if so that we can get a compile - // error if any other conditions are added. switch (link.highlight) { - .always => {}, - .always_mods => |v| if (!mouse_mods.equal(v)) continue, - inline .hover, .hover_mods => |v, tag| { - if (!line_sel.contains(screen, mouse_pin)) continue; - if (comptime tag == .hover_mods) { - if (!mouse_mods.equal(v)) continue; - } - }, + .always, .always_mods => {}, + .hover, .hover_mods => if (mouse_viewport) |vp| { + for (map.items[start..end]) |pt| { + if (pt.eql(vp)) break; + } else continue; + } else continue, } - var it = strmap.searchIterator(link.regex); - while (true) { - const match_ = it.next() catch |err| { - log.warn("failed to search for link err={}", .{err}); - break; - }; - var match = match_ orelse break; - defer match.deinit(); - const sel = match.selection(); - - // If this is a highlight link then we only want to - // include matches that include our hover point. - switch (link.highlight) { - .always, .always_mods => {}, - .hover, - .hover_mods, - => if (!sel.contains(screen, mouse_pin)) continue, - } - - try matches.append(alloc, sel); + // Record the match + for (map.items[start..end]) |pt| { + try result.put(alloc, pt, {}); } } } } }; -/// MatchSet is the result of matching links against a screen. This contains -/// all the matching links and operations on them such as whether a specific -/// cell is part of a matched link. -pub const MatchSet = struct { - /// The matches. - /// - /// Important: this must be in left-to-right top-to-bottom order. - matches: []const terminal.Selection = &.{}, - i: usize = 0, - - pub fn deinit(self: *MatchSet, alloc: Allocator) void { - alloc.free(self.matches); - } - - /// Checks if the matchset contains the given pin. This is slower than - /// orderedContains but is stateless and more flexible since it doesn't - /// require the points to be in order. - pub fn contains( - self: *MatchSet, - screen: *const Screen, - pin: terminal.Pin, - ) bool { - for (self.matches) |sel| { - if (sel.contains(screen, pin)) return true; - } - - return false; - } - - /// Checks if the matchset contains the given pt. The points must be - /// given in left-to-right top-to-bottom order. This is a stateful - /// operation and giving a point out of order can cause invalid - /// results. - pub fn orderedContains( - self: *MatchSet, - screen: *const Screen, - pin: terminal.Pin, - ) bool { - // If we're beyond the end of our possible matches, we're done. - if (self.i >= self.matches.len) return false; - - // If our selection ends before the point, then no point will ever - // again match this selection so we move on to the next one. - while (self.matches[self.i].end().before(pin)) { - self.i += 1; - if (self.i >= self.matches.len) return false; - } - - return self.matches[self.i].contains(screen, pin); - } -}; - -test "matchset" { +test "renderCellMap" { const testing = std.testing; const alloc = testing.allocator; - // Initialize our screen - var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); + const str = "1ABCD2EFGH\r\n3IJKL"; + try s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get a set var set = try Set.fromConfig(alloc, &.{ @@ -420,46 +171,41 @@ test "matchset" { defer set.deinit(alloc); // Get our matches - var match = try set.matchSet(alloc, &s, .{}, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 2), match.matches.len); - - // Test our matches - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 0, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 1, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 2, - } }).?)); + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( + alloc, + &result, + &state, + null, + .{}, + ); + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); } -test "matchset hover links" { +test "renderCellMap hover links" { const testing = std.testing; const alloc = testing.allocator; - // Initialize our screen - var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); + const str = "1ABCD2EFGH\r\n3IJKL"; + try s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get a set var set = try Set.fromConfig(alloc, &.{ @@ -479,80 +225,65 @@ test "matchset hover links" { // Not hovering over the first link { - var match = try set.matchSet(alloc, &s, .{}, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 1), match.matches.len); + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( + alloc, + &result, + &state, + null, + .{}, + ); // Test our matches - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 0, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 1, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 2, - } }).?)); + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); } // Hovering over the first link { - var match = try set.matchSet(alloc, &s, .{ .x = 1, .y = 0 }, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 2), match.matches.len); + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( + alloc, + &result, + &state, + .{ .x = 1, .y = 0 }, + .{}, + ); // Test our matches - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 0, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 1, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 2, - } }).?)); + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); } } -test "matchset mods no match" { +test "renderCellMap mods no match" { const testing = std.testing; const alloc = testing.allocator; - // Initialize our screen - var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); + const str = "1ABCD2EFGH\r\n3IJKL"; + try s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get a set var set = try Set.fromConfig(alloc, &.{ @@ -571,96 +302,21 @@ test "matchset mods no match" { defer set.deinit(alloc); // Get our matches - var match = try set.matchSet(alloc, &s, .{}, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 1), match.matches.len); - - // Test our matches - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 0, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 1, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 2, - } }).?)); -} - -test "matchset osc8" { - const testing = std.testing; - const alloc = testing.allocator; - - // Initialize our terminal - var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); - defer t.deinit(alloc); - const s: *terminal.Screen = t.screens.active; - - try t.printString("ABC"); - try t.screens.active.startHyperlink("http://example.com", null); - try t.printString("123"); - t.screens.active.endHyperlink(); - - // Get a set - var set = try Set.fromConfig(alloc, &.{}); - defer set.deinit(alloc); - - // No matches over the non-link - { - var match = try set.matchSet( - alloc, - t.screens.active, - .{ .x = 2, .y = 0 }, - inputpkg.ctrlOrSuper(.{}), - ); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 0), match.matches.len); - } - - // Match over link - var match = try set.matchSet( + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( alloc, - t.screens.active, - .{ .x = 3, .y = 0 }, - inputpkg.ctrlOrSuper(.{}), + &result, + &state, + null, + .{}, ); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 1), match.matches.len); // Test our matches - try testing.expect(!match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 4, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 5, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 6, - .y = 0, - } }).?)); + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); } diff --git a/src/renderer/message.zig b/src/renderer/message.zig index e33922ae2..a47b96080 100644 --- a/src/renderer/message.zig +++ b/src/renderer/message.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const configpkg = @import("../config.zig"); const font = @import("../font/main.zig"); const renderer = @import("../renderer.zig"); @@ -10,7 +10,7 @@ const terminal = @import("../terminal/main.zig"); pub const Message = union(enum) { /// Purposely crash the renderer. This is used for testing and debugging. /// See the "crash" binding action. - crash: void, + crash, /// A change in state in the window focus that this renderer is /// rendering within. This is only sent when a change is detected so @@ -24,7 +24,7 @@ pub const Message = union(enum) { /// Reset the cursor blink by immediately showing the cursor then /// restarting the timer. - reset_cursor_blink: void, + reset_cursor_blink, /// Change the font grid. This can happen for any number of reasons /// including a font size change, family change, etc. @@ -52,12 +52,31 @@ pub const Message = union(enum) { impl: *renderer.Renderer.DerivedConfig, }, + /// Matches for the current viewport from the search thread. These happen + /// async so they may be off for a frame or two from the actually rendered + /// viewport. The renderer must handle this gracefully. + search_viewport_matches: SearchMatches, + + /// The selected match from the search thread. May be null to indicate + /// no match currently. + search_selected_match: ?SearchMatch, + /// Activate or deactivate the inspector. inspector: bool, /// The macOS display ID has changed for the window. macos_display_id: u32, + pub const SearchMatches = struct { + arena: ArenaAllocator, + matches: []const terminal.highlight.Flattened, + }; + + pub const SearchMatch = struct { + arena: ArenaAllocator, + match: terminal.highlight.Flattened, + }; + /// Initialize a change_config message. pub fn initChangeConfig(alloc: Allocator, config: *const configpkg.Config) !Message { const thread_ptr = try alloc.create(renderer.Thread.DerivedConfig); diff --git a/src/renderer/metal/Frame.zig b/src/renderer/metal/Frame.zig index c766fb8ed..388b4f9ed 100644 --- a/src/renderer/metal/Frame.zig +++ b/src/renderer/metal/Frame.zig @@ -3,17 +3,13 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const builtin = @import("builtin"); const objc = @import("objc"); const mtl = @import("api.zig"); const Renderer = @import("../generic.zig").Renderer(Metal); const Metal = @import("../Metal.zig"); const Target = @import("Target.zig"); -const Pipeline = @import("Pipeline.zig"); const RenderPass = @import("RenderPass.zig"); -const Buffer = @import("buffer.zig").Buffer; const Health = @import("../../renderer.zig").Health; diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index 5a6bf7307..34fbfbed5 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -4,8 +4,6 @@ const IOSurfaceLayer = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const builtin = @import("builtin"); const objc = @import("objc"); const macos = @import("macos"); diff --git a/src/renderer/metal/Pipeline.zig b/src/renderer/metal/Pipeline.zig index 0b8e99159..9ba25c350 100644 --- a/src/renderer/metal/Pipeline.zig +++ b/src/renderer/metal/Pipeline.zig @@ -3,14 +3,10 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const builtin = @import("builtin"); const macos = @import("macos"); const objc = @import("objc"); const mtl = @import("api.zig"); -const Texture = @import("Texture.zig"); -const Metal = @import("../Metal.zig"); const log = std.log.scoped(.metal); diff --git a/src/renderer/metal/RenderPass.zig b/src/renderer/metal/RenderPass.zig index d42d9fa21..f204e1770 100644 --- a/src/renderer/metal/RenderPass.zig +++ b/src/renderer/metal/RenderPass.zig @@ -3,8 +3,6 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const builtin = @import("builtin"); const objc = @import("objc"); const mtl = @import("api.zig"); @@ -12,8 +10,6 @@ const Pipeline = @import("Pipeline.zig"); const Sampler = @import("Sampler.zig"); const Texture = @import("Texture.zig"); const Target = @import("Target.zig"); -const Metal = @import("../Metal.zig"); -const Buffer = @import("buffer.zig").Buffer; const log = std.log.scoped(.metal); diff --git a/src/renderer/metal/Sampler.zig b/src/renderer/metal/Sampler.zig index 0f4de8848..593f9a864 100644 --- a/src/renderer/metal/Sampler.zig +++ b/src/renderer/metal/Sampler.zig @@ -3,8 +3,6 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const builtin = @import("builtin"); const objc = @import("objc"); const mtl = @import("api.zig"); diff --git a/src/renderer/metal/Target.zig b/src/renderer/metal/Target.zig index 15780189b..f20bb0b7c 100644 --- a/src/renderer/metal/Target.zig +++ b/src/renderer/metal/Target.zig @@ -5,8 +5,6 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const builtin = @import("builtin"); const objc = @import("objc"); const macos = @import("macos"); const graphics = macos.graphics; diff --git a/src/renderer/metal/Texture.zig b/src/renderer/metal/Texture.zig index cde50e8de..5042919ac 100644 --- a/src/renderer/metal/Texture.zig +++ b/src/renderer/metal/Texture.zig @@ -3,8 +3,7 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const builtin = @import("builtin"); +const assert = @import("../../quirks.zig").inlineAssert; const objc = @import("objc"); const mtl = @import("api.zig"); diff --git a/src/renderer/metal/buffer.zig b/src/renderer/metal/buffer.zig index 43320a60b..f91f89e99 100644 --- a/src/renderer/metal/buffer.zig +++ b/src/renderer/metal/buffer.zig @@ -1,6 +1,5 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; const objc = @import("objc"); const macos = @import("macos"); diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index bf3bcc6e4..0be023572 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -1,6 +1,5 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; const macos = @import("macos"); const objc = @import("objc"); const math = @import("../../math.zig"); diff --git a/src/renderer/opengl/Frame.zig b/src/renderer/opengl/Frame.zig index 4c23fe106..289413b0a 100644 --- a/src/renderer/opengl/Frame.zig +++ b/src/renderer/opengl/Frame.zig @@ -3,16 +3,12 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const builtin = @import("builtin"); const gl = @import("opengl"); const Renderer = @import("../generic.zig").Renderer(OpenGL); const OpenGL = @import("../OpenGL.zig"); const Target = @import("Target.zig"); -const Pipeline = @import("Pipeline.zig"); const RenderPass = @import("RenderPass.zig"); -const Buffer = @import("buffer.zig").Buffer; const Health = @import("../../renderer.zig").Health; diff --git a/src/renderer/opengl/Pipeline.zig b/src/renderer/opengl/Pipeline.zig index c3d414ff2..2469f45bc 100644 --- a/src/renderer/opengl/Pipeline.zig +++ b/src/renderer/opengl/Pipeline.zig @@ -3,14 +3,8 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const builtin = @import("builtin"); const gl = @import("opengl"); -const OpenGL = @import("../OpenGL.zig"); -const Texture = @import("Texture.zig"); -const Buffer = @import("buffer.zig").Buffer; - const log = std.log.scoped(.opengl); /// Options for initializing a render pipeline. diff --git a/src/renderer/opengl/RenderPass.zig b/src/renderer/opengl/RenderPass.zig index 7a9365d88..180664942 100644 --- a/src/renderer/opengl/RenderPass.zig +++ b/src/renderer/opengl/RenderPass.zig @@ -3,16 +3,12 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const builtin = @import("builtin"); const gl = @import("opengl"); -const OpenGL = @import("../OpenGL.zig"); const Sampler = @import("Sampler.zig"); const Target = @import("Target.zig"); const Texture = @import("Texture.zig"); const Pipeline = @import("Pipeline.zig"); -const RenderPass = @import("RenderPass.zig"); const Buffer = @import("buffer.zig").Buffer; /// Options for beginning a render pass. diff --git a/src/renderer/opengl/Sampler.zig b/src/renderer/opengl/Sampler.zig index 98d4b35fe..f4013c686 100644 --- a/src/renderer/opengl/Sampler.zig +++ b/src/renderer/opengl/Sampler.zig @@ -3,8 +3,6 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const builtin = @import("builtin"); const gl = @import("opengl"); const OpenGL = @import("../OpenGL.zig"); diff --git a/src/renderer/opengl/Target.zig b/src/renderer/opengl/Target.zig index 1b3a13ed0..5c6d818f1 100644 --- a/src/renderer/opengl/Target.zig +++ b/src/renderer/opengl/Target.zig @@ -5,8 +5,6 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const builtin = @import("builtin"); const gl = @import("opengl"); const log = std.log.scoped(.opengl); diff --git a/src/renderer/opengl/Texture.zig b/src/renderer/opengl/Texture.zig index 2f3e7f46a..c37ec6866 100644 --- a/src/renderer/opengl/Texture.zig +++ b/src/renderer/opengl/Texture.zig @@ -3,8 +3,6 @@ const Self = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const builtin = @import("builtin"); const gl = @import("opengl"); const OpenGL = @import("../OpenGL.zig"); diff --git a/src/renderer/opengl/buffer.zig b/src/renderer/opengl/buffer.zig index 48b6f410e..f9cbbcebd 100644 --- a/src/renderer/opengl/buffer.zig +++ b/src/renderer/opengl/buffer.zig @@ -1,6 +1,5 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; const gl = @import("opengl"); const OpenGL = @import("../OpenGL.zig"); diff --git a/src/renderer/opengl/shaders.zig b/src/renderer/opengl/shaders.zig index 80980bac7..68c1f36a3 100644 --- a/src/renderer/opengl/shaders.zig +++ b/src/renderer/opengl/shaders.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const math = @import("../../math.zig"); const Pipeline = @import("Pipeline.zig"); diff --git a/src/renderer/row.zig b/src/renderer/row.zig new file mode 100644 index 000000000..933bb338b --- /dev/null +++ b/src/renderer/row.zig @@ -0,0 +1,63 @@ +const terminal = @import("../terminal/main.zig"); + +// TODO: Test neverExtendBg function + +/// Returns true if the row of this pin should never have its background +/// color extended for filling padding space in the renderer. This is +/// a set of heuristics that help making our padding look better. +pub fn neverExtendBg( + row: terminal.page.Row, + cells: []const terminal.page.Cell, + styles: []const terminal.Style, + palette: *const terminal.color.Palette, + default_background: terminal.color.RGB, +) bool { + // Any semantic prompts should not have their background extended + // because prompts often contain special formatting (such as + // powerline) that looks bad when extended. + switch (row.semantic_prompt) { + .prompt, .prompt_continuation, .input => return true, + .unknown, .command => {}, + } + + for (0.., cells) |x, *cell| { + // If any cell has a default background color then we don't + // extend because the default background color probably looks + // good enough as an extension. + switch (cell.content_tag) { + // If it is a background color cell, we check the color. + .bg_color_palette, .bg_color_rgb => { + const s: terminal.Style = if (cell.hasStyling()) styles[x] else .{}; + const bg = s.bg(cell, palette) orelse return true; + if (bg.eql(default_background)) return true; + }, + + // If its a codepoint cell we can check the style. + .codepoint, .codepoint_grapheme => { + // For codepoint containing, we also never extend bg + // if any cell has a powerline glyph because these are + // perfect-fit. + switch (cell.codepoint()) { + // Powerline + 0xE0B0...0xE0C8, + 0xE0CA, + 0xE0CC...0xE0D2, + 0xE0D4, + => return true, + + else => {}, + } + + // Never extend a cell that has a default background. + // A default background is applied if there is no background + // on the style or the explicitly set background + // matches our default background. + const s: terminal.Style = if (cell.hasStyling()) styles[x] else .{}; + const bg = s.bg(cell, palette) orelse return true; + if (bg.eql(default_background)) return true; + }, + } + } + + return false; +} diff --git a/src/renderer/shaders/shaders.metal b/src/renderer/shaders/shaders.metal index 4797f89e4..4e02b6336 100644 --- a/src/renderer/shaders/shaders.metal +++ b/src/renderer/shaders/shaders.metal @@ -668,7 +668,7 @@ vertex CellTextVertexOut cell_text_vertex( out.color = load_color( uniforms.cursor_color, uniforms.use_display_p3, - false + true ); } diff --git a/src/renderer/shadertoy.zig b/src/renderer/shadertoy.zig index b0a190a8b..0d096c0fc 100644 --- a/src/renderer/shadertoy.zig +++ b/src/renderer/shadertoy.zig @@ -1,6 +1,5 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const glslang = @import("glslang"); diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 8a01ed625..50c01344b 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -83,12 +83,14 @@ from the `zsh` directory. The existing `ZDOTDIR` is retained so that after loading the Ghostty shell integration the normal Zsh loading sequence occurs. -```bash +```zsh if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration fi ``` +Shell integration requires Zsh 5.1+. + ### Nushell For `nushell` Ghostty prepends to the `XDG_DATA_DIRS` directory. Nushell automatically diff --git a/src/shell-integration/zsh/.zshenv b/src/shell-integration/zsh/.zshenv index 3332b1c1f..62dcf273c 100644 --- a/src/shell-integration/zsh/.zshenv +++ b/src/shell-integration/zsh/.zshenv @@ -15,11 +15,16 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +# This script is sourced automatically by zsh when ZDOTDIR is set to this +# directory. It therefore assumes it's running within our shell integration +# environment and should not be sourced manually (unlike ghostty-integration). +# # This file can get sourced with aliases enabled. To avoid alias expansion # we quote everything that can be quoted. Some aliases will still break us # though. -# Restore the original ZDOTDIR value. +# Restore the original ZDOTDIR value if GHOSTTY_ZSH_ZDOTDIR is set. +# Otherwise, unset the ZDOTDIR that was set during shell injection. if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then 'builtin' 'export' ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR" 'builtin' 'unset' 'GHOSTTY_ZSH_ZDOTDIR' @@ -43,12 +48,6 @@ fi [[ ! -r "$_ghostty_file" ]] || 'builtin' 'source' '--' "$_ghostty_file" } always { if [[ -o 'interactive' ]]; then - 'builtin' 'autoload' '--' 'is-at-least' - 'is-at-least' "5.1" || { - builtin echo "ZSH ${ZSH_VERSION} is too old for ghostty shell integration" > /dev/stderr - 'builtin' 'unset' '_ghostty_file' - return - } # ${(%):-%x} is the path to the current file. # On top of it we add :A:h to get the directory. 'builtin' 'typeset' _ghostty_file="${${(%):-%x}:A:h}"/ghostty-integration diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 27ef39bbc..7ff43efd9 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -1,3 +1,5 @@ +# vim:ft=zsh +# # Based on (started as) a copy of Kitty's zsh integration. Kitty is # distributed under GPLv3, so this file is also distributed under GPLv3. # The license header is reproduced below: @@ -41,6 +43,13 @@ _entrypoint() { [[ -o interactive ]] || builtin return 0 # non-interactive shell (( ! $+_ghostty_state )) || builtin return 0 # already initialized + # We require zsh 5.1+ (released Sept 2015) for features like functions_source, + # introspection arrays, and array pattern substitution. + if ! { builtin autoload -- is-at-least 2>/dev/null && is-at-least 5.1; }; then + builtin echo "Zsh ${ZSH_VERSION} is too old for ghostty shell integration (5.1+ required)" >&2 + builtin return 1 + fi + # 0: no OSC 133 [AC] marks have been written yet. # 1: the last written OSC 133 C has not been closed with D yet. # 2: none of the above. diff --git a/src/simd/base64.zig b/src/simd/base64.zig index 88b97bb03..81feeb723 100644 --- a/src/simd/base64.zig +++ b/src/simd/base64.zig @@ -1,6 +1,6 @@ const std = @import("std"); const options = @import("build_options"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const scalar_decoder = @import("base64_scalar.zig").scalar_decoder; const log = std.log.scoped(.simd_base64); diff --git a/src/simd/base64_scalar.zig b/src/simd/base64_scalar.zig index 4172ed107..08886f187 100644 --- a/src/simd/base64_scalar.zig +++ b/src/simd/base64_scalar.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; pub const scalar_decoder: Base64Decoder = .init( std.base64.standard_alphabet_chars, diff --git a/src/simd/index_of.zig b/src/simd/index_of.zig index cea549b95..7bf053b0d 100644 --- a/src/simd/index_of.zig +++ b/src/simd/index_of.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); const options = @import("build_options"); extern "c" fn ghostty_simd_index_of( diff --git a/src/simd/vt.zig b/src/simd/vt.zig index 8e974ad7e..fa8754fa2 100644 --- a/src/simd/vt.zig +++ b/src/simd/vt.zig @@ -1,6 +1,6 @@ const std = @import("std"); const options = @import("build_options"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const indexOf = @import("index_of.zig").indexOf; // vt.cpp diff --git a/src/surface_mouse.zig b/src/surface_mouse.zig index a9702a8fe..691f1b23c 100644 --- a/src/surface_mouse.zig +++ b/src/surface_mouse.zig @@ -8,7 +8,6 @@ const SurfaceMouse = @This(); const std = @import("std"); const builtin = @import("builtin"); const input = @import("input.zig"); -const apprt = @import("apprt.zig"); const terminal = @import("terminal/main.zig"); const MouseShape = @import("terminal/mouse_shape.zig").MouseShape; diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index a589af179..29f414e03 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -6,7 +6,7 @@ const PageList = @This(); const std = @import("std"); const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const fastmem = @import("../fastmem.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); @@ -15,7 +15,6 @@ const point = @import("point.zig"); const pagepkg = @import("page.zig"); const stylepkg = @import("style.zig"); const size = @import("size.zig"); -const Selection = @import("Selection.zig"); const OffsetBuf = size.OffsetBuf; const Capacity = pagepkg.Capacity; const Page = pagepkg.Page; @@ -43,6 +42,7 @@ const Node = struct { prev: ?*Node = null, next: ?*Node = null, data: Page, + serial: u64, }; /// The memory pool we get page nodes from. @@ -113,6 +113,24 @@ pool_owned: bool, /// The list of pages in the screen. pages: List, +/// A monotonically increasing serial number that is incremented each +/// time a page is allocated or reused as new. The serial is assigned to +/// the Node. +/// +/// The serial number can be used to detect whether the page is identical +/// to the page that was originally referenced by a pointer. Since we reuse +/// and pool memory, pointer stability is not guaranteed, but the serial +/// will always be different for different allocations. +/// +/// Developer note: we never do overflow checking on this. If we created +/// a new page every second it'd take 584 billion years to overflow. We're +/// going to risk it. +page_serial: u64, + +/// The lowest still valid serial number that could exist. This allows +/// for quick comparisons to find invalid pages in references. +page_serial_min: u64, + /// Byte size of the total amount of allocated pages. Note this does /// not include the total allocated amount in the pool which may be more /// than this due to preheating. @@ -264,7 +282,13 @@ pub fn init( // necessary. var pool = try MemoryPool.init(alloc, std.heap.page_allocator, page_preheat); errdefer pool.deinit(); - const page_list, const page_size = try initPages(&pool, cols, rows); + var page_serial: u64 = 0; + const page_list, const page_size = try initPages( + &pool, + &page_serial, + cols, + rows, + ); // Get our minimum max size, see doc comments for more details. const min_max_size = try minMaxSize(cols, rows); @@ -282,6 +306,8 @@ pub fn init( .pool = pool, .pool_owned = true, .pages = page_list, + .page_serial = page_serial, + .page_serial_min = 0, .page_size = page_size, .explicit_max_size = max_size orelse std.math.maxInt(usize), .min_max_size = min_max_size, @@ -297,6 +323,7 @@ pub fn init( fn initPages( pool: *MemoryPool, + serial: *u64, cols: size.CellCountInt, rows: size.CellCountInt, ) !struct { List, usize } { @@ -323,6 +350,7 @@ fn initPages( .init(page_buf), Page.layout(cap), ), + .serial = serial.*, }; node.data.size.rows = @min(rem, node.data.capacity.rows); rem -= node.data.size.rows; @@ -330,6 +358,9 @@ fn initPages( // Add the page to the list page_list.append(node); page_size += page_buf.len; + + // Increment our serial + serial.* += 1; } assert(page_list.first != null); @@ -363,6 +394,7 @@ pub inline fn pauseIntegrityChecks(self: *PageList, pause: bool) void { const IntegrityError = error{ TotalRowsMismatch, ViewportPinOffsetMismatch, + PageSerialInvalid, }; /// Verify the integrity of the PageList. This is expensive and should @@ -374,8 +406,27 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { // Our viewport pin should never be garbage assert(!self.viewport_pin.garbage); + // Grab our total rows + var actual_total: usize = 0; + { + var node_ = self.pages.first; + while (node_) |node| { + actual_total += node.data.size.rows; + node_ = node.next; + + // While doing this traversal, verify no node has a serial + // number lower than our min. + if (node.serial < self.page_serial_min) { + log.warn( + "PageList integrity violation: page serial too low serial={} min={}", + .{ node.serial, self.page_serial_min }, + ); + return IntegrityError.PageSerialInvalid; + } + } + } + // Verify that our cached total_rows matches the actual row count - const actual_total = self.totalRows(); if (actual_total != self.total_rows) { log.warn( "PageList integrity violation: total_rows mismatch cached={} actual={}", @@ -523,6 +574,7 @@ pub fn reset(self: *PageList) void { // we retained the capacity for the minimum number of pages we need. self.pages, self.page_size = initPages( &self.pool, + &self.page_serial, self.cols, self.rows, ) catch @panic("initPages failed"); @@ -638,6 +690,7 @@ pub fn clone( } // Copy our pages + var page_serial: u64 = 0; var total_rows: usize = 0; var page_size: usize = 0; while (it.next()) |chunk| { @@ -646,6 +699,7 @@ pub fn clone( const node = try createPageExt( pool, chunk.node.data.capacity, + &page_serial, &page_size, ); assert(node.data.capacity.rows >= chunk.end - chunk.start); @@ -658,6 +712,8 @@ pub fn clone( chunk.end, ); + node.data.dirty = chunk.node.data.dirty; + page_list.append(node); total_rows += node.data.size.rows; @@ -688,6 +744,8 @@ pub fn clone( .alloc => true, }, .pages = page_list, + .page_serial = page_serial, + .page_serial_min = 0, .page_size = page_size, .explicit_max_size = self.explicit_max_size, .min_max_size = self.min_max_size, @@ -2429,6 +2487,14 @@ pub fn grow(self: *PageList) !?*List.Node { first.data.size.rows = 1; self.pages.insertAfter(last, first); + // We also need to reset the serial number. Since this is the only + // place we ever reuse a serial number, we also can safely set + // page_serial_min to be one more than the old serial because we + // only ever prune the oldest pages. + self.page_serial_min = first.serial + 1; + first.serial = self.page_serial; + self.page_serial += 1; + // Update any tracked pins that point to this page to point to the // new first page to the top-left. const pin_keys = self.tracked_pins.keys(); @@ -2542,7 +2608,9 @@ pub fn adjustCapacity( errdefer self.destroyNode(new_node); const new_page: *Page = &new_node.data; assert(new_page.capacity.rows >= page.capacity.rows); + assert(new_page.capacity.cols >= page.capacity.cols); new_page.size.rows = page.size.rows; + new_page.size.cols = page.size.cols; try new_page.cloneFrom(page, 0, page.size.rows); // Fix up all our tracked pins to point to the new page. @@ -2568,12 +2636,18 @@ inline fn createPage( cap: Capacity, ) Allocator.Error!*List.Node { // log.debug("create page cap={}", .{cap}); - return try createPageExt(&self.pool, cap, &self.page_size); + return try createPageExt( + &self.pool, + cap, + &self.page_serial, + &self.page_size, + ); } inline fn createPageExt( pool: *MemoryPool, cap: Capacity, + serial: *u64, total_size: ?*usize, ) Allocator.Error!*List.Node { var page = try pool.nodes.create(); @@ -2603,8 +2677,12 @@ inline fn createPageExt( // to undefined, 0xAA. if (comptime std.debug.runtime_safety) @memset(page_buf, 0); - page.* = .{ .data = .initBuf(.init(page_buf), layout) }; + page.* = .{ + .data = .initBuf(.init(page_buf), layout), + .serial = serial.*, + }; page.data.size.rows = 0; + serial.* += 1; if (total_size) |v| { // Accumulate page size now. We don't assert or check max size @@ -2683,11 +2761,11 @@ pub fn eraseRow( // If we have a pinned viewport, we need to adjust for active area. self.fixupViewport(1); - { - // Set all the rows as dirty in this page - var dirty = node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = pn.y, .end = node.data.size.rows }, true); - } + // Mark the whole page as dirty. + // + // Technically we only need to mark rows from the erased row to the end + // of the page as dirty, but that's slower and this is a hot function. + node.data.dirty = true; // We iterate through all of the following pages in order to move their // rows up by 1 as well. @@ -2720,9 +2798,8 @@ pub fn eraseRow( fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); - // Set all the rows as dirty - var dirty = node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = 0, .end = node.data.size.rows }, true); + // Mark the whole page as dirty. + node.data.dirty = true; // Our tracked pins for this page need to be updated. // If the pin is in row 0 that means the corresponding row has @@ -2773,9 +2850,11 @@ pub fn eraseRowBounded( node.data.clearCells(&rows[pn.y], 0, node.data.size.cols); fastmem.rotateOnce(Row, rows[pn.y..][0 .. limit + 1]); - // Set all the rows as dirty - var dirty = node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = pn.y, .end = pn.y + limit }, true); + // Mark the whole page as dirty. + // + // Technically we only need to mark from the erased row to the + // limit but this is a hot function, so we want to minimize work. + node.data.dirty = true; // If our viewport is a pin and our pin is within the erased // region we need to maybe shift our cache up. We do this here instead @@ -2812,11 +2891,11 @@ pub fn eraseRowBounded( fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]); - // All the rows in the page are dirty below the erased row. - { - var dirty = node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = pn.y, .end = node.data.size.rows }, true); - } + // Mark the whole page as dirty. + // + // Technically we only need to mark rows from the erased row to the end + // of the page as dirty, but that's slower and this is a hot function. + node.data.dirty = true; // We need to keep track of how many rows we've shifted so that we can // determine at what point we need to do a partial shift on subsequent @@ -2871,9 +2950,11 @@ pub fn eraseRowBounded( node.data.clearCells(&rows[0], 0, node.data.size.cols); fastmem.rotateOnce(Row, rows[0 .. shifted_limit + 1]); - // Set all the rows as dirty - var dirty = node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = 0, .end = shifted_limit }, true); + // Mark the whole page as dirty. + // + // Technically we only need to mark from the erased row to the + // limit but this is a hot function, so we want to minimize work. + node.data.dirty = true; // See the other places we do something similar in this function // for a detailed explanation. @@ -2903,9 +2984,8 @@ pub fn eraseRowBounded( fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); - // Set all the rows as dirty - var dirty = node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = 0, .end = node.data.size.rows }, true); + // Mark the whole page as dirty. + node.data.dirty = true; // Account for the rows shifted in this node. shifted += node.data.size.rows; @@ -2993,6 +3073,9 @@ pub fn eraseRows( const old_dst = dst.*; dst.* = src.*; src.* = old_dst; + + // Mark the moved row as dirty. + dst.dirty = true; } // Clear our remaining cells that we didn't shift or swapped @@ -3022,10 +3105,6 @@ pub fn eraseRows( // Our new size is the amount we scrolled chunk.node.data.size.rows = @intCast(scroll_amount); erased += chunk.end; - - // Set all the rows as dirty - var dirty = chunk.node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = 0, .end = chunk.node.data.size.rows }, true); } // Update our total row count @@ -3726,7 +3805,11 @@ pub const PageIterator = struct { pub const Chunk = struct { node: *List.Node, + + /// Start y index (inclusive) of this chunk in the page. start: size.CellCountInt, + + /// End y index (exclusive) of this chunk in the page. end: size.CellCountInt, pub fn rows(self: Chunk) []Row { @@ -3881,10 +3964,11 @@ fn growRows(self: *PageList, n: usize) !void { /// traverses the entire list of pages. This is used for testing/debugging. pub fn clearDirty(self: *PageList) void { var page = self.pages.first; - while (page) |p| { - var set = p.data.dirtyBitSet(); - set.unsetAll(); - page = p.next; + while (page) |p| : (page = p.next) { + p.data.dirty = false; + for (p.data.rows.ptr(p.data.memory)[0..p.data.size.rows]) |*row| { + row.dirty = false; + } } } @@ -3965,72 +4049,12 @@ pub const Pin = struct { /// Check if this pin is dirty. pub inline fn isDirty(self: Pin) bool { - return self.node.data.isRowDirty(self.y); + return self.node.data.dirty or self.rowAndCell().row.dirty; } /// Mark this pin location as dirty. pub inline fn markDirty(self: Pin) void { - var set = self.node.data.dirtyBitSet(); - set.set(self.y); - } - - /// Returns true if the row of this pin should never have its background - /// color extended for filling padding space in the renderer. This is - /// a set of heuristics that help making our padding look better. - pub fn neverExtendBg( - self: Pin, - palette: *const color.Palette, - default_background: color.RGB, - ) bool { - // Any semantic prompts should not have their background extended - // because prompts often contain special formatting (such as - // powerline) that looks bad when extended. - const rac = self.rowAndCell(); - switch (rac.row.semantic_prompt) { - .prompt, .prompt_continuation, .input => return true, - .unknown, .command => {}, - } - - for (self.cells(.all)) |*cell| { - // If any cell has a default background color then we don't - // extend because the default background color probably looks - // good enough as an extension. - switch (cell.content_tag) { - // If it is a background color cell, we check the color. - .bg_color_palette, .bg_color_rgb => { - const s = self.style(cell); - const bg = s.bg(cell, palette) orelse return true; - if (bg.eql(default_background)) return true; - }, - - // If its a codepoint cell we can check the style. - .codepoint, .codepoint_grapheme => { - // For codepoint containing, we also never extend bg - // if any cell has a powerline glyph because these are - // perfect-fit. - switch (cell.codepoint()) { - // Powerline - 0xE0B0...0xE0C8, - 0xE0CA, - 0xE0CC...0xE0D2, - 0xE0D4, - => return true, - - else => {}, - } - - // Never extend a cell that has a default background. - // A default background is applied if there is no background - // on the style or the explicitly set background - // matches our default background. - const s = self.style(cell); - const bg = s.bg(cell, palette) orelse return true; - if (bg.eql(default_background)) return true; - }, - } - } - - return false; + self.rowAndCell().row.dirty = true; } /// Iterators. These are the same as PageList iterator funcs but operate @@ -4375,7 +4399,7 @@ const Cell = struct { /// This is not very performant this is primarily used for assertions /// and testing. pub fn isDirty(self: Cell) bool { - return self.node.data.isRowDirty(self.row_idx); + return self.node.data.dirty or self.row.dirty; } /// Get the cell style. @@ -6235,6 +6259,39 @@ test "PageList adjustCapacity to increase hyperlinks" { } } +test "PageList adjustCapacity after col shrink" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 2, 0); + defer s.deinit(); + + // Shrink columns - this updates size.cols but not capacity.cols + try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(5, s.cols); + + { + const page = &s.pages.first.?.data; + // capacity.cols is still 10, but size.cols should be 5 + try testing.expectEqual(5, page.size.cols); + try testing.expect(page.capacity.cols >= 10); + } + + // Now adjust capacity (e.g., to increase styles) + // This should preserve the current size.cols, not revert to capacity.cols + _ = try s.adjustCapacity( + s.pages.first.?, + .{ .styles = std_capacity.styles * 2 }, + ); + + { + const page = &s.pages.first.?.data; + // After adjustCapacity, size.cols should still be 5, not 10 + try testing.expectEqual(5, page.size.cols); + try testing.expectEqual(5, s.cols); + } +} + test "PageList pageIterator single page" { const testing = std.testing; const alloc = testing.allocator; @@ -6802,11 +6859,9 @@ test "PageList eraseRowBounded less than full row" { try testing.expectEqual(s.rows, s.totalRows()); // The erased rows should be dirty - try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 5 } })); try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 6 } })); try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 7 } })); - try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 8 } })); try testing.expectEqual(s.pages.first.?, p_top.node); try testing.expectEqual(@as(usize, 4), p_top.y); @@ -6840,7 +6895,6 @@ test "PageList eraseRowBounded with pin at top" { try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); - try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); try testing.expectEqual(s.pages.first.?, p_top.node); try testing.expectEqual(@as(usize, 0), p_top.y); @@ -6865,7 +6919,6 @@ test "PageList eraseRowBounded full rows single page" { try testing.expectEqual(s.rows, s.totalRows()); // The erased rows should be dirty - try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); for (5..10) |y| try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = @intCast(y), @@ -6931,7 +6984,6 @@ test "PageList eraseRowBounded full rows two pages" { try s.eraseRowBounded(.{ .active = .{ .y = 4 } }, 4); // The erased rows should be dirty - try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); for (4..8) |y| try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = @intCast(y), diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 4a02e2b13..980906e49 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -5,8 +5,6 @@ const Parser = @This(); const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; const testing = std.testing; const table = @import("parse_table.zig").table; const osc = @import("osc.zig"); @@ -312,6 +310,7 @@ pub fn next(self: *Parser, c: u8) [3]?Action { pub inline fn collect(self: *Parser, c: u8) void { if (self.intermediates_idx >= MAX_INTERMEDIATE) { + @branchHint(.cold); log.warn("invalid intermediates count", .{}); return; } @@ -348,9 +347,7 @@ inline fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { } // A numeric value. Add it to our accumulator. - if (self.param_acc_idx > 0) { - self.param_acc *|= 10; - } + self.param_acc *|= 10; self.param_acc +|= c - '0'; // Increment our accumulator index. If we overflow then @@ -386,6 +383,7 @@ inline fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { // We only allow colon or mixed separators for the 'm' command. if (c != 'm' and self.params_sep.count() > 0) { + @branchHint(.cold); log.warn( "CSI colon or mixed separators only allowed for 'm' command, got: {f}", .{result}, diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 8ed256869..ba2af2473 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -3,7 +3,7 @@ const Screen = @This(); const std = @import("std"); const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const ansi = @import("ansi.zig"); const charsets = @import("charsets.zig"); const fastmem = @import("../fastmem.zig"); @@ -88,7 +88,7 @@ pub const Dirty = packed struct { /// The cursor position and style. pub const Cursor = struct { - // The x/y position within the viewport. + // The x/y position within the active area. x: size.CellCountInt = 0, y: size.CellCountInt = 0, @@ -161,7 +161,7 @@ pub const SavedCursor = struct { /// State required for all charset operations. pub const CharsetState = struct { /// The list of graphical charsets by slot - charsets: CharsetArray = .initFill(charsets.Charset.utf8), + charsets: CharsetArray = .{}, /// GL is the slot to use when using a 7-bit printable char (up to 127) /// GR used for 8-bit printable chars. @@ -172,7 +172,41 @@ pub const CharsetState = struct { single_shift: ?charsets.Slots = null, /// An array to map a charset slot to a lookup table. - const CharsetArray = std.EnumArray(charsets.Slots, charsets.Charset); + /// + /// We use this bespoke struct instead of `std.EnumArray` because + /// accessing these slots is very performance critical since it's + /// done for every single print. This benchmarks faster. + const CharsetArray = struct { + g0: charsets.Charset = .utf8, + g1: charsets.Charset = .utf8, + g2: charsets.Charset = .utf8, + g3: charsets.Charset = .utf8, + + pub inline fn get( + self: *const CharsetArray, + slot: charsets.Slots, + ) charsets.Charset { + return switch (slot) { + .G0 => self.g0, + .G1 => self.g1, + .G2 => self.g2, + .G3 => self.g3, + }; + } + + pub inline fn set( + self: *CharsetArray, + slot: charsets.Slots, + charset: charsets.Charset, + ) void { + switch (slot) { + .G0 => self.g0 = charset, + .G1 => self.g1 = charset, + .G2 => self.g2 = charset, + .G3 => self.g3 = charset, + } + } + }; }; pub const Options = struct { @@ -752,10 +786,13 @@ pub fn cursorDownScroll(self: *Screen) !void { self.cursor.page_row, page.getCells(self.cursor.page_row), ); - - var dirty = page.dirtyBitSet(); - dirty.set(0); + self.cursorMarkDirty(); } else { + // The call to `eraseRow` will move the tracked cursor pin up by one + // row, but we don't actually want that, so we keep the old pin and + // put it back after calling `eraseRow`. + const old_pin = self.cursor.page_pin.*; + // eraseRow will shift everything below it up. try self.pages.eraseRow(.{ .active = .{} }); @@ -763,26 +800,15 @@ pub fn cursorDownScroll(self: *Screen) !void { // because eraseRow will mark all the rotated rows as dirty // in the entire page. - // We need to move our cursor down one because eraseRows will - // preserve our pin directly and we're erasing one row. - const page_pin = self.cursor.page_pin.down(1).?; - self.cursorChangePin(page_pin); - const page_rac = page_pin.rowAndCell(); + // We don't use `cursorChangePin` here because we aren't + // actually changing the pin, we're keeping it the same. + self.cursor.page_pin.* = old_pin; + + // We do, however, need to refresh the cached page row + // and cell, because `eraseRow` will have moved the row. + const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; - - // The above may clear our cursor so we need to update that - // again. If this fails (highly unlikely) we just reset - // the cursor. - self.manualStyleUpdate() catch |err| { - // This failure should not happen because manualStyleUpdate - // handles page splitting, overflow, and more. This should only - // happen if we're out of RAM. In this case, we'll just degrade - // gracefully back to the default style. - log.err("failed to update style on cursor scroll err={}", .{err}); - self.cursor.style = .{}; - self.cursor.style_id = 0; - }; } } else { const old_pin = self.cursor.page_pin.*; @@ -852,7 +878,7 @@ pub fn cursorScrollAbove(self: *Screen) !void { // the cursor always changes page rows inside this function, and // when that happens it can mean the text in the old row needs to // be re-shaped because the cursor splits runs to break ligatures. - self.cursor.page_pin.markDirty(); + self.cursorMarkDirty(); // If the cursor is on the bottom of the screen, its faster to use // our specialized function for that case. @@ -897,9 +923,11 @@ pub fn cursorScrollAbove(self: *Screen) !void { var rows = page.rows.ptr(page.memory.ptr); fastmem.rotateOnceR(Row, rows[pin.y..page.size.rows]); - // Mark all our rotated rows as dirty. - var dirty = page.dirtyBitSet(); - dirty.setRangeValue(.{ .start = pin.y, .end = page.size.rows }, true); + // Mark the whole page as dirty. + // + // Technically we only need to mark from the cursor row to the + // end but this is a hot function, so we want to minimize work. + page.dirty = true; // Setup our cursor caches after the rotation so it points to the // correct data @@ -964,9 +992,8 @@ fn cursorScrollAboveRotate(self: *Screen) !void { &prev_rows[prev_page.size.rows - 1], ); - // All rows we rotated are dirty - var dirty = cur_page.dirtyBitSet(); - dirty.setRangeValue(.{ .start = 0, .end = cur_page.size.rows }, true); + // Mark dirty on the page, since we are dirtying all rows with this. + cur_page.dirty = true; } // Our current is our cursor page, we need to rotate down from @@ -981,12 +1008,11 @@ fn cursorScrollAboveRotate(self: *Screen) !void { cur_page.getCells(&cur_rows[self.cursor.page_pin.y]), ); - // Set all the rows we rotated and cleared dirty - var dirty = cur_page.dirtyBitSet(); - dirty.setRangeValue( - .{ .start = self.cursor.page_pin.y, .end = cur_page.size.rows }, - true, - ); + // Mark the whole page as dirty. + // + // Technically we only need to mark from the cursor row to the + // end but this is a hot function, so we want to minimize work. + cur_page.dirty = true; // Setup cursor cache data after all the rotations so our // row is valid. @@ -1050,9 +1076,9 @@ pub fn cursorCopy(self: *Screen, other: Cursor, opts: struct { const other_page = &other.page_pin.node.data; const other_link = other_page.hyperlink_set.get(other_page.memory, other.hyperlink_id); - const uri = other_link.uri.offset.ptr(other_page.memory)[0..other_link.uri.len]; + const uri = other_link.uri.slice(other_page.memory); const id_ = switch (other_link.id) { - .explicit => |id| id.offset.ptr(other_page.memory)[0..id.len], + .explicit => |id| id.slice(other_page.memory), .implicit => null, }; @@ -1077,7 +1103,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { // we must mark the old and new page dirty. We do this as long // as the pins are not equal if (!self.cursor.page_pin.eql(new)) { - self.cursor.page_pin.markDirty(); + self.cursorMarkDirty(); new.markDirty(); } @@ -1147,7 +1173,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { /// Mark the cursor position as dirty. /// TODO: test pub inline fn cursorMarkDirty(self: *Screen) void { - self.cursor.page_pin.markDirty(); + self.cursor.page_row.dirty = true; } /// Reset the cursor row's soft-wrap state and the cursor's pending wrap. @@ -1275,10 +1301,6 @@ pub fn clearRows( var it = self.pages.pageIterator(.right_down, tl, bl); while (it.next()) |chunk| { - // Mark everything in this chunk as dirty - var dirty = chunk.node.data.dirtyBitSet(); - dirty.setRangeValue(.{ .start = chunk.start, .end = chunk.end }, true); - for (chunk.rows()) |*row| { const cells_offset = row.cells; const cells_multi: [*]Cell = row.cells.ptr(chunk.node.data.memory); @@ -1294,12 +1316,15 @@ pub fn clearRows( self.clearCells(&chunk.node.data, row, cells); row.* = .{ .cells = cells_offset }; } + + row.dirty = true; } } } -/// Clear the cells with the blank cell. This takes care to handle -/// cleaning up graphemes and styles. +/// Clear the cells with the blank cell. +/// +/// This takes care to handle cleaning up graphemes and styles. pub fn clearCells( self: *Screen, page: *Page, @@ -1326,30 +1351,54 @@ pub fn clearCells( assert(@intFromPtr(&cells[cells.len - 1]) <= @intFromPtr(&row_cells[row_cells.len - 1])); } - // If this row has graphemes, then we need go through a slow path - // and delete the cell graphemes. + // If we have managed memory (styles, graphemes, or hyperlinks) + // in this row then we go cell by cell and clear them if present. if (row.grapheme) { for (cells) |*cell| { - if (cell.hasGrapheme()) page.clearGrapheme(row, cell); + if (cell.hasGrapheme()) + page.clearGrapheme(cell); + } + + // If we have no left/right scroll region we can be sure + // that we've cleared all the graphemes, so we clear the + // flag, otherwise we ask the page to update the flag. + if (cells.len == self.pages.cols) { + row.grapheme = false; + } else { + page.updateRowGraphemeFlag(row); } } - // If we have hyperlinks, we need to clear those. if (row.hyperlink) { for (cells) |*cell| { - if (cell.hyperlink) page.clearHyperlink(row, cell); + if (cell.hyperlink) + page.clearHyperlink(cell); + } + + // If we have no left/right scroll region we can be sure + // that we've cleared all the hyperlinks, so we clear the + // flag, otherwise we ask the page to update the flag. + if (cells.len == self.pages.cols) { + row.hyperlink = false; + } else { + page.updateRowHyperlinkFlag(row); } } if (row.styled) { for (cells) |*cell| { - if (cell.style_id == style.default_id) continue; - page.styles.release(page.memory, cell.style_id); + if (cell.hasStyling()) + page.styles.release(page.memory, cell.style_id); } - // If we have no left/right scroll region we can be sure that - // the row is no longer styled. - if (cells.len == self.pages.cols) row.styled = false; + // If we have no left/right scroll region we can be sure + // that we've cleared all the styles, so we clear the + // flag, otherwise we ask the page to update the flag. + if (cells.len == self.pages.cols) { + row.styled = false; + } else { + page.updateRowStyledFlag(row); + } } if (comptime build_options.kitty_graphics) { @@ -1778,10 +1827,6 @@ pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void { self.cursor.style.flags.underline = v; }, - .reset_underline => { - self.cursor.style.flags.underline = .none; - }, - .underline_color => |rgb| { self.cursor.style.underline_color = .{ .rgb = .{ .r = rgb.r, @@ -2152,7 +2197,13 @@ pub fn cursorSetHyperlink(self: *Screen) !void { ); // Retry - return try self.cursorSetHyperlink(); + // + // We check that the cursor hyperlink hasn't been destroyed + // by the capacity adjustment first though- since despite the + // terrible code above, that can still apparently happen ._. + if (self.cursor.hyperlink_id > 0) { + return try self.cursorSetHyperlink(); + } }, } } diff --git a/src/terminal/ScreenSet.zig b/src/terminal/ScreenSet.zig index 1b6b053fe..418888694 100644 --- a/src/terminal/ScreenSet.zig +++ b/src/terminal/ScreenSet.zig @@ -8,7 +8,7 @@ const ScreenSet = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const Screen = @import("Screen.zig"); diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 59cb4ef50..bc597fc2e 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -2,7 +2,7 @@ const Selection = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const page = @import("page.zig"); const point = @import("point.zig"); const PageList = @import("PageList.zig"); @@ -280,23 +280,60 @@ pub fn contains(self: Selection, s: *const Screen, pin: Pin) bool { /// Get a selection for a single row in the screen. This will return null /// if the row is not included in the selection. +/// +/// This is a very expensive operation. It has to traverse the linked list +/// of pages for the top-left, bottom-right, and the given pin to find +/// the coordinates. If you are calling this repeatedly, prefer +/// `containedRowCached`. pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection { const tl_pin = self.topLeft(s); const br_pin = self.bottomRight(s); // This is definitely not very efficient. Low-hanging fruit to - // improve this. + // improve this. Callers should prefer containedRowCached if they + // can swing it. const tl = s.pages.pointFromPin(.screen, tl_pin).?.screen; const br = s.pages.pointFromPin(.screen, br_pin).?.screen; const p = s.pages.pointFromPin(.screen, pin).?.screen; + return self.containedRowCached( + s, + tl_pin, + br_pin, + pin, + tl, + br, + p, + ); +} + +/// Same as containedRow but useful if you're calling it repeatedly +/// so that the pins can be cached across calls. Advanced. +pub fn containedRowCached( + self: Selection, + s: *const Screen, + tl_pin: Pin, + br_pin: Pin, + pin: Pin, + tl: point.Coordinate, + br: point.Coordinate, + p: point.Coordinate, +) ?Selection { if (p.y < tl.y or p.y > br.y) return null; // Rectangle case: we can return early as the x range will always be the // same. We've already validated that the row is in the selection. if (self.rectangle) return init( - s.pages.pin(.{ .screen = .{ .y = p.y, .x = tl.x } }).?, - s.pages.pin(.{ .screen = .{ .y = p.y, .x = br.x } }).?, + start: { + var copy: Pin = pin; + copy.x = tl.x; + break :start copy; + }, + end: { + var copy: Pin = pin; + copy.x = br.x; + break :end copy; + }, true, ); @@ -309,7 +346,11 @@ pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection { // Selection top-left line matches only. return init( tl_pin, - s.pages.pin(.{ .screen = .{ .y = p.y, .x = s.pages.cols - 1 } }).?, + end: { + var copy: Pin = pin; + copy.x = s.pages.cols - 1; + break :end copy; + }, false, ); } @@ -320,7 +361,11 @@ pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection { if (p.y == br.y) { assert(p.y != tl.y); return init( - s.pages.pin(.{ .screen = .{ .y = p.y, .x = 0 } }).?, + start: { + var copy: Pin = pin; + copy.x = 0; + break :start copy; + }, br_pin, false, ); @@ -328,8 +373,16 @@ pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection { // Row is somewhere between our selection lines so we return the full line. return init( - s.pages.pin(.{ .screen = .{ .y = p.y, .x = 0 } }).?, - s.pages.pin(.{ .screen = .{ .y = p.y, .x = s.pages.cols - 1 } }).?, + start: { + var copy: Pin = pin; + copy.x = 0; + break :start copy; + }, + end: { + var copy: Pin = pin; + copy.x = s.pages.cols - 1; + break :end copy; + }, false, ); } diff --git a/src/terminal/Tabstops.zig b/src/terminal/Tabstops.zig index c352cb351..13d6dc52e 100644 --- a/src/terminal/Tabstops.zig +++ b/src/terminal/Tabstops.zig @@ -12,7 +12,7 @@ const Tabstops = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const testing = std.testing; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const fastmem = @import("../fastmem.zig"); /// Unit is the type we use per tabstop unit (see file docs). diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 472b390d1..6c9db6a8d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5,8 +5,7 @@ const Terminal = @This(); const std = @import("std"); const build_options = @import("terminal_options"); -const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const unicode = @import("../unicode/main.zig"); @@ -112,6 +111,16 @@ flags: packed struct { /// True if the terminal should perform selection scrolling. selection_scroll: bool = false, + /// Dirty flag used only by the search thread. The renderer is expected + /// to set this to true if the viewport was dirty as it was rendering. + /// This is used by the search thread to more efficiently re-search the + /// viewport and active area. + /// + /// Since the renderer is going to inspect the viewport/active area ANYWAYS, + /// this lets our search thread do less work and hold the lock less time, + /// resulting in more throughput for everything. + search_viewport_dirty: bool = false, + /// Dirty flags for the renderer. dirty: Dirty = .{}, } = .{}, @@ -293,7 +302,10 @@ pub fn print(self: *Terminal, c: u21) !void { // log.debug("print={x} y={} x={}", .{ c, self.screens.active.cursor.y, self.screens.active.cursor.x }); // If we're not on the main display, do nothing for now - if (self.status_display != .main) return; + if (self.status_display != .main) { + @branchHint(.cold); + return; + } // After doing any printing, wrapping, scrolling, etc. we want to ensure // that our screen remains in a consistent state. @@ -313,6 +325,7 @@ pub fn print(self: *Terminal, c: u21) !void { self.modes.get(.grapheme_cluster) and self.screens.active.cursor.x > 0) grapheme: { + @branchHint(.unlikely); // We need the previous cell to determine if we're at a grapheme // break or not. If we are NOT, then we are still combining the // same grapheme. Otherwise, we can stay in this cell. @@ -370,20 +383,10 @@ pub fn print(self: *Terminal, c: u21) !void { // the cell width accordingly. VS16 makes the character wide and // VS15 makes it narrow. if (c == 0xFE0F or c == 0xFE0E) { - // This check below isn't robust enough to be correct. - // But it is correct enough (the emoji check alone served us - // well through Ghostty 1.2.3!) and we can fix it up later. - - // Emoji always allow VS15/16 const prev_props = unicode.table.get(prev.cell.content.codepoint); - const emoji = prev_props.grapheme_boundary_class.isExtendedPictographic(); - if (!emoji) valid_check: { - // If not an emoji, check if it is a defined variation - // sequence in emoji-variation-sequences.txt - if (c == 0xFE0F and prev_props.emoji_vs_emoji) break :valid_check; - if (c == 0xFE0E and prev_props.emoji_vs_text) break :valid_check; - return; - } + // Check if it is a valid variation sequence in + // emoji-variation-sequences.txt, and if not, ignore the char. + if (!prev_props.emoji_vs_base) return; switch (c) { 0xFE0F => wide: { @@ -478,6 +481,7 @@ pub fn print(self: *Terminal, c: u21) !void { // Attach zero-width characters to our cell as grapheme data. if (width == 0) { + @branchHint(.unlikely); // If we have grapheme clustering enabled, we don't blindly attach // any zero width character to our cells and we instead just ignore // it. @@ -535,6 +539,7 @@ pub fn print(self: *Terminal, c: u21) !void { switch (width) { // Single cell is very easy: just write in the cell 1 => { + @branchHint(.likely); self.screens.active.cursorMarkDirty(); @call(.always_inline, printCell, .{ self, c, .narrow }); }, @@ -602,10 +607,14 @@ fn printCell( self.screens.active.charset.single_shift = null; break :blk key_once; } else self.screens.active.charset.gl; + const set = self.screens.active.charset.charsets.get(key); // UTF-8 or ASCII is used as-is - if (set == .utf8 or set == .ascii) break :c unmapped_c; + if (set == .utf8 or set == .ascii) { + @branchHint(.likely); + break :c unmapped_c; + } // If we're outside of ASCII range this is an invalid value in // this table so we just return space. @@ -673,10 +682,9 @@ fn printCell( // If the prior value had graphemes, clear those if (cell.hasGrapheme()) { - self.screens.active.cursor.page_pin.node.data.clearGrapheme( - self.screens.active.cursor.page_row, - cell, - ); + const page = &self.screens.active.cursor.page_pin.node.data; + page.clearGrapheme(cell); + page.updateRowGraphemeFlag(self.screens.active.cursor.page_row); } // We don't need to update the style refs unless the @@ -718,6 +726,7 @@ fn printCell( // row so that the renderer can lookup rows with these much faster. if (comptime build_options.kitty_graphics) { if (c == kitty.graphics.unicode.placeholder) { + @branchHint(.unlikely); self.screens.active.cursor.page_row.kitty_virtual_placeholder = true; } } @@ -727,13 +736,15 @@ fn printCell( // overwriting the same hyperlink. if (self.screens.active.cursor.hyperlink_id > 0) { self.screens.active.cursorSetHyperlink() catch |err| { + @branchHint(.unlikely); log.warn("error reallocating for more hyperlink space, ignoring hyperlink err={}", .{err}); assert(!cell.hyperlink); }; } else if (had_hyperlink) { // If the previous cell had a hyperlink then we need to clear it. var page = &self.screens.active.cursor.page_pin.node.data; - page.clearHyperlink(self.screens.active.cursor.page_row, cell); + page.clearHyperlink(cell); + page.updateRowHyperlinkFlag(self.screens.active.cursor.page_row); } } @@ -1405,10 +1416,10 @@ pub fn scrollUp(self: *Terminal, count: usize) void { /// Options for scrolling the viewport of the terminal grid. pub const ScrollViewport = union(enum) { /// Scroll to the top of the scrollback - top: void, + top, /// Scroll to the bottom, i.e. the top of the active area - bottom: void, + bottom, /// Scroll by some delta amount, up is negative. delta: isize, @@ -1462,7 +1473,8 @@ fn rowWillBeShifted( if (left_cell.wide == .spacer_tail) { const wide_cell: *Cell = &cells[self.scrolling_region.left - 1]; if (wide_cell.hasGrapheme()) { - page.clearGrapheme(row, wide_cell); + page.clearGrapheme(wide_cell); + page.updateRowGraphemeFlag(row); } wide_cell.content.codepoint = 0; wide_cell.wide = .narrow; @@ -1472,7 +1484,8 @@ fn rowWillBeShifted( if (right_cell.wide == .wide) { const tail_cell: *Cell = &cells[self.scrolling_region.right + 1]; if (right_cell.hasGrapheme()) { - page.clearGrapheme(row, right_cell); + page.clearGrapheme(right_cell); + page.updateRowGraphemeFlag(row); } right_cell.content.codepoint = 0; right_cell.wide = .narrow; @@ -1660,6 +1673,9 @@ pub fn insertLines(self: *Terminal, count: usize) void { dst_row.* = src_row.*; src_row.* = dst; + // Make sure the row is marked as dirty though. + dst_row.dirty = true; + // Ensure what we did didn't corrupt the page cur_p.node.data.assertIntegrity(); } else { @@ -1855,6 +1871,9 @@ pub fn deleteLines(self: *Terminal, count: usize) void { dst_row.* = src_row.*; src_row.* = dst; + // Make sure the row is marked as dirty though. + dst_row.dirty = true; + // Ensure what we did didn't corrupt the page cur_p.node.data.assertIntegrity(); } else { @@ -3268,7 +3287,7 @@ test "Terminal: print invalid VS16 non-grapheme" { try t.print('x'); try t.print(0xFE0F); - // We should have 2 cells taken up. It is one character but "wide". + // We should have 1 narrow cell. try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); @@ -3581,6 +3600,71 @@ test "Terminal: VS15 on already narrow emoji" { } } +test "Terminal: print invalid VS15 following emoji is wide" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print('\u{1F9E0}'); // 🧠 + try t.print(0xFE0E); // not valid with U+1F9E0 as base + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '\u{1F9E0}'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: print invalid VS15 in emoji ZWJ sequence" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print('\u{1F469}'); // 👩 + try t.print(0xFE0E); // not valid with U+1F469 as base + try t.print('\u{200D}'); // ZWJ + try t.print('\u{1F466}'); // 👦 + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '\u{1F469}'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ '\u{200D}', '\u{1F466}' }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + test "Terminal: VS15 to make narrow character with pending wrap" { var t = try init(testing.allocator, .{ .rows = 5, .cols = 2 }); defer t.deinit(testing.allocator); @@ -3703,9 +3787,9 @@ test "Terminal: print invalid VS16 grapheme" { // https://github.com/mitchellh/ghostty/issues/1482 try t.print('x'); - try t.print(0xFE0F); + try t.print(0xFE0F); // invalid VS16 - // We should have 2 cells taken up. It is one character but "wide". + // We should have 1 cells taken up, and narrow. try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); @@ -3738,7 +3822,7 @@ test "Terminal: print invalid VS16 with second char" { try t.print(0xFE0F); try t.print('y'); - // We should have 2 cells taken up. It is one character but "wide". + // We should have 2 cells taken up, from two separate narrow characters. try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); @@ -3761,6 +3845,40 @@ test "Terminal: print invalid VS16 with second char" { } } +test "Terminal: print invalid VS16 with second char (combining)" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // https://github.com/mitchellh/ghostty/issues/1482 + try t.print('n'); + try t.print(0xFE0F); // invalid VS16 + try t.print(0x0303); // combining tilde + + // We should have 1 cells taken up, and narrow. + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'n'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{'\u{0303}'}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + test "Terminal: overwrite grapheme should clear grapheme data" { var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 704c3fbe3..3ebacbbff 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -1,6 +1,5 @@ const std = @import("std"); const build_options = @import("terminal_options"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const kitty_gfx = @import("kitty/graphics.zig"); diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index 894172b4c..258d73071 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const size = @import("size.zig"); const getOffset = size.getOffset; diff --git a/src/terminal/c/key_encode.zig b/src/terminal/c/key_encode.zig index 47bd904e0..063cd8df7 100644 --- a/src/terminal/c/key_encode.zig +++ b/src/terminal/c/key_encode.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; diff --git a/src/terminal/c/key_event.zig b/src/terminal/c/key_event.zig index b52932fdd..748b8799c 100644 --- a/src/terminal/c/key_event.zig +++ b/src/terminal/c/key_event.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index 124fc3b7c..c4cdaad3b 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -1,6 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; -const builtin = @import("builtin"); const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; const osc = @import("../osc.zig"); diff --git a/src/terminal/c/sgr.zig b/src/terminal/c/sgr.zig index e65b9e3ee..53536417f 100644 --- a/src/terminal/c/sgr.zig +++ b/src/terminal/c/sgr.zig @@ -1,8 +1,6 @@ const std = @import("std"); -const assert = std.debug.assert; const testing = std.testing; const Allocator = std.mem.Allocator; -const builtin = @import("builtin"); const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; const sgr = @import("../sgr.zig"); diff --git a/src/terminal/charsets.zig b/src/terminal/charsets.zig index b4fd58efc..00a2d8d1f 100644 --- a/src/terminal/charsets.zig +++ b/src/terminal/charsets.zig @@ -1,6 +1,6 @@ const std = @import("std"); const build_options = @import("terminal_options"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const LibEnum = @import("../lib/enum.zig").Enum; /// The available charset slots for a terminal. @@ -24,7 +24,7 @@ pub const Charset = LibEnum( /// The table for the given charset. This returns a pointer to a /// slice that is guaranteed to be 255 chars that can be used to map /// ASCII to the given charset. -pub fn table(set: Charset) []const u16 { +pub inline fn table(set: Charset) []const u16 { return switch (set) { .british => &british, .dec_special => &dec_special, diff --git a/src/terminal/color.zig b/src/terminal/color.zig index 4492d65ae..07c3e72f5 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -1,7 +1,7 @@ const colorpkg = @This(); const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const x11_color = @import("x11_color.zig"); /// The default palette. @@ -356,8 +356,12 @@ pub const RGB = packed struct(u24) { /// /// The value should be between 0.0 and 1.0, inclusive. fn fromIntensity(value: []const u8) !u8 { - const i = std.fmt.parseFloat(f64, value) catch return error.InvalidFormat; + const i = std.fmt.parseFloat(f64, value) catch { + @branchHint(.cold); + return error.InvalidFormat; + }; if (i < 0.0 or i > 1.0) { + @branchHint(.cold); return error.InvalidFormat; } @@ -370,10 +374,15 @@ pub const RGB = packed struct(u24) { /// value scaled in 4, 8, 12, or 16 bits, respectively. fn fromHex(value: []const u8) !u8 { if (value.len == 0 or value.len > 4) { + @branchHint(.cold); return error.InvalidFormat; } - const color = std.fmt.parseUnsigned(u16, value, 16) catch return error.InvalidFormat; + const color = std.fmt.parseUnsigned(u16, value, 16) catch { + @branchHint(.cold); + return error.InvalidFormat; + }; + const divisor: usize = switch (value.len) { 1 => std.math.maxInt(u4), 2 => std.math.maxInt(u8), @@ -407,6 +416,7 @@ pub const RGB = packed struct(u24) { /// per color channel. pub fn parse(value: []const u8) !RGB { if (value.len == 0) { + @branchHint(.cold); return error.InvalidFormat; } @@ -433,7 +443,10 @@ pub const RGB = packed struct(u24) { .b = try RGB.fromHex(value[9..13]), }, - else => return error.InvalidFormat, + else => { + @branchHint(.cold); + return error.InvalidFormat; + }, } } @@ -443,6 +456,7 @@ pub const RGB = packed struct(u24) { if (x11_color.map.get(std.mem.trim(u8, value, " "))) |rgb| return rgb; if (value.len < "rgb:a/a/a".len or !std.mem.eql(u8, value[0..3], "rgb")) { + @branchHint(.cold); return error.InvalidFormat; } @@ -454,6 +468,7 @@ pub const RGB = packed struct(u24) { } else false; if (value[i] != ':') { + @branchHint(.cold); return error.InvalidFormat; } @@ -462,8 +477,10 @@ pub const RGB = packed struct(u24) { const r = r: { const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end| value[i..end] - else + else { + @branchHint(.cold); return error.InvalidFormat; + }; i += slice.len + 1; @@ -476,8 +493,10 @@ pub const RGB = packed struct(u24) { const g = g: { const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end| value[i..end] - else + else { + @branchHint(.cold); return error.InvalidFormat; + }; i += slice.len + 1; diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 971ea13a0..52f696131 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -1,6 +1,6 @@ const std = @import("std"); const build_options = @import("terminal_options"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const terminal = @import("main.zig"); const DCS = terminal.DCS; diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 35fd71665..74bbfe482 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const color = @import("color.zig"); const size = @import("size.zig"); @@ -825,6 +825,8 @@ pub const PageFormatter = struct { /// byte written to the writer offset by the byte index. It is the /// caller's responsibility to free the map. /// + /// The x/y coordinate will be the coordinates within the page. + /// /// Warning: there is a significant performance hit to track this point_map: ?struct { alloc: Allocator, @@ -1450,6 +1452,76 @@ test "Page plain single line" { ); } +test "Page plain single line soft-wrapped unwrapped" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 3, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello!"); + + // Verify we have only a single page + const pages = &t.screens.active.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + // Create the formatter + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ + .emit = .plain, + .unwrap = true, + }); + + // Test our point map. + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Verify output + // Note: we don't test the trailing state, which may have bugs + // with unwrap... + _ = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello!", output); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + try testing.expectEqual( + Coordinate{ .x = 0, .y = 0 }, + point_map.items[0], + ); + try testing.expectEqual( + Coordinate{ .x = 1, .y = 0 }, + point_map.items[1], + ); + try testing.expectEqual( + Coordinate{ .x = 2, .y = 0 }, + point_map.items[2], + ); + try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[3], + ); + try testing.expectEqual( + Coordinate{ .x = 1, .y = 1 }, + point_map.items[4], + ); + try testing.expectEqual( + Coordinate{ .x = 2, .y = 1 }, + point_map.items[5], + ); +} + test "Page plain single wide char" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index 23b10950e..e06050605 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -31,8 +31,7 @@ //! bottleneck. const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const autoHash = std.hash.autoHash; const math = std.math; const mem = std.mem; diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig new file mode 100644 index 000000000..582ef6f06 --- /dev/null +++ b/src/terminal/highlight.zig @@ -0,0 +1,204 @@ +//! Highlights are any contiguous sequences of cells that should +//! be called out in some way, most commonly for text selection but +//! also search results or any other purpose. +//! +//! Within the terminal package, a highlight is a generic concept +//! that represents a range of cells. + +// NOTE: The plan is for highlights to ultimately replace Selection +// completely. Selection is deeply tied to various parts of the Ghostty +// internals so this may take some time. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const size = @import("size.zig"); +const PageList = @import("PageList.zig"); +const PageChunk = PageList.PageIterator.Chunk; +const Pin = PageList.Pin; +const Screen = @import("Screen.zig"); + +/// An untracked highlight is a highlight that stores its highlighted +/// area as a top-left and bottom-right screen pin. Since it is untracked, +/// the pins are only valid for the current terminal state and may not +/// be safe to use after any terminal modifications. +/// +/// For rectangle highlights/selections, the downstream consumer of this +/// code is expected to interpret the pins in whatever shape they want. +/// For example, a rectangular selection would interpret the pins as +/// setting the x bounds for each row between start.y and end.y. +/// +/// To simplify all operations, start MUST be before or equal to end. +pub const Untracked = struct { + start: Pin, + end: Pin, + + pub fn track( + self: *const Untracked, + screen: *Screen, + ) Allocator.Error!Tracked { + return try .init( + screen, + self.start, + self.end, + ); + } + + pub fn eql(self: Untracked, other: Untracked) bool { + return self.start.eql(other.start) and self.end.eql(other.end); + } +}; + +/// A tracked highlight is a highlight that stores its highlighted +/// area as tracked pins within a screen. +/// +/// A tracked highlight ensures that the pins remain valid even as +/// the terminal state changes. Because of this, tracked highlights +/// have more operations available to them. +/// +/// There is more overhead to creating and maintaining tracked highlights. +/// If you're manipulating highlights that are untracked and you're sure +/// that the terminal state won't change, you can use the `initAssume` +/// function. +pub const Tracked = struct { + start: *Pin, + end: *Pin, + + pub fn init( + screen: *Screen, + start: Pin, + end: Pin, + ) Allocator.Error!Tracked { + const start_tracked = try screen.pages.trackPin(start); + errdefer screen.pages.untrackPin(start_tracked); + const end_tracked = try screen.pages.trackPin(end); + errdefer screen.pages.untrackPin(end_tracked); + return .{ + .start = start_tracked, + .end = end_tracked, + }; + } + + /// Initializes a tracked highlight by assuming that the provided + /// pins are already tracked. This allows callers to perform tracked + /// operations without the overhead of tracking the pins, if the + /// caller can guarantee that the pins are already tracked or that + /// the terminal state will not change. + /// + /// Do not call deinit on highlights created with this function. + pub fn initAssume( + start: *Pin, + end: *Pin, + ) Tracked { + return .{ + .start = start, + .end = end, + }; + } + + pub fn deinit( + self: Tracked, + screen: *Screen, + ) void { + screen.pages.untrackPin(self.start); + screen.pages.untrackPin(self.end); + } +}; + +/// A flattened highlight is a highlight that stores its highlighted +/// area as a list of page chunks. This representation allows for +/// traversing the entire highlighted area without needing to read any +/// terminal state or dereference any page nodes (which may have been +/// pruned). +pub const Flattened = struct { + /// The page chunks that make up this highlight. This handles the + /// y bounds since chunks[0].start is the first highlighted row + /// and chunks[len - 1].end is the last highlighted row (exclsive). + chunks: std.MultiArrayList(Chunk), + + /// The x bounds of the highlight. `bot_x` may be less than `top_x` + /// for typical left-to-right highlights: can start the selection right + /// of the end on a higher row. + top_x: size.CellCountInt, + bot_x: size.CellCountInt, + + /// A flattened chunk is almost identical to a PageList.Chunk but + /// we also flatten the serial number. This lets the flattened + /// highlight more robust for comparisons and validity checks with + /// the PageList. + pub const Chunk = struct { + node: *PageList.List.Node, + serial: u64, + start: size.CellCountInt, + end: size.CellCountInt, + }; + + pub const empty: Flattened = .{ + .chunks = .empty, + .top_x = 0, + .bot_x = 0, + }; + + pub fn init( + alloc: Allocator, + start: Pin, + end: Pin, + ) Allocator.Error!Flattened { + var result: std.MultiArrayList(PageChunk) = .empty; + errdefer result.deinit(alloc); + var it = start.pageIterator(.right_down, end); + while (it.next()) |chunk| try result.append(alloc, .{ + .node = chunk.node, + .serial = chunk.node.serial, + .start = chunk.start, + .end = chunk.end, + }); + return .{ + .chunks = result, + .top_x = start.x, + .end_x = end.x, + }; + } + + pub fn deinit(self: *Flattened, alloc: Allocator) void { + self.chunks.deinit(alloc); + } + + pub fn clone(self: *const Flattened, alloc: Allocator) Allocator.Error!Flattened { + return .{ + .chunks = try self.chunks.clone(alloc), + .top_x = self.top_x, + .bot_x = self.bot_x, + }; + } + + pub fn startPin(self: Flattened) Pin { + const slice = self.chunks.slice(); + return .{ + .node = slice.items(.node)[0], + .x = self.top_x, + .y = slice.items(.start)[0], + }; + } + + /// Convert to an Untracked highlight. + pub fn untracked(self: Flattened) Untracked { + // Note: we don't use startPin/endPin here because it is slightly + // faster to reuse the slices. + const slice = self.chunks.slice(); + const nodes = slice.items(.node); + const starts = slice.items(.start); + const ends = slice.items(.end); + return .{ + .start = .{ + .node = nodes[0], + .x = self.top_x, + .y = starts[0], + }, + .end = .{ + .node = nodes[nodes.len - 1], + .x = self.bot_x, + .y = ends[ends.len - 1] - 1, + }, + }; + } +}; diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig index c608321b1..975e6f30e 100644 --- a/src/terminal/hyperlink.zig +++ b/src/terminal/hyperlink.zig @@ -1,6 +1,5 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; const hash_map = @import("hash_map.zig"); const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; const pagepkg = @import("page.zig"); @@ -103,7 +102,7 @@ pub const PageEntry = struct { // Copy the URI { - const uri = self.uri.offset.ptr(self_page.memory)[0..self.uri.len]; + const uri = self.uri.slice(self_page.memory); const buf = try dst_page.string_alloc.alloc(u8, dst_page.memory, uri.len); @memcpy(buf, uri); copy.uri = .{ @@ -113,14 +112,14 @@ pub const PageEntry = struct { } errdefer dst_page.string_alloc.free( dst_page.memory, - copy.uri.offset.ptr(dst_page.memory)[0..copy.uri.len], + copy.uri.slice(dst_page.memory), ); // Copy the ID switch (copy.id) { .implicit => {}, // Shallow is fine .explicit => |slice| { - const id = slice.offset.ptr(self_page.memory)[0..slice.len]; + const id = slice.slice(self_page.memory); const buf = try dst_page.string_alloc.alloc(u8, dst_page.memory, id.len); @memcpy(buf, id); copy.id = .{ .explicit = .{ @@ -133,7 +132,7 @@ pub const PageEntry = struct { .implicit => {}, .explicit => |v| dst_page.string_alloc.free( dst_page.memory, - v.offset.ptr(dst_page.memory)[0..v.len], + v.slice(dst_page.memory), ), }; @@ -147,13 +146,13 @@ pub const PageEntry = struct { .implicit => |v| autoHash(&hasher, v), .explicit => |slice| autoHashStrat( &hasher, - slice.offset.ptr(base)[0..slice.len], + slice.slice(base), .Deep, ), } autoHashStrat( &hasher, - self.uri.offset.ptr(base)[0..self.uri.len], + self.uri.slice(base), .Deep, ); return hasher.final(); @@ -181,8 +180,8 @@ pub const PageEntry = struct { return std.mem.eql( u8, - self.uri.offset.ptr(self_base)[0..self.uri.len], - other.uri.offset.ptr(other_base)[0..other.uri.len], + self.uri.slice(self_base), + other.uri.slice(other_base), ); } @@ -196,12 +195,12 @@ pub const PageEntry = struct { .implicit => {}, .explicit => |v| alloc.free( page.memory, - v.offset.ptr(page.memory)[0..v.len], + v.slice(page.memory), ), } alloc.free( page.memory, - self.uri.offset.ptr(page.memory)[0..self.uri.len], + self.uri.slice(page.memory), ); } }; diff --git a/src/terminal/kitty/color.zig b/src/terminal/kitty/color.zig index dface5723..deeabcfb7 100644 --- a/src/terminal/kitty/color.zig +++ b/src/terminal/kitty/color.zig @@ -1,6 +1,4 @@ const std = @import("std"); -const build_options = @import("terminal_options"); -const LibEnum = @import("../../lib/enum.zig").Enum; const terminal = @import("../main.zig"); const RGB = terminal.color.RGB; const Terminator = terminal.osc.Terminator; diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index 99a7cdaac..dfce56e35 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const simd = @import("../../simd/main.zig"); diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 1559c0cec..5b3ab915d 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -1,9 +1,7 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const renderer = @import("../../renderer.zig"); -const point = @import("../point.zig"); const Terminal = @import("../Terminal.zig"); const command = @import("graphics_command.zig"); const image = @import("graphics_image.zig"); diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index f485e0161..d2877cfc2 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -1,13 +1,12 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const posix = std.posix; const fastmem = @import("../../fastmem.zig"); const command = @import("graphics_command.zig"); -const point = @import("../point.zig"); const PageList = @import("../PageList.zig"); const wuffs = @import("wuffs"); diff --git a/src/terminal/kitty/graphics_render.zig b/src/terminal/kitty/graphics_render.zig index af888582f..946b537a8 100644 --- a/src/terminal/kitty/graphics_render.zig +++ b/src/terminal/kitty/graphics_render.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const assert = std.debug.assert; const testing = std.testing; const terminal = @import("../main.zig"); diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index cfa654ae8..8ff68e3fa 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; diff --git a/src/terminal/kitty/graphics_unicode.zig b/src/terminal/kitty/graphics_unicode.zig index b2a90296c..ceadf63ee 100644 --- a/src/terminal/kitty/graphics_unicode.zig +++ b/src/terminal/kitty/graphics_unicode.zig @@ -2,7 +2,7 @@ //! Kitty graphics protocol unicode placeholder, virtual placement feature. const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const terminal = @import("../main.zig"); const kitty_gfx = terminal.kitty.graphics; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index d57bd6530..06c930014 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -1,10 +1,8 @@ -const builtin = @import("builtin"); - const charsets = @import("charsets.zig"); const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); -const hyperlink = @import("hyperlink.zig"); +const render = @import("render.zig"); const stream_readonly = @import("stream_readonly.zig"); const style = @import("style.zig"); pub const apc = @import("apc.zig"); @@ -14,6 +12,7 @@ pub const point = @import("point.zig"); pub const color = @import("color.zig"); pub const device_status = @import("device_status.zig"); pub const formatter = @import("formatter.zig"); +pub const highlight = @import("highlight.zig"); pub const kitty = @import("kitty.zig"); pub const modes = @import("modes.zig"); pub const page = @import("page.zig"); @@ -40,6 +39,7 @@ pub const Pin = PageList.Pin; pub const Point = point.Point; pub const ReadonlyHandler = stream_readonly.Handler; pub const ReadonlyStream = stream_readonly.Stream; +pub const RenderState = render.RenderState; pub const Screen = @import("Screen.zig"); pub const ScreenSet = @import("ScreenSet.zig"); pub const Scrollbar = PageList.Scrollbar; diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index effdfbd62..f62b7a6cd 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -9,7 +9,7 @@ const std = @import("std"); const builtin = @import("builtin"); const build_options = @import("terminal_options"); const mem = std.mem; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = mem.Allocator; const LibEnum = @import("../lib/enum.zig").Enum; const RGB = @import("color.zig").RGB; @@ -524,6 +524,7 @@ pub const Parser = struct { // We always keep space for 1 byte at the end to null-terminate // values. if (self.buf_idx >= self.buf.len - 1) { + @branchHint(.cold); if (self.state != .invalid) { log.warn( "OSC sequence too long (> {d}), ignoring. state={}", @@ -1048,6 +1049,7 @@ pub const Parser = struct { ';' => { const ext = self.buf[self.buf_start .. self.buf_idx - 1]; if (!std.mem.eql(u8, ext, "notify")) { + @branchHint(.cold); log.warn("unknown rxvt extension: {s}", .{ext}); self.state = .invalid; return; @@ -1601,11 +1603,13 @@ pub const Parser = struct { fn endKittyColorProtocolOption(self: *Parser, kind: enum { key_only, key_and_value }, final: bool) void { if (self.temp_state.key.len == 0) { + @branchHint(.cold); log.warn("zero length key in kitty color protocol", .{}); return; } const key = kitty_color.Kind.parse(self.temp_state.key) orelse { + @branchHint(.cold); log.warn("unknown key in kitty color protocol: {s}", .{self.temp_state.key}); return; }; @@ -1620,6 +1624,7 @@ pub const Parser = struct { .kitty_color_protocol => |*v| { // Cap our allocation amount for our list. if (v.list.items.len >= @as(usize, kitty_color.Kind.max) * 2) { + @branchHint(.cold); self.state = .invalid; log.warn("exceeded limit for number of keys in kitty color protocol, ignoring", .{}); return; @@ -1631,11 +1636,13 @@ pub const Parser = struct { if (kind == .key_only or value.len == 0) { v.list.append(alloc, .{ .reset = key }) catch |err| { + @branchHint(.cold); log.warn("unable to append kitty color protocol option: {}", .{err}); return; }; } else if (mem.eql(u8, "?", value)) { v.list.append(alloc, .{ .query = key }) catch |err| { + @branchHint(.cold); log.warn("unable to append kitty color protocol option: {}", .{err}); return; }; @@ -1651,6 +1658,7 @@ pub const Parser = struct { }, }, }) catch |err| { + @branchHint(.cold); log.warn("unable to append kitty color protocol option: {}", .{err}); return; }; @@ -1681,6 +1689,7 @@ pub const Parser = struct { const alloc = self.alloc.?; const list = self.buf_dynamic.?; list.append(alloc, 0) catch { + @branchHint(.cold); log.warn("allocation failed on allocable string termination", .{}); self.temp_state.str.* = ""; return; diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 5c83fc7c8..124ff2545 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -3,14 +3,13 @@ const builtin = @import("builtin"); const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const posix = std.posix; const fastmem = @import("../fastmem.zig"); const color = @import("color.zig"); const hyperlink = @import("hyperlink.zig"); const kitty = @import("kitty.zig"); -const sgr = @import("sgr.zig"); const stylepkg = @import("style.zig"); const Style = stylepkg.Style; const StyleId = stylepkg.Id; @@ -108,6 +107,15 @@ pub const Page = struct { /// first column, all cells in that row are laid out in column order. cells: Offset(Cell), + /// Set to true when an operation is performed that dirties all rows in + /// the page. See `Row.dirty` for more information on dirty tracking. + /// + /// NOTE: A value of false does NOT indicate that + /// the page has no dirty rows in it, only + /// that no full-page-dirtying operations + /// have occurred since it was last cleared. + dirty: bool, + /// The string allocator for this page used for shared utf-8 encoded /// strings. Liveness of strings and memory management is deferred to /// the individual use case. @@ -136,44 +144,6 @@ pub const Page = struct { hyperlink_map: hyperlink.Map, hyperlink_set: hyperlink.Set, - /// The offset to the first mask of dirty bits in the page. - /// - /// The dirty bits is a contiguous array of usize where each bit represents - /// a row in the page, in order. If the bit is set, then the row is dirty - /// and requires a redraw. Dirty status is only ever meant to convey that - /// a cell has changed visually. A cell which changes in a way that doesn't - /// affect the visual representation may not be marked as dirty. - /// - /// Dirty tracking may have false positives but should never have false - /// negatives. A false negative would result in a visual artifact on the - /// screen. - /// - /// Dirty bits are only ever unset by consumers of a page. The page - /// structure itself does not unset dirty bits since the page does not - /// know when a cell has been redrawn. - /// - /// As implementation background: it may seem that dirty bits should be - /// stored elsewhere and not on the page itself, because the only data - /// that could possibly change is in the active area of a terminal - /// historically and that area is small compared to the typical scrollback. - /// My original thinking was to put the dirty bits on Screen instead and - /// have them only track the active area. However, I decided to put them - /// into the page directly for a few reasons: - /// - /// 1. It's simpler. The page is a self-contained unit and it's nice - /// to have all the data for a page in one place. - /// - /// 2. It's cheap. Even a very large page might have 1000 rows and - /// that's only ~128 bytes of 64-bit integers to track all the dirty - /// bits. Compared to the hundreds of kilobytes a typical page - /// consumes, this is nothing. - /// - /// 3. It's more flexible. If we ever want to implement new terminal - /// features that allow non-active area to be dirty, we can do that - /// with minimal dirty-tracking work. - /// - dirty: Offset(usize), - /// The current dimensions of the page. The capacity may be larger /// than this. This allows us to allocate a larger page than necessary /// and also to resize a page smaller without reallocating. @@ -238,7 +208,6 @@ pub const Page = struct { .memory = @alignCast(buf.start()[0..l.total_size]), .rows = rows, .cells = cells, - .dirty = buf.member(usize, l.dirty_start), .styles = StyleSet.init( buf.add(l.styles_start), l.styles_layout, @@ -267,6 +236,7 @@ pub const Page = struct { ), .size = .{ .cols = cap.cols, .rows = cap.rows }, .capacity = cap, + .dirty = false, }; } @@ -686,11 +656,8 @@ pub const Page = struct { const other_rows = other.rows.ptr(other.memory)[y_start..y_end]; const rows = self.rows.ptr(self.memory)[0 .. y_end - y_start]; - const other_dirty_set = other.dirtyBitSet(); - var dirty_set = self.dirtyBitSet(); - for (rows, 0.., other_rows, y_start..) |*dst_row, dst_y, *src_row, src_y| { + for (rows, other_rows) |*dst_row, *src_row| { try self.cloneRowFrom(other, dst_row, src_row); - if (other_dirty_set.isSet(src_y)) dirty_set.set(dst_y); } // We should remain consistent @@ -752,6 +719,7 @@ pub const Page = struct { copy.grapheme = dst_row.grapheme; copy.hyperlink = dst_row.hyperlink; copy.styled = dst_row.styled; + copy.dirty |= dst_row.dirty; } // Our cell offset remains the same @@ -791,12 +759,6 @@ pub const Page = struct { } if (src_cell.hasGrapheme()) { - // To prevent integrity checks flipping. This will - // get fixed up when we check the style id below. - if (build_options.slow_runtime_safety) { - dst_cell.style_id = stylepkg.default_id; - } - // Copy the grapheme codepoints const cps = other.lookupGrapheme(src_cell).?; @@ -1090,26 +1052,54 @@ pub const Page = struct { const cells = row.cells.ptr(self.memory)[left..end]; + // If we have managed memory (styles, graphemes, or hyperlinks) + // in this row then we go cell by cell and clear them if present. if (row.grapheme) { for (cells) |*cell| { - if (cell.hasGrapheme()) self.clearGrapheme(row, cell); + if (cell.hasGrapheme()) + @call(.always_inline, clearGrapheme, .{ self, cell }); + } + + // If we have no left/right scroll region we can be sure + // that we've cleared all the graphemes, so we clear the + // flag, otherwise we use the update function to update. + if (cells.len == self.size.cols) { + row.grapheme = false; + } else { + self.updateRowGraphemeFlag(row); } } if (row.hyperlink) { for (cells) |*cell| { - if (cell.hyperlink) self.clearHyperlink(row, cell); + if (cell.hyperlink) + @call(.always_inline, clearHyperlink, .{ self, cell }); + } + + // If we have no left/right scroll region we can be sure + // that we've cleared all the hyperlinks, so we clear the + // flag, otherwise we use the update function to update. + if (cells.len == self.size.cols) { + row.hyperlink = false; + } else { + self.updateRowHyperlinkFlag(row); } } if (row.styled) { for (cells) |*cell| { - if (cell.style_id == stylepkg.default_id) continue; - - self.styles.release(self.memory, cell.style_id); + if (cell.hasStyling()) + self.styles.release(self.memory, cell.style_id); } - if (cells.len == self.size.cols) row.styled = false; + // If we have no left/right scroll region we can be sure + // that we've cleared all the styles, so we clear the + // flag, otherwise we use the update function to update. + if (cells.len == self.size.cols) { + row.styled = false; + } else { + self.updateRowStyledFlag(row); + } } if (comptime build_options.kitty_graphics) { @@ -1137,7 +1127,11 @@ pub const Page = struct { } /// Clear the hyperlink from the given cell. - pub inline fn clearHyperlink(self: *Page, row: *Row, cell: *Cell) void { + /// + /// In order to update the hyperlink flag on the row, call + /// `updateRowHyperlinkFlag` after you finish clearing any + /// hyperlinks in the row. + pub fn clearHyperlink(self: *Page, cell: *Cell) void { defer self.assertIntegrity(); // Get our ID @@ -1149,9 +1143,13 @@ pub const Page = struct { self.hyperlink_set.release(self.memory, entry.value_ptr.*); map.removeByPtr(entry.key_ptr); cell.hyperlink = false; + } - // Mark that we no longer have hyperlinks, also search the row - // to make sure its state is correct. + /// Checks if the row contains any hyperlinks and sets + /// the hyperlink flag to false if none are found. + /// + /// Call after removing hyperlinks in a row. + pub inline fn updateRowHyperlinkFlag(self: *Page, row: *Row) void { const cells = row.cells.ptr(self.memory)[0..self.size.cols]; for (cells) |c| if (c.hyperlink) return; row.hyperlink = false; @@ -1198,7 +1196,7 @@ pub const Page = struct { }; errdefer self.string_alloc.free( self.memory, - page_uri.offset.ptr(self.memory)[0..page_uri.len], + page_uri.slice(self.memory), ); // Allocate an ID for our page memory if we have to. @@ -1228,7 +1226,7 @@ pub const Page = struct { .implicit => {}, .explicit => |slice| self.string_alloc.free( self.memory, - slice.offset.ptr(self.memory)[0..slice.len], + slice.slice(self.memory), ), }; @@ -1421,7 +1419,7 @@ pub const Page = struct { // most graphemes to fit within our chunk size. const cps = try self.grapheme_alloc.alloc(u21, self.memory, slice.len + 1); errdefer self.grapheme_alloc.free(self.memory, cps); - const old_cps = slice.offset.ptr(self.memory)[0..slice.len]; + const old_cps = slice.slice(self.memory); fastmem.copy(u21, cps[0..old_cps.len], old_cps); cps[slice.len] = cp; slice.* = .{ @@ -1440,7 +1438,7 @@ pub const Page = struct { const cell_offset = getOffset(Cell, self.memory, cell); const map = self.grapheme_map.map(self.memory); const slice = map.get(cell_offset) orelse return null; - return slice.offset.ptr(self.memory)[0..slice.len]; + return slice.slice(self.memory); } /// Move the graphemes from one cell to another. This can't fail @@ -1465,7 +1463,11 @@ pub const Page = struct { } /// Clear the graphemes for a given cell. - pub inline fn clearGrapheme(self: *Page, row: *Row, cell: *Cell) void { + /// + /// In order to update the grapheme flag on the row, call + /// `updateRowGraphemeFlag` after you finish clearing any + /// graphemes in the row. + pub fn clearGrapheme(self: *Page, cell: *Cell) void { defer self.assertIntegrity(); if (build_options.slow_runtime_safety) assert(cell.hasGrapheme()); @@ -1475,15 +1477,21 @@ pub const Page = struct { const entry = map.getEntry(cell_offset).?; // Free our grapheme data - const cps = entry.value_ptr.offset.ptr(self.memory)[0..entry.value_ptr.len]; + const cps = entry.value_ptr.slice(self.memory); self.grapheme_alloc.free(self.memory, cps); // Remove the entry map.removeByPtr(entry.key_ptr); - // Mark that we no longer have graphemes, also search the row - // to make sure its state is correct. + // Mark that we no longer have graphemes by changing the content tag. cell.content_tag = .codepoint; + } + + /// Checks if the row contains any graphemes and sets + /// the grapheme flag to false if none are found. + /// + /// Call after removing graphemes in a row. + pub inline fn updateRowGraphemeFlag(self: *Page, row: *Row) void { const cells = row.cells.ptr(self.memory)[0..self.size.cols]; for (cells) |c| if (c.hasGrapheme()) return; row.grapheme = false; @@ -1501,30 +1509,23 @@ pub const Page = struct { return self.grapheme_map.map(self.memory).capacity(); } - /// Returns the bitset for the dirty bits on this page. + /// Checks if the row contains any styles and sets + /// the styled flag to false if none are found. /// - /// The returned value is a DynamicBitSetUnmanaged but it is NOT - /// actually dynamic; do NOT call resize on this. It is safe to - /// read and write but do not resize it. - pub inline fn dirtyBitSet(self: *const Page) std.DynamicBitSetUnmanaged { - return .{ - .bit_length = self.capacity.rows, - .masks = self.dirty.ptr(self.memory), - }; + /// Call after removing styles in a row. + pub inline fn updateRowStyledFlag(self: *Page, row: *Row) void { + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells) |c| if (c.hasStyling()) return; + row.styled = false; } - /// Returns true if the given row is dirty. This is NOT very - /// efficient if you're checking many rows and you should use - /// dirtyBitSet directly instead. - pub inline fn isRowDirty(self: *const Page, y: usize) bool { - return self.dirtyBitSet().isSet(y); - } - - /// Returns true if this page is dirty at all. If you plan on - /// checking any additional rows, you should use dirtyBitSet and - /// check this on your own so you have the set available. + /// Returns true if this page is dirty at all. pub inline fn isDirty(self: *const Page) bool { - return self.dirtyBitSet().findFirstSet() != null; + if (self.dirty) return true; + for (self.rows.ptr(self.memory)[0..self.size.rows]) |row| { + if (row.dirty) return true; + } + return false; } pub const Layout = struct { @@ -1533,8 +1534,6 @@ pub const Page = struct { rows_size: usize, cells_start: usize, cells_size: usize, - dirty_start: usize, - dirty_size: usize, styles_start: usize, styles_layout: StyleSet.Layout, grapheme_alloc_start: usize, @@ -1561,19 +1560,8 @@ pub const Page = struct { const cells_start = alignForward(usize, rows_end, @alignOf(Cell)); const cells_end = cells_start + (cells_count * @sizeOf(Cell)); - // The division below cannot fail because our row count cannot - // exceed the maximum value of usize. - const dirty_bit_length: usize = rows_count; - const dirty_usize_length: usize = std.math.divCeil( - usize, - dirty_bit_length, - @bitSizeOf(usize), - ) catch unreachable; - const dirty_start = alignForward(usize, cells_end, @alignOf(usize)); - const dirty_end: usize = dirty_start + (dirty_usize_length * @sizeOf(usize)); - const styles_layout: StyleSet.Layout = .init(cap.styles); - const styles_start = alignForward(usize, dirty_end, StyleSet.base_align.toByteUnits()); + const styles_start = alignForward(usize, cells_end, StyleSet.base_align.toByteUnits()); const styles_end = styles_start + styles_layout.total_size; const grapheme_alloc_layout = GraphemeAlloc.layout(cap.grapheme_bytes); @@ -1614,8 +1602,6 @@ pub const Page = struct { .rows_size = rows_end - rows_start, .cells_start = cells_start, .cells_size = cells_end - cells_start, - .dirty_start = dirty_start, - .dirty_size = dirty_end - dirty_start, .styles_start = styles_start, .styles_layout = styles_layout, .grapheme_alloc_start = grapheme_alloc_start, @@ -1707,11 +1693,9 @@ pub const Capacity = struct { // The size per row is: // - The row metadata itself // - The cells per row (n=cols) - // - 1 bit for dirty tracking const bits_per_row: usize = size: { var bits: usize = @bitSizeOf(Row); // Row metadata bits += @bitSizeOf(Cell) * @as(usize, @intCast(cols)); // Cells (n=cols) - bits += 1; // The dirty bit break :size bits; }; const available_bits: usize = styles_start * 8; @@ -1775,7 +1759,20 @@ pub const Row = packed struct(u64) { // everything throughout the same. kitty_virtual_placeholder: bool = false, - _padding: u23 = 0, + /// True if this row is dirty and requires a redraw. This is set to true + /// by any operation that modifies the row's contents or position, and + /// consumers of the page are expected to clear it when they redraw. + /// + /// Dirty status is only ever meant to convey that one or more cells in + /// the row have changed visually. A cell which changes in a way that + /// doesn't affect the visual representation may not be marked as dirty. + /// + /// Dirty tracking may have false positives but should never have false + /// negatives. A false negative would result in a visual artifact on the + /// screen. + dirty: bool = false, + + _padding: u22 = 0, /// Semantic prompt type. pub const SemanticPrompt = enum(u3) { @@ -1802,8 +1799,9 @@ pub const Row = packed struct(u64) { /// Returns true if this row has any managed memory outside of the /// row structure (graphemes, styles, etc.) - fn managedMemory(self: Row) bool { - return self.grapheme or self.styled or self.hyperlink; + pub inline fn managedMemory(self: Row) bool { + // Ordered on purpose for likelihood. + return self.styled or self.hyperlink or self.grapheme; } }; @@ -1896,7 +1894,7 @@ pub const Cell = packed struct(u64) { return cell; } - pub fn isZero(self: Cell) bool { + pub inline fn isZero(self: Cell) bool { return @as(u64, @bitCast(self)) == 0; } @@ -1906,7 +1904,7 @@ pub const Cell = packed struct(u64) { /// - Cell text is blank /// - Cell is styled but only with a background color and no text /// - Cell has a unicode placeholder for Kitty graphics protocol - pub fn hasText(self: Cell) bool { + pub inline fn hasText(self: Cell) bool { return switch (self.content_tag) { .codepoint, .codepoint_grapheme, @@ -1918,7 +1916,7 @@ pub const Cell = packed struct(u64) { }; } - pub fn codepoint(self: Cell) u21 { + pub inline fn codepoint(self: Cell) u21 { return switch (self.content_tag) { .codepoint, .codepoint_grapheme, @@ -1931,14 +1929,14 @@ pub const Cell = packed struct(u64) { } /// The width in grid cells that this cell takes up. - pub fn gridWidth(self: Cell) u2 { + pub inline fn gridWidth(self: Cell) u2 { return switch (self.wide) { .narrow, .spacer_head, .spacer_tail => 1, .wide => 2, }; } - pub fn hasStyling(self: Cell) bool { + pub inline fn hasStyling(self: Cell) bool { return self.style_id != stylepkg.default_id; } @@ -1957,12 +1955,12 @@ pub const Cell = packed struct(u64) { }; } - pub fn hasGrapheme(self: Cell) bool { + pub inline fn hasGrapheme(self: Cell) bool { return self.content_tag == .codepoint_grapheme; } /// Returns true if the set of cells has text in it. - pub fn hasTextAny(cells: []const Cell) bool { + pub inline fn hasTextAny(cells: []const Cell) bool { for (cells) |cell| { if (cell.hasText()) return true; } @@ -2079,10 +2077,6 @@ test "Page init" { .styles = 32, }); defer page.deinit(); - - // Dirty set should be empty - const dirty = page.dirtyBitSet(); - try std.testing.expectEqual(@as(usize, 0), dirty.count()); } test "Page read and write cells" { @@ -2132,7 +2126,8 @@ test "Page appendGrapheme small" { try testing.expectEqualSlices(u21, &.{ 0x0A, 0x0B }, page.lookupGrapheme(rac.cell).?); // Clear it - page.clearGrapheme(rac.row, rac.cell); + page.clearGrapheme(rac.cell); + page.updateRowGraphemeFlag(rac.row); try testing.expect(!rac.row.grapheme); try testing.expect(!rac.cell.hasGrapheme()); } @@ -2177,7 +2172,8 @@ test "Page clearGrapheme not all cells" { try page.appendGrapheme(rac2.row, rac2.cell, 0x0A); // Clear it - page.clearGrapheme(rac.row, rac.cell); + page.clearGrapheme(rac.cell); + page.updateRowGraphemeFlag(rac.row); try testing.expect(rac.row.grapheme); try testing.expect(!rac.cell.hasGrapheme()); try testing.expect(rac2.cell.hasGrapheme()); @@ -2233,6 +2229,84 @@ test "Page clone" { } } +test "Page clone graphemes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Append some graphemes + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .init(0x09); + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + try page.appendGrapheme(rac.row, rac.cell, 0x0B); + } + + // Clone it + var page2 = try page.clone(); + defer page2.deinit(); + { + const rac = page2.getRowAndCell(0, 0); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x0A, 0x0B }, page2.lookupGrapheme(rac.cell).?); + } +} + +test "Page clone styles" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write with some styles + { + const id = try page.styles.add(page.memory, .{ .flags = .{ + .bold = true, + } }); + + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.row.styled = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + .style_id = id, + }; + page.styles.use(page.memory, id); + } + } + + // Clone it + var page2 = try page.clone(); + defer page2.deinit(); + { + const id: u16 = style: { + const rac = page2.getRowAndCell(0, 0); + break :style rac.cell.style_id; + }; + + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + try testing.expect(rac.row.styled); + try testing.expectEqual(id, rac.cell.style_id); + } + + const style = page.styles.get( + page.memory, + id, + ); + try testing.expect((Style{ .flags = .{ + .bold = true, + } }).eql(style.*)); + } +} + test "Page cloneFrom" { var page = try Page.init(.{ .cols = 10, @@ -2441,7 +2515,8 @@ test "Page cloneFrom graphemes" { // Write again for (0..page.capacity.rows) |y| { const rac = page.getRowAndCell(1, y); - page.clearGrapheme(rac.row, rac.cell); + page.clearGrapheme(rac.cell); + page.updateRowGraphemeFlag(rac.row); rac.cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = 0 }, diff --git a/src/terminal/parse_table.zig b/src/terminal/parse_table.zig index 2c8ccf8fc..01bd569cb 100644 --- a/src/terminal/parse_table.zig +++ b/src/terminal/parse_table.zig @@ -10,7 +10,6 @@ //! const std = @import("std"); -const builtin = @import("builtin"); const parser = @import("Parser.zig"); const State = parser.State; const Action = parser.TransitionAction; diff --git a/src/terminal/point.zig b/src/terminal/point.zig index e7e2a8840..5a3d4a6f8 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -1,6 +1,5 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; const size = @import("size.zig"); /// The possible reference locations for a point. When someone says "(42, 80)" diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index e07de4e97..e67682ff5 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -1,12 +1,10 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const size = @import("size.zig"); const Offset = size.Offset; const OffsetBuf = size.OffsetBuf; -const fastmem = @import("../fastmem.zig"); - /// A reference counted set. /// /// This set is created with some capacity in mind. You can determine @@ -256,6 +254,7 @@ pub fn RefCountedSet( // we may end up with a PSL of `len` which would exceed the bounds. // In such a case, we claim to be out of memory. if (self.psl_stats[self.psl_stats.len - 1] > 0) { + @branchHint(.cold); return AddError.OutOfMemory; } @@ -308,6 +307,7 @@ pub fn RefCountedSet( if (items[id].meta.ref == 0) { // See comment in `addContext` for details. if (self.psl_stats[self.psl_stats.len - 1] > 0) { + @branchHint(.cold); return AddError.OutOfMemory; } @@ -513,14 +513,11 @@ pub fn RefCountedSet( return null; } - // We don't bother checking dead items. - if (item.meta.ref == 0) { - continue; - } - // If the item is a part of the same probe sequence, - // we check if it matches the value we're looking for. + // we make sure it's not dead and then check to see + // if it matches the value we're looking for. if (item.meta.psl == i and + item.meta.ref > 0 and ctx.eql(value, item.value)) { return id; @@ -549,9 +546,12 @@ pub fn RefCountedSet( } /// Insert the given value into the hash table with the given ID. - /// asserts that the value is not already present in the table. + /// + /// If runtime safety is enabled, asserts that + /// the value is not already present in the table. fn insert(self: *Self, base: anytype, value: T, new_id: Id, ctx: Context) Id { - assert(self.lookupContext(base, value, ctx) == null); + if (comptime std.debug.runtime_safety) + assert(self.lookupContext(base, value, ctx) == null); const table = self.table.ptr(base); const items = self.items.ptr(base); @@ -589,6 +589,11 @@ pub fn RefCountedSet( // unless its ID is greater than the one we're // given (i.e. prefer smaller IDs). if (item.meta.ref == 0) { + // Dead items aren't super common relative + // to other places to insert/swap the held + // item in to the set. + @branchHint(.unlikely); + if (comptime @hasDecl(Context, "deleted")) { // Inform the context struct that we're // deleting the dead item's value for good. diff --git a/src/terminal/render.zig b/src/terminal/render.zig new file mode 100644 index 000000000..b6430ea34 --- /dev/null +++ b/src/terminal/render.zig @@ -0,0 +1,1377 @@ +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const fastmem = @import("../fastmem.zig"); +const color = @import("color.zig"); +const cursor = @import("cursor.zig"); +const highlight = @import("highlight.zig"); +const point = @import("point.zig"); +const size = @import("size.zig"); +const page = @import("page.zig"); +const PageList = @import("PageList.zig"); +const Selection = @import("Selection.zig"); +const Screen = @import("Screen.zig"); +const ScreenSet = @import("ScreenSet.zig"); +const Style = @import("style.zig").Style; +const Terminal = @import("Terminal.zig"); + +// Developer note: this is in src/terminal and not src/renderer because +// the goal is that this remains generic to multiple renderers. This can +// aid specifically with libghostty-vt with converting terminal state to +// a renderable form. + +/// Contains the state required to render the screen, including optimizing +/// for repeated render calls and only rendering dirty regions. +/// +/// Previously, our renderer would use `clone` to clone the screen within +/// the viewport to perform rendering. This worked well enough that we kept +/// it all the way up through the Ghostty 1.2.x series, but the clone time +/// was repeatedly a bottleneck blocking IO. +/// +/// Rather than a generic clone that tries to clone all screen state per call +/// (within a region), a stateful approach that optimizes for only what a +/// renderer needs to do makes more sense. +/// +/// To use this, initialize the render state to empty, then call `update` +/// on each frame to update the state to the latest terminal state. +/// +/// var state: RenderState = .empty; +/// defer state.deinit(alloc); +/// state.update(alloc, &terminal); +/// +/// Note: the render state retains as much memory as possible between updates +/// to prevent future allocations. If a very large frame is rendered once, +/// the render state will retain that much memory until deinit. To avoid +/// waste, it is recommended that the caller `deinit` and start with an +/// empty render state every so often. +pub const RenderState = struct { + /// The current screen dimensions. It is possible that these don't match + /// the renderer's current dimensions in grid cells because resizing + /// can happen asynchronously. For example, for Metal, our NSView resizes + /// at a different time than when our internal terminal state resizes. + /// This can lead to a one or two frame mismatch a renderer needs to + /// handle. + /// + /// The viewport is always exactly equal to the active area size so this + /// is also the viewport size. + rows: size.CellCountInt, + cols: size.CellCountInt, + + /// The color state for the terminal. + colors: Colors, + + /// Cursor state within the viewport. + cursor: Cursor, + + /// The rows (y=0 is top) of the viewport. Guaranteed to be `rows` length. + /// + /// This is a MultiArrayList because only the update cares about + /// the allocators. Callers care about all the other properties, and + /// this better optimizes cache locality for read access for those + /// use cases. + row_data: std.MultiArrayList(Row), + + /// The dirty state of the render state. This is set by the update method. + /// The renderer/caller should set this to false when it has handled + /// the dirty state. + dirty: Dirty, + + /// The screen type that this state represents. This is used primarily + /// to detect changes. + screen: ScreenSet.Key, + + /// The last viewport pin used to generate this state. This is NOT + /// a tracked pin and is generally NOT safe to read other than the direct + /// values for comparison. + viewport_pin: ?PageList.Pin = null, + + /// The cached selection so we can avoid expensive selection calculations + /// if possible. + selection_cache: ?SelectionCache = null, + + /// Initial state. + pub const empty: RenderState = .{ + .rows = 0, + .cols = 0, + .colors = .{ + .background = .{}, + .foreground = .{}, + .cursor = null, + .palette = color.default, + }, + .cursor = .{ + .active = .{ .x = 0, .y = 0 }, + .viewport = null, + .cell = .{}, + .style = undefined, + .visual_style = .block, + .password_input = false, + .visible = true, + .blinking = false, + }, + .row_data = .empty, + .dirty = .false, + .screen = .primary, + }; + + /// The color state for the terminal. + /// + /// The background/foreground will be reversed if the terminal reverse + /// color mode is on! You do not need to handle that manually! + pub const Colors = struct { + background: color.RGB, + foreground: color.RGB, + cursor: ?color.RGB, + palette: color.Palette, + }; + + pub const Cursor = struct { + /// The x/y position of the cursor within the active area. + active: point.Coordinate, + + /// The x/y position of the cursor within the viewport. This + /// may be null if the cursor is not visible within the viewport. + viewport: ?Viewport, + + /// The cell data for the cursor position. Managed memory is not + /// safe to access from this. + cell: page.Cell, + + /// The style, always valid even if the cell is default style. + style: Style, + + /// The visual style of the cursor itself, such as a block or + /// bar. + visual_style: cursor.Style, + + /// True if the cursor is detected to be at a password input field. + password_input: bool, + + /// Cursor visibility state determined by the terminal mode. + visible: bool, + + /// Cursor blink state determined by the terminal mode. + blinking: bool, + + pub const Viewport = struct { + /// The x/y position of the cursor within the viewport. + x: size.CellCountInt, + y: size.CellCountInt, + + /// Whether the cursor is part of a wide character and + /// on the tail of it. If so, some renderers may use this + /// to move the cursor back one. + wide_tail: bool, + }; + }; + + /// A row within the viewport. + pub const Row = struct { + /// Arena used for any heap allocations for cell contents + /// in this row. Importantly, this is NOT used for the MultiArrayList + /// itself. We do this on purpose so that we can easily clear rows, + /// but retain cached MultiArrayList capacities since grid sizes don't + /// change often. + arena: ArenaAllocator.State, + + /// The page pin. This is not safe to read unless you can guarantee + /// the terminal state hasn't changed since the last `update` call. + pin: PageList.Pin, + + /// Raw row data. + raw: page.Row, + + /// The cells in this row. Guaranteed to be `cols` length. + cells: std.MultiArrayList(Cell), + + /// A dirty flag that can be used by the renderer to track + /// its own draw state. `update` will mark this true whenever + /// this row is changed, too. + dirty: bool, + + /// The x range of the selection within this row. + selection: ?[2]size.CellCountInt, + + /// The highlights within this row. + highlights: std.ArrayList(Highlight), + }; + + pub const Highlight = struct { + /// A special tag that can be used by the caller to differentiate + /// different highlight types. The value is opaque to the RenderState. + tag: u8, + + /// The x ranges of highlights within this row. + range: [2]size.CellCountInt, + }; + + pub const Cell = struct { + /// Always set, this is the raw copied cell data from page.Cell. + /// The managed memory (hyperlinks, graphames, etc.) is NOT safe + /// to access from here. It is duplicated into the other fields if + /// it exists. + raw: page.Cell, + + /// Grapheme data for the cell. This is undefined unless the + /// raw cell's content_tag is `codepoint_grapheme`. + grapheme: []const u21, + + /// The style data for the cell. This is undefined unless + /// the style_id is non-default on raw. + style: Style, + }; + + // Dirty state + pub const Dirty = enum { + /// Not dirty at all. Can skip rendering if prior state was + /// already rendered. + false, + + /// Partially dirty. Some rows changed but not all. None of the + /// global state changed such as colors. + partial, + + /// Fully dirty. Global state changed or dimensions changed. All rows + /// should be redrawn. + full, + }; + + const SelectionCache = struct { + selection: Selection, + tl_pin: PageList.Pin, + br_pin: PageList.Pin, + }; + + pub fn deinit(self: *RenderState, alloc: Allocator) void { + for ( + self.row_data.items(.arena), + self.row_data.items(.cells), + ) |state, *cells| { + var arena: ArenaAllocator = state.promote(alloc); + arena.deinit(); + cells.deinit(alloc); + } + self.row_data.deinit(alloc); + } + + /// Update the render state to the latest terminal state. + /// + /// This will reset the terminal dirty state since it is consumed + /// by this render state update. + pub fn update( + self: *RenderState, + alloc: Allocator, + t: *Terminal, + ) Allocator.Error!void { + const s: *Screen = t.screens.active; + const viewport_pin = s.pages.getTopLeft(.viewport); + const redraw = redraw: { + // If our screen key changed, we need to do a full rebuild + // because our render state is viewport-specific. + if (t.screens.active_key != self.screen) break :redraw true; + + // If our terminal is dirty at all, we do a full rebuild. These + // dirty values are full-terminal dirty values. + { + const Int = @typeInfo(Terminal.Dirty).@"struct".backing_integer.?; + const v: Int = @bitCast(t.flags.dirty); + if (v > 0) break :redraw true; + } + + // If our screen is dirty at all, we do a full rebuild. This is + // a full screen dirty tracker. + { + const Int = @typeInfo(Screen.Dirty).@"struct".backing_integer.?; + const v: Int = @bitCast(t.screens.active.dirty); + if (v > 0) break :redraw true; + } + + // If our dimensions changed, we do a full rebuild. + if (self.rows != s.pages.rows or + self.cols != s.pages.cols) + { + break :redraw true; + } + + // If our viewport pin changed, we do a full rebuild. + if (self.viewport_pin) |old| { + if (!old.eql(viewport_pin)) break :redraw true; + } + + break :redraw false; + }; + + // Always set our cheap fields, its more expensive to compare + self.rows = s.pages.rows; + self.cols = s.pages.cols; + self.viewport_pin = viewport_pin; + self.cursor.active = .{ .x = s.cursor.x, .y = s.cursor.y }; + self.cursor.cell = s.cursor.page_cell.*; + self.cursor.style = s.cursor.style; + self.cursor.visual_style = s.cursor.cursor_style; + self.cursor.password_input = t.flags.password_input; + self.cursor.visible = t.modes.get(.cursor_visible); + self.cursor.blinking = t.modes.get(.cursor_blinking); + + // Always reset the cursor viewport position. In the future we can + // probably cache this by comparing the cursor pin and viewport pin + // but may not be worth it. + self.cursor.viewport = null; + + // Colors. + self.colors.cursor = t.colors.cursor.get(); + self.colors.palette = t.colors.palette.current; + bg_fg: { + // Background/foreground can be unset initially which would + // depend on "default" background/foreground. The expected use + // case of Terminal is that the caller set their own configured + // defaults on load so this doesn't happen. + const bg = t.colors.background.get() orelse break :bg_fg; + const fg = t.colors.foreground.get() orelse break :bg_fg; + if (t.modes.get(.reverse_colors)) { + self.colors.background = fg; + self.colors.foreground = bg; + } else { + self.colors.background = bg; + self.colors.foreground = fg; + } + } + + // Ensure our row length is exactly our height, freeing or allocating + // data as necessary. In most cases we'll have a perfectly matching + // size. + if (self.row_data.len != self.rows) { + @branchHint(.unlikely); + + if (self.row_data.len < self.rows) { + // Resize our rows to the desired length, marking any added + // values undefined. + const old_len = self.row_data.len; + try self.row_data.resize(alloc, self.rows); + + // Initialize all our values. Its faster to use slice() + set() + // because appendAssumeCapacity does this multiple times. + var row_data = self.row_data.slice(); + for (old_len..self.rows) |y| { + row_data.set(y, .{ + .arena = .{}, + .pin = undefined, + .raw = undefined, + .cells = .empty, + .dirty = true, + .selection = null, + .highlights = .empty, + }); + } + } else { + const row_data = self.row_data.slice(); + for ( + row_data.items(.arena)[self.rows..], + row_data.items(.cells)[self.rows..], + ) |state, *cell| { + var arena: ArenaAllocator = state.promote(alloc); + arena.deinit(); + cell.deinit(alloc); + } + self.row_data.shrinkRetainingCapacity(self.rows); + } + } + + // Break down our row data + const row_data = self.row_data.slice(); + const row_arenas = row_data.items(.arena); + const row_pins = row_data.items(.pin); + const row_rows = row_data.items(.raw); + const row_cells = row_data.items(.cells); + const row_sels = row_data.items(.selection); + const row_highlights = row_data.items(.highlights); + const row_dirties = row_data.items(.dirty); + + // Track the last page that we know was dirty. This lets us + // more quickly do the full-page dirty check. + var last_dirty_page: ?*page.Page = null; + + // Go through and setup our rows. + var row_it = s.pages.rowIterator( + .right_down, + .{ .viewport = .{} }, + null, + ); + var y: size.CellCountInt = 0; + var any_dirty: bool = false; + while (row_it.next()) |row_pin| : (y = y + 1) { + // Find our cursor if we haven't found it yet. We do this even + // if the row is not dirty because the cursor is unrelated. + if (self.cursor.viewport == null and + row_pin.node == s.cursor.page_pin.node and + row_pin.y == s.cursor.page_pin.y) + { + self.cursor.viewport = .{ + .y = y, + .x = s.cursor.x, + + // Future: we should use our own state here to look this + // up rather than calling this. + .wide_tail = if (s.cursor.x > 0) + s.cursorCellLeft(1).wide == .wide + else + false, + }; + } + + // Store our pin. We have to store these even if we're not dirty + // because dirty is only a renderer optimization. It doesn't + // apply to memory movement. This will let us remap any cell + // pins back to an exact entry in our RenderState. + row_pins[y] = row_pin; + + // Get all our cells in the page. + const p: *page.Page = &row_pin.node.data; + const page_rac = row_pin.rowAndCell(); + + dirty: { + // If we're redrawing then we're definitely dirty. + if (redraw) break :dirty; + + // If our page is the same as last time then its dirty. + if (p == last_dirty_page) break :dirty; + if (p.dirty) { + // If this page is dirty then clear the dirty flag + // of the last page and then store this one. This benchmarks + // faster than iterating pages again later. + if (last_dirty_page) |last_p| last_p.dirty = false; + last_dirty_page = p; + break :dirty; + } + + // If our row is dirty then we're dirty. + if (page_rac.row.dirty) break :dirty; + + // Not dirty! + continue; + } + + // Set that at least one row was dirty. + any_dirty = true; + + // Clear our row dirty, we'll clear our page dirty later. + // We can't clear it now because we have more rows to go through. + page_rac.row.dirty = false; + + // Promote our arena. State is copied by value so we need to + // restore it on all exit paths so we don't leak memory. + var arena = row_arenas[y].promote(alloc); + defer row_arenas[y] = arena.state; + + // Reset our cells if we're rebuilding this row. + if (row_cells[y].len > 0) { + _ = arena.reset(.retain_capacity); + row_cells[y].clearRetainingCapacity(); + row_sels[y] = null; + row_highlights[y] = .empty; + } + row_dirties[y] = true; + + // Get all our cells in the page. + const page_cells: []const page.Cell = p.getCells(page_rac.row); + assert(page_cells.len == self.cols); + + // Copy our raw row data + row_rows[y] = page_rac.row.*; + + // Note: our cells MultiArrayList uses our general allocator. + // We do this on purpose because as rows become dirty, we do + // not want to reallocate space for cells (which are large). This + // was a source of huge slowdown. + // + // Our per-row arena is only used for temporary allocations + // pertaining to cells directly (e.g. graphemes, hyperlinks). + const cells: *std.MultiArrayList(Cell) = &row_cells[y]; + try cells.resize(alloc, self.cols); + + // We always copy our raw cell data. In the case we have no + // managed memory, we can skip setting any other fields. + // + // This is an important optimization. For plain-text screens + // this ends up being something around 300% faster based on + // the `screen-clone` benchmark. + const cells_slice = cells.slice(); + fastmem.copy( + page.Cell, + cells_slice.items(.raw), + page_cells, + ); + if (!page_rac.row.managedMemory()) continue; + + const arena_alloc = arena.allocator(); + const cells_grapheme = cells_slice.items(.grapheme); + const cells_style = cells_slice.items(.style); + for (page_cells, 0..) |*page_cell, x| { + // Append assuming its a single-codepoint, styled cell + // (most common by far). + if (page_cell.style_id > 0) cells_style[x] = p.styles.get( + p.memory, + page_cell.style_id, + ).*; + + // Switch on our content tag to handle less likely cases. + switch (page_cell.content_tag) { + .codepoint => { + @branchHint(.likely); + // Primary codepoint goes into `raw` field. + }, + + // If we have a multi-codepoint grapheme, look it up and + // set our content type. + .codepoint_grapheme => { + @branchHint(.unlikely); + cells_grapheme[x] = try arena_alloc.dupe( + u21, + p.lookupGrapheme(page_cell) orelse &.{}, + ); + }, + + .bg_color_rgb => { + @branchHint(.unlikely); + cells_style[x] = .{ .bg_color = .{ .rgb = .{ + .r = page_cell.content.color_rgb.r, + .g = page_cell.content.color_rgb.g, + .b = page_cell.content.color_rgb.b, + } } }; + }, + + .bg_color_palette => { + @branchHint(.unlikely); + cells_style[x] = .{ .bg_color = .{ + .palette = page_cell.content.color_palette, + } }; + }, + } + } + } + assert(y == self.rows); + + // If our screen has a selection, then mark the rows with the + // selection. We do this outside of the loop above because its unlikely + // a selection exists and because the way our selections are structured + // today is very inefficient. + // + // NOTE: To improve the performance of the block below, we'll need + // to rethink how we model selections in general. + // + // There are performance improvements that can be made here, though. + // For example, `containedRow` recalculates a bunch of information + // we can cache. + if (s.selection) |*sel| selection: { + @branchHint(.unlikely); + + // Populate our selection cache to avoid some expensive + // recalculation. + const cache: *const SelectionCache = cache: { + if (self.selection_cache) |*c| cache_check: { + // If we're redrawing, we recalculate the cache just to + // be safe. + if (redraw) break :cache_check; + + // If our selection isn't equal, we aren't cached! + if (!c.selection.eql(sel.*)) break :cache_check; + + // If we have no dirty rows, we can not recalculate. + if (!any_dirty) break :selection; + + // We have dirty rows, we can utilize the cache. + break :cache c; + } + + // Create a new cache + const tl_pin = sel.topLeft(s); + const br_pin = sel.bottomRight(s); + self.selection_cache = .{ + .selection = .init(tl_pin, br_pin, sel.rectangle), + .tl_pin = tl_pin, + .br_pin = br_pin, + }; + break :cache &self.selection_cache.?; + }; + + // Grab the inefficient data we need from the selection. At + // least we can cache it. + const tl = s.pages.pointFromPin(.screen, cache.tl_pin).?.screen; + const br = s.pages.pointFromPin(.screen, cache.br_pin).?.screen; + + // We need to determine if our selection is within the viewport. + // The viewport is generally very small so the efficient way to + // do this is to traverse the viewport pages and check for the + // matching selection pages. + for ( + row_pins, + row_sels, + ) |pin, *sel_bounds| { + const p = s.pages.pointFromPin(.screen, pin).?.screen; + const row_sel = sel.containedRowCached( + s, + cache.tl_pin, + cache.br_pin, + pin, + tl, + br, + p, + ) orelse continue; + const start = row_sel.start(); + const end = row_sel.end(); + assert(start.node == end.node); + assert(start.x <= end.x); + assert(start.y == end.y); + sel_bounds.* = .{ start.x, end.x }; + } + } + + // Handle dirty state. + if (redraw) { + // Fully redraw resets some other state. + self.screen = t.screens.active_key; + self.dirty = .full; + + // Note: we don't clear any row_data here because our rebuild + // above did this. + } else if (any_dirty and self.dirty == .false) { + self.dirty = .partial; + } + + // Finalize our final dirty page + if (last_dirty_page) |last_p| last_p.dirty = false; + + // Clear our dirty flags + t.flags.dirty = .{}; + s.dirty = .{}; + } + + /// Update the highlights in the render state from the given flattened + /// highlights. Because this uses flattened highlights, it does not require + /// reading from the terminal state so it should be done outside of + /// any critical sections. + /// + /// This will not clear any previous highlights, so the caller must + /// manually clear them if desired. + pub fn updateHighlightsFlattened( + self: *RenderState, + alloc: Allocator, + tag: u8, + hls: []const highlight.Flattened, + ) Allocator.Error!void { + // Fast path, we have no highlights! + if (hls.len == 0) return; + + // This is, admittedly, horrendous. This is some low hanging fruit + // to optimize. In my defense, screens are usually small, the number + // of highlights is usually small, and this only happens on the + // viewport outside of a locked area. Still, I'd love to see this + // improved someday. + + // We need to track whether any row had a match so we can mark + // the dirty state. + var any_dirty: bool = false; + + const row_data = self.row_data.slice(); + const row_arenas = row_data.items(.arena); + const row_dirties = row_data.items(.dirty); + const row_pins = row_data.items(.pin); + const row_highlights_slice = row_data.items(.highlights); + for ( + row_arenas, + row_pins, + row_highlights_slice, + row_dirties, + ) |*row_arena, row_pin, *row_highlights, *dirty| { + for (hls) |hl| { + const chunks_slice = hl.chunks.slice(); + const nodes = chunks_slice.items(.node); + const starts = chunks_slice.items(.start); + const ends = chunks_slice.items(.end); + for (0.., nodes) |i, node| { + // If this node doesn't match or we're not within + // the row range, skip it. + if (node != row_pin.node or + row_pin.y < starts[i] or + row_pin.y >= ends[i]) continue; + + // We're a match! + var arena = row_arena.promote(alloc); + defer row_arena.* = arena.state; + const arena_alloc = arena.allocator(); + try row_highlights.append( + arena_alloc, + .{ + .tag = tag, + .range = .{ + if (i == 0 and + row_pin.y == starts[0]) + hl.top_x + else + 0, + if (i == nodes.len - 1 and + row_pin.y == ends[nodes.len - 1] - 1) + hl.bot_x + else + self.cols - 1, + }, + }, + ); + + dirty.* = true; + any_dirty = true; + } + } + } + + // Mark our dirty state. + if (any_dirty and self.dirty == .false) self.dirty = .partial; + } + + pub const StringMap = std.ArrayListUnmanaged(point.Coordinate); + + /// Convert the current render state contents to a UTF-8 encoded + /// string written to the given writer. This will unwrap all the wrapped + /// rows. This is useful for a minimal viewport search. + /// + /// This currently writes empty cell contents as \x00 and writes all + /// blank lines. This is fine for our current usage (link search) but + /// we can adjust this later. + /// + /// NOTE: There is a limitation in that wrapped lines before/after + /// the the top/bottom line of the viewport are not included, since + /// the render state cuts them off. + pub fn string( + self: *const RenderState, + writer: *std.Io.Writer, + map: ?struct { + alloc: Allocator, + map: *StringMap, + }, + ) (Allocator.Error || std.Io.Writer.Error)!void { + const row_slice = self.row_data.slice(); + const row_rows = row_slice.items(.raw); + const row_cells = row_slice.items(.cells); + + for ( + 0.., + row_rows, + row_cells, + ) |y, row, cells| { + const cells_slice = cells.slice(); + for ( + 0.., + cells_slice.items(.raw), + cells_slice.items(.grapheme), + ) |x, cell, graphemes| { + var len: usize = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch + return error.WriteFailed; + try writer.print("{u}", .{cell.codepoint()}); + if (cell.hasGrapheme()) { + for (graphemes) |cp| { + len += std.unicode.utf8CodepointSequenceLength(cp) catch + return error.WriteFailed; + try writer.print("{u}", .{cp}); + } + } + + if (map) |m| try m.map.appendNTimes(m.alloc, .{ + .x = @intCast(x), + .y = @intCast(y), + }, len); + } + + if (!row.wrap) { + try writer.writeAll("\n"); + if (map) |m| try m.map.append(m.alloc, .{ + .x = @intCast(cells_slice.len), + .y = @intCast(y), + }); + } + } + } + + /// A set of coordinates representing cells. + pub const CellSet = std.AutoArrayHashMapUnmanaged(point.Coordinate, void); + + /// Returns a map of the cells that match to an OSC8 hyperlink over the + /// given point in the render state. + /// + /// IMPORTANT: The terminal must not have updated since the last call to + /// `update`. If there is any chance the terminal has updated, the caller + /// must first call `update` again to refresh the render state. + /// + /// For example, you may want to hold a lock for the duration of the + /// update and hyperlink lookup to ensure no updates happen in between. + pub fn linkCells( + self: *const RenderState, + alloc: Allocator, + viewport_point: point.Coordinate, + ) Allocator.Error!CellSet { + var result: CellSet = .empty; + errdefer result.deinit(alloc); + + const row_slice = self.row_data.slice(); + const row_pins = row_slice.items(.pin); + const row_cells = row_slice.items(.cells); + + // Grab our link ID + const link_page: *page.Page = &row_pins[viewport_point.y].node.data; + const link = link: { + const rac = link_page.getRowAndCell( + viewport_point.x, + viewport_point.y, + ); + + // The likely scenario is that our mouse isn't even over a link. + if (!rac.cell.hyperlink) { + @branchHint(.likely); + return result; + } + + const link_id = link_page.lookupHyperlink(rac.cell) orelse + return result; + break :link link_page.hyperlink_set.get( + link_page.memory, + link_id, + ); + }; + + for ( + 0.., + row_pins, + row_cells, + ) |y, pin, cells| { + for (0.., cells.items(.raw)) |x, cell| { + if (!cell.hyperlink) continue; + + const other_page: *page.Page = &pin.node.data; + const other = link: { + const rac = other_page.getRowAndCell(x, y); + const link_id = other_page.lookupHyperlink(rac.cell) orelse continue; + break :link other_page.hyperlink_set.get( + other_page.memory, + link_id, + ); + }; + + if (link.eql( + link_page.memory, + other, + other_page.memory, + )) try result.put(alloc, .{ + .y = @intCast(y), + .x = @intCast(x), + }, {}); + } + } + + return result; + } +}; + +test "styled" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + // This fills the screen up + try t.decaln(); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); +} + +test "basic text" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABCD"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Verify we have the right number of rows + const row_data = state.row_data.slice(); + try testing.expectEqual(3, row_data.len); + + // All rows should have cols cells + const cells = row_data.items(.cells); + try testing.expectEqual(10, cells[0].len); + try testing.expectEqual(10, cells[1].len); + try testing.expectEqual(10, cells[2].len); + + // Row zero should contain our text + try testing.expectEqual('A', cells[0].get(0).raw.codepoint()); + try testing.expectEqual('B', cells[0].get(1).raw.codepoint()); + try testing.expectEqual('C', cells[0].get(2).raw.codepoint()); + try testing.expectEqual('D', cells[0].get(3).raw.codepoint()); + try testing.expectEqual(0, cells[0].get(4).raw.codepoint()); +} + +test "styled text" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("\x1b[1mA"); // Bold + try s.nextSlice("\x1b[0;3mB"); // Italic + try s.nextSlice("\x1b[0;4mC"); // Underline + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Verify we have the right number of rows + const row_data = state.row_data.slice(); + try testing.expectEqual(3, row_data.len); + + // All rows should have cols cells + const cells = row_data.items(.cells); + try testing.expectEqual(10, cells[0].len); + try testing.expectEqual(10, cells[1].len); + try testing.expectEqual(10, cells[2].len); + + // Row zero should contain our text + { + const cell = cells[0].get(0); + try testing.expectEqual('A', cell.raw.codepoint()); + try testing.expect(cell.style.flags.bold); + } + { + const cell = cells[0].get(1); + try testing.expectEqual('B', cell.raw.codepoint()); + try testing.expect(!cell.style.flags.bold); + try testing.expect(cell.style.flags.italic); + } + try testing.expectEqual('C', cells[0].get(2).raw.codepoint()); + try testing.expectEqual(0, cells[0].get(3).raw.codepoint()); +} + +test "grapheme" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A"); + try s.nextSlice("👨‍"); // this has a ZWJ + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Verify we have the right number of rows + const row_data = state.row_data.slice(); + try testing.expectEqual(3, row_data.len); + + // All rows should have cols cells + const cells = row_data.items(.cells); + try testing.expectEqual(10, cells[0].len); + try testing.expectEqual(10, cells[1].len); + try testing.expectEqual(10, cells[2].len); + + // Row zero should contain our text + { + const cell = cells[0].get(0); + try testing.expectEqual('A', cell.raw.codepoint()); + } + { + const cell = cells[0].get(1); + try testing.expectEqual(0x1F468, cell.raw.codepoint()); + try testing.expectEqual(.wide, cell.raw.wide); + try testing.expectEqualSlices(u21, &.{0x200D}, cell.grapheme); + } + { + const cell = cells[0].get(2); + try testing.expectEqual(0, cell.raw.codepoint()); + try testing.expectEqual(.spacer_tail, cell.raw.wide); + } +} + +test "cursor state in viewport" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A\x1b[H"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + + // Initial update + try state.update(alloc, &t); + try testing.expectEqual(0, state.cursor.active.x); + try testing.expectEqual(0, state.cursor.active.y); + try testing.expectEqual(0, state.cursor.viewport.?.x); + try testing.expectEqual(0, state.cursor.viewport.?.y); + try testing.expectEqual('A', state.cursor.cell.codepoint()); + try testing.expect(state.cursor.style.default()); + + // Set a style on the cursor + try s.nextSlice("\x1b[1m"); // Bold + try state.update(alloc, &t); + try testing.expect(!state.cursor.style.default()); + try testing.expect(state.cursor.style.flags.bold); + try s.nextSlice("\x1b[0m"); // Reset style + + // Move cursor to 2,1 + try s.nextSlice("\x1b[2;3H"); + try state.update(alloc, &t); + try testing.expectEqual(2, state.cursor.active.x); + try testing.expectEqual(1, state.cursor.active.y); + try testing.expectEqual(2, state.cursor.viewport.?.x); + try testing.expectEqual(1, state.cursor.viewport.?.y); +} + +test "cursor state out of viewport" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 2, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A\r\nB\r\nC\r\nD\r\n"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + + // Initial update + try state.update(alloc, &t); + try testing.expectEqual(0, state.cursor.active.x); + try testing.expectEqual(1, state.cursor.active.y); + try testing.expectEqual(0, state.cursor.viewport.?.x); + try testing.expectEqual(1, state.cursor.viewport.?.y); + + // Scroll the viewport + try t.scrollViewport(.top); + try state.update(alloc, &t); + + // Set a style on the cursor + try testing.expectEqual(0, state.cursor.active.x); + try testing.expectEqual(1, state.cursor.active.y); + try testing.expect(state.cursor.viewport == null); +} + +test "dirty state" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + var state: RenderState = .empty; + defer state.deinit(alloc); + + // First update should trigger redraw due to resize + try state.update(alloc, &t); + try testing.expectEqual(.full, state.dirty); + + // Reset dirty flag and dirty rows + state.dirty = .false; + { + const row_data = state.row_data.slice(); + const dirty = row_data.items(.dirty); + @memset(dirty, false); + } + + // Second update with no changes - no dirty rows + try state.update(alloc, &t); + try testing.expectEqual(.false, state.dirty); + { + const row_data = state.row_data.slice(); + const dirty = row_data.items(.dirty); + for (dirty) |d| try testing.expect(!d); + } + + // Write to first line + try s.nextSlice("A"); + try state.update(alloc, &t); + try testing.expectEqual(.partial, state.dirty); + { + const row_data = state.row_data.slice(); + const dirty = row_data.items(.dirty); + try testing.expect(dirty[0]); // First row dirty + try testing.expect(!dirty[1]); // Second row clean + } +} + +test "colors" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + var state: RenderState = .empty; + defer state.deinit(alloc); + + // Default colors + try state.update(alloc, &t); + + // Change cursor color + try s.nextSlice("\x1b]12;#FF0000\x07"); + try state.update(alloc, &t); + + const c = state.colors.cursor.?; + try testing.expectEqual(0xFF, c.r); + try testing.expectEqual(0, c.g); + try testing.expectEqual(0, c.b); + + // Change palette color 0 to White + try s.nextSlice("\x1b]4;0;#FFFFFF\x07"); + try state.update(alloc, &t); + const p0 = state.colors.palette[0]; + try testing.expectEqual(0xFF, p0.r); + try testing.expectEqual(0xFF, p0.g); + try testing.expectEqual(0xFF, p0.b); +} + +test "selection single line" { + const testing = std.testing; + const alloc = testing.allocator; + + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + const screen: *Screen = t.screens.active; + try screen.select(.init( + screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + screen.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?, + false, + )); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + const row_data = state.row_data.slice(); + const sels = row_data.items(.selection); + try testing.expectEqual(null, sels[0]); + try testing.expectEqualSlices(size.CellCountInt, &.{ 0, 2 }, &sels[1].?); + try testing.expectEqual(null, sels[2]); + + // Clear the selection + try screen.select(null); + try state.update(alloc, &t); + try testing.expectEqual(null, sels[0]); + try testing.expectEqual(null, sels[1]); + try testing.expectEqual(null, sels[2]); +} + +test "selection multiple lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + const screen: *Screen = t.screens.active; + try screen.select(.init( + screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + screen.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?, + false, + )); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + const row_data = state.row_data.slice(); + const sels = row_data.items(.selection); + try testing.expectEqual(null, sels[0]); + try testing.expectEqualSlices( + size.CellCountInt, + &.{ 0, screen.pages.cols - 1 }, + &sels[1].?, + ); + try testing.expectEqualSlices( + size.CellCountInt, + &.{ 0, 2 }, + &sels[2].?, + ); +} + +test "linkCells" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + var state: RenderState = .empty; + defer state.deinit(alloc); + + // Create a hyperlink + try s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); + try state.update(alloc, &t); + + // Query link at 0,0 + var cells = try state.linkCells(alloc, .{ .x = 0, .y = 0 }); + defer cells.deinit(alloc); + + try testing.expectEqual(4, cells.count()); + try testing.expect(cells.contains(.{ .x = 0, .y = 0 })); + try testing.expect(cells.contains(.{ .x = 1, .y = 0 })); + try testing.expect(cells.contains(.{ .x = 2, .y = 0 })); + try testing.expect(cells.contains(.{ .x = 3, .y = 0 })); + + // Query no link + var cells2 = try state.linkCells(alloc, .{ .x = 4, .y = 0 }); + defer cells2.deinit(alloc); + try testing.expectEqual(0, cells2.count()); +} + +test "string" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 5, + .rows = 2, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("AB"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + var w = std.Io.Writer.Allocating.init(alloc); + defer w.deinit(); + + try state.string(&w.writer, null); + + const result = try w.toOwnedSlice(); + defer alloc.free(result); + + const expected = "AB\x00\x00\x00\n\x00\x00\x00\x00\x00\n"; + try testing.expectEqualStrings(expected, result); +} + +test "dirty row resets highlights" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABC"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Reset dirty state + state.dirty = .false; + { + const row_data = state.row_data.slice(); + const dirty = row_data.items(.dirty); + @memset(dirty, false); + } + + // Manually add a highlight to row 0 + { + const row_data = state.row_data.slice(); + const row_arenas = row_data.items(.arena); + const row_highlights = row_data.items(.highlights); + var arena = row_arenas[0].promote(alloc); + defer row_arenas[0] = arena.state; + try row_highlights[0].append(arena.allocator(), .{ + .tag = 1, + .range = .{ 0, 2 }, + }); + } + + // Verify we have a highlight + { + const row_data = state.row_data.slice(); + const row_highlights = row_data.items(.highlights); + try testing.expectEqual(1, row_highlights[0].items.len); + } + + // Write to row 0 to make it dirty + try s.nextSlice("\x1b[H"); // Move to home + try s.nextSlice("X"); + try state.update(alloc, &t); + + // Verify the highlight was reset on the dirty row + { + const row_data = state.row_data.slice(); + const row_highlights = row_data.items(.highlights); + try testing.expectEqual(0, row_highlights[0].items.len); + } +} diff --git a/src/terminal/search.zig b/src/terminal/search.zig index 0f0c53c03..e69603c25 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -1,10 +1,18 @@ //! Search functionality for the terminal. +pub const options = @import("terminal_options"); + pub const Active = @import("search/active.zig").ActiveSearch; pub const PageList = @import("search/pagelist.zig").PageListSearch; pub const Screen = @import("search/screen.zig").ScreenSearch; pub const Viewport = @import("search/viewport.zig").ViewportSearch; -pub const Thread = @import("search/Thread.zig"); + +// The search thread is not available in libghostty due to the xev dep +// for now. +pub const Thread = switch (options.artifact) { + .ghostty => @import("search/Thread.zig"), + .lib => void, +}; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 776dfc84a..8f2d73f16 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -12,13 +12,15 @@ const std = @import("std"); const builtin = @import("builtin"); const testing = std.testing; const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const Mutex = std.Thread.Mutex; const xev = @import("../../global.zig").xev; const internal_os = @import("../../os/main.zig"); const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue; +const MessageData = @import("../../datastruct/main.zig").MessageData; const point = @import("../point.zig"); -const PageList = @import("../PageList.zig"); -const Screen = @import("../Screen.zig"); +const FlattenedHighlight = @import("../highlight.zig").Flattened; +const UntrackedHighlight = @import("../highlight.zig").Untracked; const ScreenSet = @import("../ScreenSet.zig"); const Selection = @import("../Selection.zig"); const Terminal = @import("../Terminal.zig"); @@ -161,7 +163,14 @@ fn threadMain_(self: *Thread) !void { // Run log.debug("starting search thread", .{}); - defer log.debug("starting search thread shutdown", .{}); + defer { + log.debug("starting search thread shutdown", .{}); + + // Send the quit message + if (self.opts.event_cb) |cb| { + cb(.quit, self.opts.event_userdata); + } + } // Unlike some of our other threads, we interleave search work // with our xev loop so that we can try to make forward search progress @@ -232,7 +241,32 @@ fn drainMailbox(self: *Thread) !void { while (self.mailbox.pop()) |message| { log.debug("mailbox message={}", .{message}); switch (message) { - .change_needle => |v| try self.changeNeedle(v), + .change_needle => |v| { + defer v.deinit(); + try self.changeNeedle(v.slice()); + }, + .select => |v| try self.select(v), + } + } +} + +fn select(self: *Thread, sel: ScreenSearch.Select) !void { + const s = if (self.search) |*s| s else return; + const screen_search = s.screens.getPtr(s.last_screen.key) orelse return; + + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + + // The selection will trigger a selection change notification + // if it did change. + if (try screen_search.select(sel)) scroll: { + if (screen_search.selected) |m| { + // Selection changed, let's scroll the viewport to see it + // since we have the lock anyways. + const screen = self.opts.terminal.screens.get( + s.last_screen.key, + ) orelse break :scroll; + screen.scroll(.{ .pin = m.highlight.start.* }); } } } @@ -243,8 +277,27 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { // Stop the previous search if (self.search) |*s| { + // If our search is unchanged, do nothing. + if (std.ascii.eqlIgnoreCase(s.viewport.needle(), needle)) return; + s.deinit(); self.search = null; + + // When the search changes then we need to emit that it stopped. + if (self.opts.event_cb) |cb| { + cb( + .{ .total_matches = 0 }, + self.opts.event_userdata, + ); + cb( + .{ .selected_match = null }, + self.opts.event_userdata, + ); + cb( + .{ .viewport_matches = &.{} }, + self.opts.event_userdata, + ); + } } // No needle means stop the search. @@ -370,24 +423,42 @@ pub const Mailbox = BlockingQueue(Message, 64); /// The messages that can be sent to the thread. pub const Message = union(enum) { + /// Represents a write request. Magic number comes from the max size + /// we want this union to be. + pub const WriteReq = MessageData(u8, 255); + /// Change the search term. If no prior search term is given this /// will start a search. If an existing search term is given this will /// stop the prior search and start a new one. - change_needle: []const u8, + change_needle: WriteReq, + + /// Select a search result. + select: ScreenSearch.Select, }; /// Events that can be emitted from the search thread. The caller /// chooses to handle these as they see fit. pub const Event = union(enum) { + /// Search is quitting. The search thread is exiting. + quit, + /// Search is complete for the given needle on all screens. complete, /// Total matches on the current active screen have changed. total_matches: usize, + /// Selected match changed. + selected_match: ?SelectedMatch, + /// Matches in the viewport have changed. The memory is owned by the /// search thread and is only valid during the callback. - viewport_matches: []const Selection, + viewport_matches: []const FlattenedHighlight, + + pub const SelectedMatch = struct { + idx: usize, + highlight: FlattenedHighlight, + }; }; /// Search state. @@ -398,11 +469,9 @@ const Search = struct { /// The searchers for all the screens. screens: std.EnumMap(ScreenSet.Key, ScreenSearch), - /// The last active screen - last_active_screen: ScreenSet.Key, - - /// The last total matches reported. - last_total: ?usize, + /// All state related to screen switches, collected so that when + /// we switch screens it makes everything related stale, too. + last_screen: ScreenState, /// True if we sent the complete notification yet. last_complete: bool, @@ -410,6 +479,22 @@ const Search = struct { /// The last viewport matches we found. stale_viewport_matches: bool, + const ScreenState = struct { + /// Last active screen key + key: ScreenSet.Key, + + /// Last notified total matches count + total: ?usize = null, + + /// Last notified selected match index + selected: ?SelectedMatch = null, + + const SelectedMatch = struct { + idx: usize, + highlight: UntrackedHighlight, + }; + }; + pub fn init( alloc: Allocator, needle: []const u8, @@ -417,11 +502,14 @@ const Search = struct { var vp: ViewportSearch = try .init(alloc, needle); errdefer vp.deinit(); + // We use dirty tracking for active area changes. Start with it + // dirty so the first change is re-searched. + vp.active_dirty = true; + return .{ .viewport = vp, .screens = .init(.{}), - .last_active_screen = .primary, - .last_total = null, + .last_screen = .{ .key = .primary }, .last_complete = false, .stale_viewport_matches = true, }; @@ -500,9 +588,10 @@ const Search = struct { t: *Terminal, ) void { // Update our active screen - if (t.screens.active_key != self.last_active_screen) { - self.last_active_screen = t.screens.active_key; - self.last_total = null; // force notification + if (t.screens.active_key != self.last_screen.key) { + // The default values will force resets of a bunch of other + // state too to force recalculations and notifications. + self.last_screen = .{ .key = t.screens.active_key }; } // Reconcile our screens with the terminal screens. Remove @@ -551,6 +640,27 @@ const Search = struct { } } + // See the `search_viewport_dirty` flag on the terminal to know + // what exactly this is for. But, if this is set, we know the renderer + // found the viewport/active area dirty, so we should mark it as + // dirty in our viewport searcher so it forces a re-search. + if (t.flags.search_viewport_dirty) { + t.flags.search_viewport_dirty = false; + + // Mark our viewport dirty so it researches the active + self.viewport.active_dirty = true; + + // Reload our active area for our active screen + if (self.screens.getPtr(t.screens.active_key)) |screen_search| { + screen_search.reloadActive() catch |err| switch (err) { + error.OutOfMemory => log.warn( + "error reloading active area for screen key={} err={}", + .{ t.screens.active_key, err }, + ), + }; + } + } + // Check our viewport for changes. if (self.viewport.update(&t.screens.active.pages)) |updated| { if (updated) self.stale_viewport_matches = true; @@ -584,12 +694,13 @@ const Search = struct { cb: EventCallback, ud: ?*anyopaque, ) void { - const screen_search = self.screens.get(self.last_active_screen) orelse return; + const screen_search = self.screens.get(self.last_screen.key) orelse return; // Check our total match data const total = screen_search.matchesLen(); - if (total != self.last_total) { - self.last_total = total; + if (total != self.last_screen.total) { + log.debug("notifying total matches={}", .{total}); + self.last_screen.total = total; cb(.{ .total_matches = total }, ud); } @@ -603,10 +714,13 @@ const Search = struct { // process will make it stale again. self.stale_viewport_matches = false; - var results: std.ArrayList(Selection) = .empty; - defer results.deinit(alloc); - while (self.viewport.next()) |sel| { - results.append(alloc, sel) catch |err| switch (err) { + var arena: ArenaAllocator = .init(alloc); + defer arena.deinit(); + const arena_alloc = arena.allocator(); + var results: std.ArrayList(FlattenedHighlight) = .empty; + while (self.viewport.next()) |hl| { + const hl_cloned = hl.clone(arena_alloc) catch continue; + results.append(arena_alloc, hl_cloned) catch |err| switch (err) { error.OutOfMemory => { log.warn( "error collecting viewport matches err={}", @@ -621,40 +735,89 @@ const Search = struct { }; } + log.debug("notifying viewport matches len={}", .{results.items.len}); cb(.{ .viewport_matches = results.items }, ud); } + // Check our last selected match data. + if (screen_search.selected) |m| match: { + const flattened = screen_search.selectedMatch() orelse break :match; + const untracked = flattened.untracked(); + if (self.last_screen.selected) |prev| { + if (prev.idx == m.idx and prev.highlight.eql(untracked)) { + // Same selection, don't update it. + break :match; + } + } + + // New selection, notify! + self.last_screen.selected = .{ + .idx = m.idx, + .highlight = untracked, + }; + + log.debug("notifying selection updated idx={}", .{m.idx}); + cb( + .{ .selected_match = .{ + .idx = m.idx, + .highlight = flattened, + } }, + ud, + ); + } else if (self.last_screen.selected != null) { + log.debug("notifying selection cleared", .{}); + self.last_screen.selected = null; + cb( + .{ .selected_match = null }, + ud, + ); + } + // Send our complete notification if we just completed. if (!self.last_complete and self.isComplete()) { + log.debug("notifying search complete", .{}); self.last_complete = true; cb(.complete, ud); } } }; -test { - const UserData = struct { - const Self = @This(); - reset: std.Thread.ResetEvent = .{}, - total: usize = 0, - viewport: []const Selection = &.{}, +const TestUserData = struct { + const Self = @This(); + reset: std.Thread.ResetEvent = .{}, + total: usize = 0, + selected: ?Event.SelectedMatch = null, + viewport: []FlattenedHighlight = &.{}, - fn callback(event: Event, userdata: ?*anyopaque) void { - const ud: *Self = @ptrCast(@alignCast(userdata.?)); - switch (event) { - .complete => ud.reset.set(), - .total_matches => |v| ud.total = v, - .viewport_matches => |v| { - testing.allocator.free(ud.viewport); - ud.viewport = testing.allocator.dupe( - Selection, - v, - ) catch unreachable; - }, - } + fn deinit(self: *Self) void { + for (self.viewport) |*hl| hl.deinit(testing.allocator); + testing.allocator.free(self.viewport); + } + + fn callback(event: Event, userdata: ?*anyopaque) void { + const ud: *Self = @ptrCast(@alignCast(userdata.?)); + switch (event) { + .quit => {}, + .complete => ud.reset.set(), + .total_matches => |v| ud.total = v, + .selected_match => |v| ud.selected = v, + .viewport_matches => |v| { + for (ud.viewport) |*hl| hl.deinit(testing.allocator); + testing.allocator.free(ud.viewport); + + ud.viewport = testing.allocator.alloc( + FlattenedHighlight, + v.len, + ) catch unreachable; + for (ud.viewport, v) |*dst, src| { + dst.* = src.clone(testing.allocator) catch unreachable; + } + }, } - }; + } +}; +test { const alloc = testing.allocator; var mutex: std.Thread.Mutex = .{}; var t: Terminal = try .init(alloc, .{ .cols = 20, .rows = 2 }); @@ -664,12 +827,12 @@ test { defer stream.deinit(); try stream.nextSlice("Hello, world"); - var ud: UserData = .{}; - defer alloc.free(ud.viewport); + var ud: TestUserData = .{}; + defer ud.deinit(); var thread: Thread = try .init(alloc, .{ .mutex = &mutex, .terminal = &t, - .event_cb = &UserData.callback, + .event_cb = &TestUserData.callback, .event_userdata = &ud, }); defer thread.deinit(); @@ -682,7 +845,10 @@ test { // Start our search _ = thread.mailbox.push( - .{ .change_needle = "world" }, + .{ .change_needle = try .init( + alloc, + @as([]const u8, "world"), + ) }, .forever, ); try thread.wakeup.notify(); @@ -698,14 +864,14 @@ test { try testing.expectEqual(1, ud.total); try testing.expectEqual(1, ud.viewport.len); { - const sel = ud.viewport[0]; + const sel = ud.viewport[0].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 7, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 11, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig index 2ace939e7..236f4c7a6 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -3,8 +3,8 @@ const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); const size = @import("../size.zig"); +const FlattenedHighlight = @import("../highlight.zig").Flattened; const PageList = @import("../PageList.zig"); -const Selection = @import("../Selection.zig"); const SlidingWindow = @import("sliding_window.zig").SlidingWindow; const Terminal = @import("../Terminal.zig"); @@ -96,7 +96,7 @@ pub const ActiveSearch = struct { /// Find the next match for the needle in the active area. This returns /// null when there are no more matches. - pub fn next(self: *ActiveSearch) ?Selection { + pub fn next(self: *ActiveSearch) ?FlattenedHighlight { return self.window.next(); } }; @@ -115,26 +115,28 @@ test "simple search" { _ = try search.update(&t.screens.active.pages); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } @@ -158,15 +160,16 @@ test "clear screen and search" { _ = try search.update(&t.screens.active.pages); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig index 8b6b57949..4bfd241e7 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -1,16 +1,14 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const assert = std.debug.assert; const testing = std.testing; -const CircBuf = @import("../../datastruct/main.zig").CircBuf; const terminal = @import("../main.zig"); const point = terminal.point; +const FlattenedHighlight = @import("../highlight.zig").Flattened; const Page = terminal.Page; const PageList = terminal.PageList; const Pin = PageList.Pin; const Selection = terminal.Selection; const Screen = terminal.Screen; -const PageFormatter = @import("../formatter.zig").PageFormatter; const Terminal = @import("../Terminal.zig"); const SlidingWindow = @import("sliding_window.zig").SlidingWindow; @@ -97,7 +95,7 @@ pub const PageListSearch = struct { /// /// This does NOT access the PageList, so it can be called without /// a lock held. - pub fn next(self: *PageListSearch) ?Selection { + pub fn next(self: *PageListSearch) ?FlattenedHighlight { return self.window.next(); } @@ -111,6 +109,11 @@ pub const PageListSearch = struct { /// This returns false if there is no more data to feed. This essentially /// means we've searched the entire pagelist. pub fn feed(self: *PageListSearch) Allocator.Error!bool { + // If our pin becomes garbage it means wherever we were next + // was reused and we can't make sense of our progress anymore. + // It is effectively equivalent to reaching the end of the PageList. + if (self.pin.garbage) return false; + // Add at least enough data to find a single match. var rem = self.window.needle.len; @@ -149,26 +152,28 @@ test "simple search" { defer search.deinit(); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); @@ -335,12 +340,13 @@ test "feed with match spanning page boundary" { try testing.expect(try search.feed()); // Should find the spanning match - const sel = search.next().?; - try testing.expect(sel.start().node != sel.end().node); + const h = search.next().?; + const sel = h.untracked(); + try testing.expect(sel.start.node != sel.end.node); { const str = try t.screens.active.selectionString( alloc, - .{ .sel = sel }, + .{ .sel = .init(sel.start, sel.end, false) }, ); defer alloc.free(str); try testing.expectEqualStrings(str, "Test"); @@ -388,3 +394,48 @@ test "feed with match spanning page boundary with newline" { try testing.expect(search.next() == null); try testing.expect(!try search.feed()); } + +test "feed with pruned page" { + const alloc = testing.allocator; + + // Zero here forces minimum max size to effectively two pages. + var p: PageList = try .init(alloc, 80, 24, 0); + defer p.deinit(); + + // Grow to capacity + const page1_node = p.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try p.grow() == null); + } + + // Grow and allocate one more page. Then fill that page up. + const page2_node = (try p.grow()).?; + const page2 = page2_node.data; + for (0..page2.capacity.rows - page2.size.rows) |_| { + try testing.expect(try p.grow() == null); + } + + // Setup search and feed until we can't + var search: PageListSearch = try .init( + alloc, + "Test", + &p, + p.pages.last.?, + ); + defer search.deinit(); + try testing.expect(try search.feed()); + try testing.expect(!try search.feed()); + + // Next should create a new page, but it should reuse our first + // page since we're at max size. + const new = (try p.grow()).?; + try testing.expect(p.pages.last.? == new); + + // Our first should now be page2 and our last should be page1 + try testing.expectEqual(page2_node, p.pages.first.?); + try testing.expectEqual(page1_node, p.pages.last.?); + + // Feed should still do nothing + try testing.expect(!try search.feed()); +} diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 07d700742..0ae7f8a1f 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -1,17 +1,22 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); +const highlight = @import("../highlight.zig"); +const size = @import("../size.zig"); +const FlattenedHighlight = highlight.Flattened; +const TrackedHighlight = highlight.Tracked; const PageList = @import("../PageList.zig"); const Pin = PageList.Pin; const Screen = @import("../Screen.zig"); -const Selection = @import("../Selection.zig"); const Terminal = @import("../Terminal.zig"); const ActiveSearch = @import("active.zig").ActiveSearch; const PageListSearch = @import("pagelist.zig").PageListSearch; const SlidingWindow = @import("sliding_window.zig").SlidingWindow; +const log = std.log.scoped(.search_screen); + /// Searches for a needle within a Screen, handling active area updates, /// pages being pruned from the screen (e.g. scrollback limits), and more. /// @@ -40,12 +45,34 @@ pub const ScreenSearch = struct { /// Current state of the search, a state machine. state: State, + /// The currently selected match, if any. As the screen contents + /// change or get pruned, the screen search will do its best to keep + /// this accurate. + selected: ?SelectedMatch = null, + /// The results found so far. These are stored separately because history /// is mostly immutable once found, while active area results may /// change. This lets us easily reset the active area results for a /// re-search scenario. - history_results: std.ArrayList(Selection), - active_results: std.ArrayList(Selection), + history_results: std.ArrayList(FlattenedHighlight), + active_results: std.ArrayList(FlattenedHighlight), + + /// The dimensions of the screen. When this changes we need to + /// restart the whole search, currently. + rows: size.CellCountInt, + cols: size.CellCountInt, + + pub const SelectedMatch = struct { + /// Index from the end of the match list (0 = most recent match) + idx: usize, + + /// Tracked highlight so we can detect movement. + highlight: TrackedHighlight, + + pub fn deinit(self: *SelectedMatch, screen: *Screen) void { + self.highlight.deinit(screen); + } + }; /// History search state. const HistorySearch = struct { @@ -89,6 +116,11 @@ pub const ScreenSearch = struct { pub fn needsFeed(self: State) bool { return switch (self) { .history_feed => true, + + // Not obvious but complete search states will prune + // stale history results on feed. + .complete => true, + else => false, }; } @@ -102,6 +134,8 @@ pub const ScreenSearch = struct { ) Allocator.Error!ScreenSearch { var result: ScreenSearch = .{ .screen = screen, + .rows = screen.pages.rows, + .cols = screen.pages.cols, .active = try .init(alloc, needle_unowned), .history = null, .state = .active, @@ -120,7 +154,10 @@ pub const ScreenSearch = struct { const alloc = self.allocator(); self.active.deinit(); if (self.history) |*h| h.deinit(self.screen); + if (self.selected) |*m| m.deinit(self.screen); + for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.deinit(alloc); + for (self.history_results.items) |*hl| hl.deinit(alloc); self.history_results.deinit(alloc); } @@ -145,11 +182,11 @@ pub const ScreenSearch = struct { pub fn matches( self: *ScreenSearch, alloc: Allocator, - ) Allocator.Error![]Selection { + ) Allocator.Error![]FlattenedHighlight { const active_results = self.active_results.items; const history_results = self.history_results.items; const results = try alloc.alloc( - Selection, + FlattenedHighlight, active_results.len + history_results.len, ); errdefer alloc.free(results); @@ -162,7 +199,7 @@ pub const ScreenSearch = struct { results[0..active_results.len], active_results, ); - std.mem.reverse(Selection, results[0..active_results.len]); + std.mem.reverse(FlattenedHighlight, results[0..active_results.len]); // History does a backward search, so we can just append them // after. @@ -213,7 +250,33 @@ pub const ScreenSearch = struct { /// Feed more data to the searcher so it can continue searching. This /// accesses the screen state, so the caller must hold the necessary locks. + /// + /// Feed on a complete screen search will perform some cleanup of + /// potentially stale history results (pruned) and reclaim some memory. pub fn feed(self: *ScreenSearch) Allocator.Error!void { + // If the screen resizes, we have to reset our entire search. That + // isn't ideal but we don't have a better way right now to handle + // reflowing the search results beyond putting a tracked pin for + // every single result. + if (self.screen.pages.rows != self.rows or + self.screen.pages.cols != self.cols) + { + // Reinit + const new: ScreenSearch = try .init( + self.allocator(), + self.screen, + self.needle(), + ); + + // Deinit/reinit + self.deinit(); + self.* = new; + + // New result should have matching dimensions + assert(self.screen.pages.rows == self.rows); + assert(self.screen.pages.cols == self.cols); + } + const history: *PageListSearch = if (self.history) |*h| &h.searcher else { // No history to feed, search is complete. self.state = .complete; @@ -225,6 +288,11 @@ pub const ScreenSearch = struct { if (!try history.feed()) { // No more data to feed, search is complete. self.state = .complete; + + // We use this opportunity to also clean up older history + // results that may be gone due to scrollback pruning, though. + self.pruneHistory(); + return; } @@ -243,17 +311,38 @@ pub const ScreenSearch = struct { } } + fn pruneHistory(self: *ScreenSearch) void { + // Go through our history results in order (newest to oldest) to find + // any result that contains an invalid serial. Prune up to that + // point. + for (0..self.history_results.items.len) |i| { + const hl = &self.history_results.items[i]; + const serials = hl.chunks.items(.serial); + const lowest = serials[0]; + if (lowest < self.screen.pages.page_serial_min) { + // Everything from here forward we assume is invalid because + // our history results only get older. + const alloc = self.allocator(); + for (self.history_results.items[i..]) |*prune_hl| prune_hl.deinit(alloc); + self.history_results.shrinkAndFree(alloc, i); + return; + } + } + } + fn tickActive(self: *ScreenSearch) Allocator.Error!void { // For the active area, we consume the entire search in one go // because the active area is generally small. const alloc = self.allocator(); - while (self.active.next()) |sel| { + while (self.active.next()) |hl| { // If this fails, then we miss a result since `active.next()` // moves forward and prunes data. In the future, we may want // to have some more robust error handling but the only // scenario this would fail is OOM and we're probably in // deeper trouble at that point anyways. - try self.active_results.append(alloc, sel); + var hl_cloned = try hl.clone(alloc); + errdefer hl_cloned.deinit(alloc); + try self.active_results.append(alloc, hl_cloned); } // We've consumed the entire active area, move to history. @@ -270,13 +359,19 @@ pub const ScreenSearch = struct { // Try to consume all the loaded matches in one go, because // the search is generally fast for loaded data. const alloc = self.allocator(); - while (history.searcher.next()) |sel| { + while (history.searcher.next()) |hl| { // Ignore selections that are found within the starting // node since those are covered by the active area search. - if (sel.start().node == history.start_pin.node) continue; + if (hl.chunks.items(.node)[0] == history.start_pin.node) continue; // Same note as tickActive for error handling. - try self.history_results.append(alloc, sel); + var hl_cloned = try hl.clone(alloc); + errdefer hl_cloned.deinit(alloc); + try self.history_results.append(alloc, hl_cloned); + + // Since history only appends to our results in reverse order, + // we don't need to update any selected match state. The index + // and prior results are unaffected. } // We need to be fed more data. @@ -291,6 +386,25 @@ pub const ScreenSearch = struct { /// /// The caller must hold the necessary locks to access the screen state. pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void { + // If our selection pin became garbage it means we scrolled off + // the end. Clear our selection and on exit of this function, + // try to select the last match. + const select_prev: bool = select_prev: { + const m = if (self.selected) |*m| m else break :select_prev false; + if (!m.highlight.start.garbage and + !m.highlight.end.garbage) break :select_prev false; + + m.deinit(self.screen); + self.selected = null; + break :select_prev true; + }; + defer if (select_prev) { + _ = self.select(.prev) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + }; + + const alloc = self.allocator(); const list: *PageList = &self.screen.pages; if (try self.active.update(list)) |history_node| history: { // We need to account for any active area growth that would @@ -305,6 +419,7 @@ pub const ScreenSearch = struct { if (h.start_pin.garbage) { h.deinit(self.screen); self.history = null; + for (self.history_results.items) |*hl| hl.deinit(alloc); self.history_results.clearRetainingCapacity(); break :state null; } @@ -317,7 +432,7 @@ pub const ScreenSearch = struct { // initialize. var search: PageListSearch = try .init( - self.allocator(), + alloc, self.needle(), list, history_node, @@ -346,7 +461,6 @@ pub const ScreenSearch = struct { // collect all the results into a new list. We ASSUME that // reloadActive is being called frequently enough that there isn't // a massive amount of history to search here. - const alloc = self.allocator(); var window: SlidingWindow = try .init( alloc, .forward, @@ -361,32 +475,91 @@ pub const ScreenSearch = struct { } assert(history.start_pin.node == history_node); - var results: std.ArrayList(Selection) = try .initCapacity( + var results: std.ArrayList(FlattenedHighlight) = try .initCapacity( alloc, self.history_results.items.len, ); errdefer results.deinit(alloc); - while (window.next()) |sel| { - if (sel.start().node == history_node) continue; - try results.append( - alloc, - sel, - ); + while (window.next()) |hl| { + if (hl.chunks.items(.node)[0] == history_node) continue; + + var hl_cloned = try hl.clone(alloc); + errdefer hl_cloned.deinit(alloc); + try results.append(alloc, hl_cloned); } // If we have no matches then there is nothing to change // in our history (fast path) if (results.items.len == 0) break :history; + // The number added to our history. Needed for updating + // our selection if we have one. + const added_len = results.items.len; + // Matches! Reverse our list then append all the remaining // history items that didn't start on our original node. - std.mem.reverse(Selection, results.items); + std.mem.reverse(FlattenedHighlight, results.items); try results.appendSlice(alloc, self.history_results.items); self.history_results.deinit(alloc); self.history_results = results; + + // If our prior selection was in the history area, update + // the offset. + if (self.selected) |*m| selected: { + const active_len = self.active_results.items.len; + if (m.idx < active_len) break :selected; + m.idx += added_len; + + // Moving the idx should not change our targeted result + // since the history is immutable. + if (comptime std.debug.runtime_safety) { + const hl = self.history_results.items[m.idx - active_len]; + assert(m.highlight.start.eql(hl.startPin())); + } + } + } else { + // No history node means we have no history + if (self.history) |*h| { + h.deinit(self.screen); + self.history = null; + for (self.history_results.items) |*hl| hl.deinit(alloc); + self.history_results.clearRetainingCapacity(); + } + + // If we have a selection in the history area, we need to + // move it to the end of the active area. + if (self.selected) |*m| selected: { + const active_len = self.active_results.items.len; + if (m.idx < active_len) break :selected; + m.deinit(self.screen); + self.selected = null; + _ = self.select(.prev) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + } } + // Figure out if we need to fixup our selection later because + // it was in the active area. + const old_active_len = self.active_results.items.len; + const old_selection_idx: ?usize = if (self.selected) |m| m.idx else null; + errdefer if (old_selection_idx != null and + old_selection_idx.? < old_active_len) + { + // This is the error scenario. If something fails below, + // our active area is probably gone, so we just go back + // to the first result because our selection can't be trusted. + if (self.selected) |*m| { + m.deinit(self.screen); + self.selected = null; + _ = self.select(.next) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + } + }; + // Reset our active search results and search again. + for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.clearRetainingCapacity(); switch (self.state) { // If we're in the active state we run a normal tick so @@ -401,6 +574,203 @@ pub const ScreenSearch = struct { try self.tickActive(); }, } + + // Active area search was successful. Now we have to fixup our + // selection if we had one. + fixup: { + const old_idx = old_selection_idx orelse break :fixup; + const m = if (self.selected) |*m| m else break :fixup; + + // If our old selection wasn't in the active area, then we + // need to fix up our offsets. + if (old_idx >= old_active_len) { + m.idx -= old_active_len; + m.idx += self.active_results.items.len; + break :fixup; + } + + // We search for the matching highlight in the new active results. + for (0.., self.active_results.items) |i, hl| { + const untracked = hl.untracked(); + if (m.highlight.start.eql(untracked.start) and + m.highlight.end.eql(untracked.end)) + { + // Found it! Update our index. + m.idx = self.active_results.items.len - 1 - i; + break :fixup; + } + } + + // No match, just go back to the first match. + m.deinit(self.screen); + self.selected = null; + _ = self.select(.next) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + } + } + + /// Return the selected match. + /// + /// This does not require read/write access to the underlying screen. + pub fn selectedMatch(self: *const ScreenSearch) ?FlattenedHighlight { + const sel = self.selected orelse return null; + const active_len = self.active_results.items.len; + if (sel.idx < active_len) { + return self.active_results.items[active_len - 1 - sel.idx]; + } + + const history_len = self.history_results.items.len; + if (sel.idx < active_len + history_len) { + return self.history_results.items[sel.idx - active_len]; + } + + return null; + } + + pub const Select = enum { + /// Next selection, in reverse order (newest to oldest), + /// non-wrapping. + next, + + /// Prev selection, in forward order (oldest to newest), + /// non-wrapping. + prev, + }; + + /// Select the next or previous search result. This requires read/write + /// access to the underlying screen, since we utilize tracked pins to + /// ensure our selection sticks with contents changing. + pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!bool { + // All selection requires valid pins so we prune history and + // reload our active area immediately. This ensures all search + // results point to valid nodes. + try self.reloadActive(); + self.pruneHistory(); + + return switch (to) { + .next => try self.selectNext(), + .prev => try self.selectPrev(), + }; + } + + fn selectNext(self: *ScreenSearch) Allocator.Error!bool { + // Get our previous match so we can change it. If we have no + // prior match, we have the easy task of getting the first. + var prev = if (self.selected) |*m| m else { + // Get our highlight + const hl: FlattenedHighlight = hl: { + if (self.active_results.items.len > 0) { + // Active is in forward order + const len = self.active_results.items.len; + break :hl self.active_results.items[len - 1]; + } else if (self.history_results.items.len > 0) { + // History is in reverse order + break :hl self.history_results.items[0]; + } else { + // No matches at all. Can't select anything. + return false; + } + }; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Our selection is index zero since we just started and + // we store our selection. + self.selected = .{ + .idx = 0, + .highlight = tracked, + }; + return true; + }; + + const next_idx = prev.idx + 1; + const active_len = self.active_results.items.len; + const history_len = self.history_results.items.len; + if (next_idx >= active_len + history_len) { + // No more matches. We don't wrap or reset the match currently. + return false; + } + const hl: FlattenedHighlight = if (next_idx < active_len) + self.active_results.items[active_len - 1 - next_idx] + else + self.history_results.items[next_idx - active_len]; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Free our previous match and setup our new selection + prev.deinit(self.screen); + self.selected = .{ + .idx = next_idx, + .highlight = tracked, + }; + + return true; + } + + fn selectPrev(self: *ScreenSearch) Allocator.Error!bool { + // Get our previous match so we can change it. If we have no + // prior match, we have the easy task of getting the last. + var prev = if (self.selected) |*m| m else { + // Get our highlight (oldest match) + const hl: FlattenedHighlight = hl: { + if (self.history_results.items.len > 0) { + // History is in reverse order, so last item is oldest + const len = self.history_results.items.len; + break :hl self.history_results.items[len - 1]; + } else if (self.active_results.items.len > 0) { + // Active is in forward order, so first item is oldest + break :hl self.active_results.items[0]; + } else { + // No matches at all. Can't select anything. + return false; + } + }; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Our selection is the last index since we just started + // and we store our selection. + const active_len = self.active_results.items.len; + const history_len = self.history_results.items.len; + self.selected = .{ + .idx = active_len + history_len - 1, + .highlight = tracked, + }; + return true; + }; + + // Can't go below zero + if (prev.idx == 0) { + // No more matches. We don't wrap or reset the match currently. + return false; + } + + const next_idx = prev.idx - 1; + const active_len = self.active_results.items.len; + const hl: FlattenedHighlight = if (next_idx < active_len) + self.active_results.items[active_len - 1 - next_idx] + else + self.history_results.items[next_idx - active_len]; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Free our previous match and setup our new selection + prev.deinit(self.screen); + self.selected = .{ + .idx = next_idx, + .highlight = tracked, + }; + + return true; } }; @@ -425,26 +795,26 @@ test "simple search" { try testing.expectEqual(2, matches.len); { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 2, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 2, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } { - const sel = matches[1]; + const sel = matches[1].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } @@ -477,15 +847,15 @@ test "simple search with history" { try testing.expectEqual(1, matches.len); { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } @@ -528,26 +898,26 @@ test "reload active with history change" { defer alloc.free(matches); try testing.expectEqual(2, matches.len); { - const sel = matches[1]; + const sel = matches[1].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 4, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } } @@ -562,15 +932,15 @@ test "reload active with history change" { defer alloc.free(matches); try testing.expectEqual(1, matches.len); { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 2, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 5, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } } } @@ -603,14 +973,363 @@ test "active change contents" { try testing.expectEqual(1, matches.len); { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 1, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 1, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} + +test "select next" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + + // Initially no selection + try testing.expect(search.selectedMatch() == null); + + // Select our next match (first) + try search.searchAll(); + _ = try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Next match + _ = try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Next match (no wrap) + _ = try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} + +test "select in active changes contents completely" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + _ = try search.select(.next); + _ = try search.select(.next); + { + // Initial selection is the first fizz + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Erase the screen, move our cursor to the top, and change contents. + try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + try s.nextSlice("Fuzz\r\nFizz\r\nHello!"); + + try search.reloadActive(); + { + // Our selection should move to the first + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Erase the screen, redraw with same contents. + try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + try s.nextSlice("Fuzz\r\nFizz\r\nFizz"); + + try search.reloadActive(); + { + // Our selection should not move to the first + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} + +test "select into history" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + try s.nextSlice("hello."); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Get all matches + _ = try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Erase the screen, redraw with same contents. + try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + try s.nextSlice("yo yo"); + + try search.reloadActive(); + { + // Our selection should not move since the history is still active. + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Create some new history by adding more lines. + try s.nextSlice("\r\nfizz\r\nfizz\r\nfizz"); // Clear screen and move home + try search.reloadActive(); + { + // Our selection should not move since the history is still not + // pruned. + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} + +test "select prev" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + + // Initially no selection + try testing.expect(search.selectedMatch() == null); + + // Select prev (oldest first) + try search.searchAll(); + _ = try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Prev match (towards newest) + _ = try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Prev match (no wrap, stays at newest) + _ = try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} + +test "select prev then next" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Select next (newest first) + _ = try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + } + + // Select next (older) + _ = try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + } + + // Select prev (back to newer) + _ = try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + } +} + +test "select prev with history" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + try s.nextSlice("Fizz."); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Select prev (oldest first, should be in history) + _ = try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Select prev (towards newer, should move to active area) + _ = try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } } diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index db60a6670..3d64042ce 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -1,14 +1,17 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const CircBuf = @import("../../datastruct/main.zig").CircBuf; const terminal = @import("../main.zig"); const point = terminal.point; +const size = terminal.size; const PageList = terminal.PageList; const Pin = PageList.Pin; const Selection = terminal.Selection; const Screen = terminal.Screen; +const Terminal = terminal.Terminal; const PageFormatter = @import("../formatter.zig").PageFormatter; +const FlattenedHighlight = terminal.highlight.Flattened; /// Searches page nodes via a sliding window. The sliding window maintains /// the invariant that data isn't pruned until (1) we've searched it and @@ -51,6 +54,10 @@ pub const SlidingWindow = struct { /// data to meta. meta: MetaBuf, + /// Buffer that can fit any amount of chunks necessary for next + /// to never fail allocation. + chunk_buf: std.MultiArrayList(FlattenedHighlight.Chunk), + /// Offset into data for our current state. This handles the /// situation where our search moved through meta[0] but didn't /// do enough to prune it. @@ -81,6 +88,7 @@ pub const SlidingWindow = struct { const MetaBuf = CircBuf(Meta, undefined); const Meta = struct { node: *PageList.List.Node, + serial: u64, cell_map: std.ArrayList(point.Coordinate), pub fn deinit(self: *Meta, alloc: Allocator) void { @@ -113,6 +121,7 @@ pub const SlidingWindow = struct { .alloc = alloc, .data = data, .meta = meta, + .chunk_buf = .empty, .needle = needle, .direction = direction, .overlap_buf = overlap_buf, @@ -122,6 +131,7 @@ pub const SlidingWindow = struct { pub fn deinit(self: *SlidingWindow) void { self.alloc.free(self.overlap_buf); self.alloc.free(self.needle); + self.chunk_buf.deinit(self.alloc); self.data.deinit(self.alloc); var meta_it = self.meta.iterator(.forward); @@ -143,14 +153,17 @@ pub const SlidingWindow = struct { /// the invariant that the window is always big enough to contain /// the needle. /// - /// It may seem wasteful to return a full selection, since the needle - /// length is known it seems like we can get away with just returning - /// the start index. However, returning a full selection will give us - /// more flexibility in the future (e.g. if we want to support regex - /// searches or other more complex searches). It does cost us some memory, - /// but searches are expected to be relatively rare compared to normal - /// operations and can eat up some extra memory temporarily. - pub fn next(self: *SlidingWindow) ?Selection { + /// This returns a flattened highlight on a match. The + /// flattened highlight requires allocation and is therefore more expensive + /// than a normal selection, but it is more efficient to render since it + /// has all the information without having to dereference pointers into + /// the terminal state. + /// + /// The flattened highlight chunks reference internal memory for this + /// sliding window and are only valid until the next call to `next()` + /// or `append()`. If the caller wants to retain the flattened highlight + /// then they should clone it. + pub fn next(self: *SlidingWindow) ?FlattenedHighlight { const slices = slices: { // If we have less data then the needle then we can't possibly match const data_len = self.data.len(); @@ -163,8 +176,8 @@ pub const SlidingWindow = struct { }; // Search the first slice for the needle. - if (std.mem.indexOf(u8, slices[0], self.needle)) |idx| { - return self.selection( + if (std.ascii.indexOfIgnoreCase(slices[0], self.needle)) |idx| { + return self.highlight( idx, self.needle.len, ); @@ -189,32 +202,38 @@ pub const SlidingWindow = struct { @memcpy(self.overlap_buf[prefix.len..overlap_len], suffix); // Search the overlap - const idx = std.mem.indexOf( - u8, + const idx = std.ascii.indexOfIgnoreCase( self.overlap_buf[0..overlap_len], self.needle, ) orelse break :overlap; // We found a match in the overlap buffer. We need to map the // index back to the data buffer in order to get our selection. - return self.selection( + return self.highlight( slices[0].len - prefix.len + idx, self.needle.len, ); } // Search the last slice for the needle. - if (std.mem.indexOf(u8, slices[1], self.needle)) |idx| { - return self.selection( + if (std.ascii.indexOfIgnoreCase(slices[1], self.needle)) |idx| { + return self.highlight( slices[0].len + idx, self.needle.len, ); } + // Special case 1-lengthed needles to delete the entire buffer. + if (self.needle.len == 1) { + self.clearAndRetainCapacity(); + self.assertIntegrity(); + return null; + } + // No match. We keep `needle.len - 1` bytes available to // handle the future overlap case. - var meta_it = self.meta.iterator(.reverse); prune: { + var meta_it = self.meta.iterator(.reverse); var saved: usize = 0; while (meta_it.next()) |meta| { const needed = self.needle.len - 1 - saved; @@ -263,114 +282,238 @@ pub const SlidingWindow = struct { return null; } - /// Return a selection for the given start and length into the data - /// buffer and also prune the data/meta buffers if possible up to - /// this start index. + /// Return a flattened highlight for the given start and length. + /// + /// The flattened highlight can be used to render the highlight + /// in the most efficient way because it doesn't require a terminal + /// lock to access terminal data to compare whether some viewport + /// matches the highlight (because it doesn't need to traverse + /// the page nodes). /// /// The start index is assumed to be relative to the offset. i.e. /// index zero is actually at `self.data[self.data_offset]`. The /// selection will account for the offset. - fn selection( + fn highlight( self: *SlidingWindow, start_offset: usize, len: usize, - ) Selection { + ) terminal.highlight.Flattened { const start = start_offset + self.data_offset; - assert(start < self.data.len()); - assert(start + len <= self.data.len()); + const end = start + len - 1; + if (comptime std.debug.runtime_safety) { + assert(start < self.data.len()); + assert(start + len <= self.data.len()); + } - // meta_consumed is the number of bytes we've consumed in the - // data buffer up to and NOT including the meta where we've - // found our pin. This is important because it tells us the - // amount of data we can safely deleted from self.data since - // we can't partially delete a meta block's data. (The partial - // amount is represented by self.data_offset). - var meta_it = self.meta.iterator(.forward); - var meta_consumed: usize = 0; - const tl: Pin = pin(&meta_it, &meta_consumed, start); + // Clear our previous chunk buffer to store this result + self.chunk_buf.clearRetainingCapacity(); + var result: terminal.highlight.Flattened = .empty; - // Store the information required to prune later. We store this - // now because we only want to prune up to our START so we can - // find overlapping matches. - const tl_meta_idx = meta_it.idx - 1; - const tl_meta_consumed = meta_consumed; + // Go through the meta nodes to find our start. + const tl: struct { + /// If non-null, we need to continue searching for the bottom-right. + br: ?struct { + it: MetaBuf.Iterator, + consumed: usize, + }, - // We have to seek back so that we reinspect our current - // iterator value again in case the start and end are in the - // same segment. - meta_it.seekBy(-1); - const br: Pin = pin(&meta_it, &meta_consumed, start + len - 1); - assert(meta_it.idx >= 1); + /// Data to prune, both are lengths. + prune: struct { + meta: usize, + data: usize, + }, + } = tl: { + var meta_it = self.meta.iterator(.forward); + var meta_consumed: usize = 0; + while (meta_it.next()) |meta| { + // Always increment our consumed count so that our index + // is right for the end search if we do it. + const prior_meta_consumed = meta_consumed; + meta_consumed += meta.cell_map.items.len; + + // meta_i is the index we expect to find the match in the + // cell map within this meta if it contains it. + const meta_i = start - prior_meta_consumed; + + // This meta doesn't contain the match. This means we + // can also prune this set of data because we only look + // forward. + if (meta_i >= meta.cell_map.items.len) continue; + + // Now we look for the end. In MOST cases it is the same as + // our starting chunk because highlights are usually small and + // not on a boundary, so let's optimize for that. + const end_i = end - prior_meta_consumed; + if (end_i < meta.cell_map.items.len) { + @branchHint(.likely); + + // The entire highlight is within this meta. + const start_map = meta.cell_map.items[meta_i]; + const end_map = meta.cell_map.items[end_i]; + result.top_x = start_map.x; + result.bot_x = end_map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .serial = meta.serial, + .start = @intCast(start_map.y), + .end = @intCast(end_map.y + 1), + }); + + break :tl .{ + .br = null, + .prune = .{ + .meta = meta_it.idx - 1, + .data = prior_meta_consumed, + }, + }; + } else { + // We found the meta that contains the start of the match + // only. Consume this entire node from our start offset. + const map = meta.cell_map.items[meta_i]; + result.top_x = map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .serial = meta.serial, + .start = @intCast(map.y), + .end = meta.node.data.size.rows, + }); + + break :tl .{ + .br = .{ + .it = meta_it, + .consumed = meta_consumed, + }, + .prune = .{ + .meta = meta_it.idx - 1, + .data = prior_meta_consumed, + }, + }; + } + } else { + // Precondition that the start index is within the data buffer. + unreachable; + } + }; + + // Search for our end. + if (tl.br) |br| { + var meta_it = br.it; + var meta_consumed: usize = br.consumed; + while (meta_it.next()) |meta| { + // meta_i is the index we expect to find the match in the + // cell map within this meta if it contains it. + const meta_i = end - meta_consumed; + if (meta_i >= meta.cell_map.items.len) { + // This meta doesn't contain the match. We still add it + // to our results because we want the full flattened list. + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .serial = meta.serial, + .start = 0, + .end = meta.node.data.size.rows, + }); + + meta_consumed += meta.cell_map.items.len; + continue; + } + + // We found it + const map = meta.cell_map.items[meta_i]; + result.bot_x = map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .serial = meta.serial, + .start = 0, + .end = @intCast(map.y + 1), + }); + break; + } else { + // Precondition that the end index is within the data buffer. + unreachable; + } + } // Our offset into the current meta block is the start index // minus the amount of data fully consumed. We then add one // to move one past the match so we don't repeat it. - self.data_offset = start - tl_meta_consumed + 1; + self.data_offset = start - tl.prune.data + 1; - // meta_it.idx is br's meta index plus one (because the iterator - // moves one past the end; we call next() one last time). So - // we compare against one to check that the meta that we matched - // in has prior meta blocks we can prune. - if (tl_meta_idx > 0) { + // If we went beyond our initial meta node we can prune. + if (tl.prune.meta > 0) { // Deinit all our memory in the meta blocks prior to our // match. - const meta_count = tl_meta_idx; - meta_it.reset(); - for (0..meta_count) |_| meta_it.next().?.deinit(self.alloc); - if (comptime std.debug.runtime_safety) { - assert(meta_it.idx == meta_count); - assert(meta_it.next().?.node == tl.node); + var meta_it = self.meta.iterator(.forward); + var meta_consumed: usize = 0; + for (0..tl.prune.meta) |_| { + const meta: *Meta = meta_it.next().?; + meta_consumed += meta.cell_map.items.len; + meta.deinit(self.alloc); } - self.meta.deleteOldest(meta_count); + if (comptime std.debug.runtime_safety) { + assert(meta_it.idx == tl.prune.meta); + assert(meta_it.next().?.node == self.chunk_buf.items(.node)[0]); + } + self.meta.deleteOldest(tl.prune.meta); // Delete all the data up to our current index. - assert(tl_meta_consumed > 0); - self.data.deleteOldest(tl_meta_consumed); + assert(tl.prune.data > 0); + self.data.deleteOldest(tl.prune.data); } - self.assertIntegrity(); - return switch (self.direction) { - .forward => .init(tl, br, false), - .reverse => .init(br, tl, false), - }; - } + switch (self.direction) { + .forward => {}, + .reverse => { + const slice = self.chunk_buf.slice(); + const nodes = slice.items(.node); + const starts = slice.items(.start); + const ends = slice.items(.end); - /// Convert a data index into a pin. - /// - /// The iterator and offset are both expected to be passed by - /// pointer so that the pin can be efficiently called for multiple - /// indexes (in order). See selection() for an example. - /// - /// Precondition: the index must be within the data buffer. - fn pin( - it: *MetaBuf.Iterator, - offset: *usize, - idx: usize, - ) Pin { - while (it.next()) |meta| { - // meta_i is the index we expect to find the match in the - // cell map within this meta if it contains it. - const meta_i = idx - offset.*; - if (meta_i >= meta.cell_map.items.len) { - // This meta doesn't contain the match. This means we - // can also prune this set of data because we only look - // forward. - offset.* += meta.cell_map.items.len; - continue; - } + if (self.chunk_buf.len > 1) { + // Reverse all our chunks. This should be pretty obvious why. + std.mem.reverse(*PageList.List.Node, nodes); + std.mem.reverse(size.CellCountInt, starts); + std.mem.reverse(size.CellCountInt, ends); - // We found the meta that contains the start of the match. - const map = meta.cell_map.items[meta_i]; - return .{ - .node = meta.node, - .y = @intCast(map.y), - .x = map.x, - }; + // Now normally with forward traversal with multiple pages, + // the suffix of the first page and the prefix of the last + // page are used. + // + // For a reverse traversal, this is inverted (since the + // pages are in reverse order we get the suffix of the last + // page and the prefix of the first page). So we need to + // invert this. + // + // We DON'T need to do this for any middle pages because + // they always use the full page. + // + // This is a fixup that makes our start/end match the + // same logic as the loops above if they were in forward + // order. + assert(nodes.len >= 2); + starts[0] = ends[0] - 1; + ends[0] = nodes[0].data.size.rows; + ends[nodes.len - 1] = starts[nodes.len - 1] + 1; + starts[nodes.len - 1] = 0; + } else { + // For a single chunk, the y values are in reverse order + // (start is the screen-end, end is the screen-start). + // Swap them to get proper top-to-bottom order. + const start_y = starts[0]; + starts[0] = ends[0] - 1; + ends[0] = start_y + 1; + } + + // X values also need to be reversed since the top/bottom + // are swapped for the nodes. + const top_x = result.top_x; + result.top_x = result.bot_x; + result.bot_x = top_x; + }, } - // Unreachable because it is a precondition that the index is - // within the data buffer. - unreachable; + // Copy over our MultiArrayList so it points to the proper memory. + result.chunks = self.chunk_buf; + return result; } /// Add a new node to the sliding window. This will always grow @@ -387,6 +530,7 @@ pub const SlidingWindow = struct { // Initialize our metadata for the node. var meta: Meta = .{ .node = node, + .serial = node.serial, .cell_map = .empty, }; errdefer meta.deinit(self.alloc); @@ -400,7 +544,10 @@ pub const SlidingWindow = struct { // Encode the page into the buffer. const formatter: PageFormatter = formatter: { - var formatter: PageFormatter = .init(&meta.node.data, .plain); + var formatter: PageFormatter = .init(&meta.node.data, .{ + .emit = .plain, + .unwrap = true, + }); formatter.point_map = .{ .alloc = self.alloc, .map = &meta.cell_map, @@ -442,10 +589,11 @@ pub const SlidingWindow = struct { // Ensure our buffers are big enough to store what we need. try self.data.ensureUnusedCapacity(self.alloc, written.len); try self.meta.ensureUnusedCapacity(self.alloc, 1); + try self.chunk_buf.ensureTotalCapacity(self.alloc, self.meta.capacity()); // Append our new node to the circular buffer. - try self.data.appendSlice(written); - try self.meta.append(meta); + self.data.appendSliceAssumeCapacity(written); + self.meta.appendAssumeCapacity(meta); self.assertIntegrity(); return written.len; @@ -473,7 +621,7 @@ pub const SlidingWindow = struct { assert(data_len == self.data.len()); // Integrity check: verify our data offset is within bounds. - assert(self.data_offset < self.data.len()); + assert(self.data.len() == 0 or self.data_offset < self.data.len()); } }; @@ -505,26 +653,118 @@ test "SlidingWindow single append" { // We should be able to find two matches. { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start)); try testing.expectEqual(point.Point{ .active = .{ .x = 10, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end)); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 19, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start)); try testing.expectEqual(point.Point{ .active = .{ .x = 22, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append case insensitive ASCII" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "Boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // We want to test single-page cases. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 22, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append single char" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "b"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // We want to test single-page cases. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -582,26 +822,83 @@ test "SlidingWindow two pages" { // Search should find two matches { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, .y = 22, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 79, .y = 22, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 23, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 10, .y = 23, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow two pages single char" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "b"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find two matches + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -634,15 +931,16 @@ test "SlidingWindow two pages match across boundary" { // Search should find a match { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, .y = 22, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 23, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -831,15 +1129,16 @@ test "SlidingWindow single append across circular buffer boundary" { try testing.expect(slices[1].len > 0); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 19, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 21, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); } @@ -889,15 +1188,16 @@ test "SlidingWindow single append match on boundary" { try testing.expect(slices[1].len > 0); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 21, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); } @@ -920,26 +1220,28 @@ test "SlidingWindow single append reversed" { // We should be able to find two matches. { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 19, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 22, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 10, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -997,26 +1299,28 @@ test "SlidingWindow two pages reversed" { // Search should find two matches (in reverse order) { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 23, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 10, .y = 23, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, .y = 22, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 79, .y = 22, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -1049,15 +1353,16 @@ test "SlidingWindow two pages match across boundary reversed" { // Search should find a match { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, .y = 22, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 23, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -1185,15 +1490,16 @@ test "SlidingWindow single append across circular buffer boundary reversed" { try testing.expect(slices[1].len > 0); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 19, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 21, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); } @@ -1244,15 +1550,90 @@ test "SlidingWindow single append match on boundary reversed" { try testing.expect(slices[1].len > 0); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 21, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); } + +test "SlidingWindow single append soft wrapped" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "boo!"); + defer w.deinit(); + + var t: Terminal = try .init(alloc, .{ .cols = 4, .rows = 5 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A\r\nxxboo!\r\nC"); + + // We want to test single-page cases. + const screen = t.screens.active; + try testing.expect(screen.pages.pages.first == screen.pages.pages.last); + const node: *PageList.List.Node = screen.pages.pages.first.?; + _ = try w.append(node); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 1, + } }, screen.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 2, + } }, screen.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append reversed soft wrapped" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "boo!"); + defer w.deinit(); + + var t: Terminal = try .init(alloc, .{ .cols = 4, .rows = 5 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("A\r\nxxboo!\r\nC"); + + // We want to test single-page cases. + const screen = t.screens.active; + try testing.expect(screen.pages.pages.first == screen.pages.pages.last); + const node: *PageList.List.Node = screen.pages.pages.first.?; + _ = try w.append(node); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 1, + } }, screen.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 2, + } }, screen.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 5b9199afc..76deebcec 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -1,11 +1,11 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); const size = @import("../size.zig"); +const FlattenedHighlight = @import("../highlight.zig").Flattened; const PageList = @import("../PageList.zig"); -const Selection = @import("../Selection.zig"); const SlidingWindow = @import("sliding_window.zig").SlidingWindow; const Terminal = @import("../Terminal.zig"); @@ -26,6 +26,12 @@ pub const ViewportSearch = struct { window: SlidingWindow, fingerprint: ?Fingerprint, + /// If this is null, then active dirty tracking is disabled and if the + /// viewport overlaps the active area we always re-search. If this is + /// non-null, then we only re-search if the active area is dirty. Dirty + /// marking is up to the caller. + active_dirty: ?bool, + pub fn init( alloc: Allocator, needle_unowned: []const u8, @@ -35,7 +41,11 @@ pub const ViewportSearch = struct { // a small amount of work to reverse things. var window: SlidingWindow = try .init(alloc, .forward, needle_unowned); errdefer window.deinit(); - return .{ .window = window, .fingerprint = null }; + return .{ + .window = window, + .fingerprint = null, + .active_dirty = null, + }; } pub fn deinit(self: *ViewportSearch) void { @@ -74,17 +84,29 @@ pub const ViewportSearch = struct { var fingerprint: Fingerprint = try .init(self.window.alloc, list); if (self.fingerprint) |*old| { if (old.eql(fingerprint)) match: { - // If our fingerprint contains the active area, then we always - // re-search since the active area is mutable. - const active_tl = list.getTopLeft(.active); - const active_br = list.getBottomRight(.active).?; + // Determine if we need to check if we overlap the active + // area. If we have dirty tracking on we also set it to + // false here. + const check_active: bool = active: { + const dirty = self.active_dirty orelse break :active true; + if (!dirty) break :active false; + self.active_dirty = false; + break :active true; + }; - // If our viewport contains the start or end of the active area, - // we are in the active area. We purposely do this first - // because our viewport is always larger than the active area. - for (old.nodes) |node| { - if (node == active_tl.node) break :match; - if (node == active_br.node) break :match; + if (check_active) { + // If our fingerprint contains the active area, then we always + // re-search since the active area is mutable. + const active_tl = list.getTopLeft(.active); + const active_br = list.getBottomRight(.active).?; + + // If our viewport contains the start or end of the active area, + // we are in the active area. We purposely do this first + // because our viewport is always larger than the active area. + for (old.nodes) |node| { + if (node == active_tl.node) break :match; + if (node == active_br.node) break :match; + } } // No change @@ -102,6 +124,10 @@ pub const ViewportSearch = struct { self.fingerprint = null; } + // If our active area was set as dirty, we always unset it here + // because we're re-searching now. + if (self.active_dirty) |*v| v.* = false; + // Clear our previous sliding window self.window.clearAndRetainCapacity(); @@ -150,7 +176,7 @@ pub const ViewportSearch = struct { /// Find the next match for the needle in the active area. This returns /// null when there are no more matches. - pub fn next(self: *ViewportSearch) ?Selection { + pub fn next(self: *ViewportSearch) ?FlattenedHighlight { return self.window.next(); } @@ -207,26 +233,28 @@ test "simple search" { try testing.expect(try search.update(&t.screens.active.pages)); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } @@ -250,15 +278,63 @@ test "clear screen and search" { try testing.expect(try search.update(&t.screens.active.pages)); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(search.next() == null); +} + +test "clear screen and search dirty tracking" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ViewportSearch = try .init(alloc, "Fizz"); + defer search.deinit(); + + // Turn on dirty tracking + search.active_dirty = false; + + // Should update since we've never searched before + try testing.expect(try search.update(&t.screens.active.pages)); + + // Should not update since nothing changed + try testing.expect(!try search.update(&t.screens.active.pages)); + + try s.nextSlice("\x1b[2J"); // Clear screen + try s.nextSlice("\x1b[H"); // Move cursor home + try s.nextSlice("Buzz\r\nFizz\r\nBuzz"); + + // Should still not update since active area isn't dirty + try testing.expect(!try search.update(&t.screens.active.pages)); + + // Mark + search.active_dirty = true; + try testing.expect(try search.update(&t.screens.active.pages)); + + { + const h = search.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } @@ -289,15 +365,16 @@ test "history search, no active area" { try testing.expect(try search.update(&t.screens.active.pages)); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } try testing.expect(search.next() == null); diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index b9765ca6a..6fd4f1e79 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -2,7 +2,7 @@ const std = @import("std"); const build_options = @import("terminal_options"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const lib = @import("../lib/main.zig"); const color = @import("color.zig"); @@ -32,7 +32,6 @@ pub const Attribute = union(Tag) { /// Underline the text underline: Underline, - reset_underline, underline_color: color.RGB, @"256_underline_color": u8, reset_underline_color, @@ -92,7 +91,6 @@ pub const Attribute = union(Tag) { "reset_italic", "faint", "underline", - "reset_underline", "underline_color", "256_underline_color", "reset_underline_color", @@ -186,19 +184,25 @@ pub const Parser = struct { /// Next returns the next attribute or null if there are no more attributes. pub fn next(self: *Parser) ?Attribute { if (self.idx >= self.params.len) { - // If we're at index zero it means we must have an empty - // list and an empty list implicitly means unset. - if (self.idx == 0) { - // Add one to ensure we don't loop on unset - self.idx += 1; - return .unset; - } + // We're more likely to not be done than to be done. + @branchHint(.unlikely); - return null; + // Add one to ensure we don't loop on unset + defer self.idx += 1; + + // If we're at index zero it means we must have an empty list + // and an empty list implicitly means unset, otherwise we're + // done and return null. + return if (self.idx == 0) .unset else null; } const slice = self.params[self.idx..self.params.len]; - const colon = self.params_sep.isSet(self.idx); + // Call inlined for performance reasons. + const colon = @call( + .always_inline, + SepList.isSet, + .{ self.params_sep, self.idx }, + ); self.idx += 1; // Our last one will have an idx be the last value. @@ -206,20 +210,30 @@ pub const Parser = struct { // If we have a colon separator then we need to ensure we're // parsing a value that allows it. - if (colon) switch (slice[0]) { - 4, 38, 48, 58 => {}, + if (colon) { + // Colons are fairly rare in the wild. + @branchHint(.unlikely); - else => { - // Consume all the colon separated values. - const start = self.idx; - while (self.params_sep.isSet(self.idx)) self.idx += 1; - self.idx += 1; - return .{ .unknown = .{ - .full = self.params, - .partial = slice[0..@min(self.idx - start + 1, slice.len)], - } }; - }, - }; + switch (slice[0]) { + 4, 38, 48, 58 => {}, + + else => { + // In real world use it's very rare + // that we receive an invalid sequence. + @branchHint(.cold); + + // Consume all the colon separated + // values and return them as unknown. + const start = self.idx; + while (self.params_sep.isSet(self.idx)) self.idx += 1; + self.idx += 1; + return .{ .unknown = .{ + .full = self.params, + .partial = slice[0..@min(self.idx - start + 1, slice.len)], + } }; + }, + } + } switch (slice[0]) { 0 => return .unset, @@ -232,25 +246,37 @@ pub const Parser = struct { 4 => underline: { if (colon) { + // Colons are fairly rare in the wild. + @branchHint(.unlikely); + assert(slice.len >= 2); if (self.isColon()) { + // Invalid/unknown SGRs are just not very likely. + @branchHint(.cold); + self.consumeUnknownColon(); break :underline; } self.idx += 1; - switch (slice[1]) { - 0 => return .reset_underline, - 1 => return .{ .underline = .single }, - 2 => return .{ .underline = .double }, - 3 => return .{ .underline = .curly }, - 4 => return .{ .underline = .dotted }, - 5 => return .{ .underline = .dashed }, + return .{ + .underline = switch (slice[1]) { + 0 => .none, + 1 => .single, + 2 => .double, + 3 => .curly, + 4 => .dotted, + 5 => .dashed, - // For unknown underline styles, just render - // a single underline. - else => return .{ .underline = .single }, - } + // For unknown underline styles, + // just render a single underline. + else => single: { + // This is quite a rare condition. + @branchHint(.cold); + break :single .single; + }, + }, + }; } return .{ .underline = .single }; @@ -272,7 +298,7 @@ pub const Parser = struct { 23 => return .reset_italic, - 24 => return .reset_underline, + 24 => return .{ .underline = .none }, 25 => return .reset_blink, @@ -286,23 +312,32 @@ pub const Parser = struct { .@"8_fg" = @enumFromInt(slice[0] - 30), }, - 38 => if (slice.len >= 2) switch (slice[1]) { - // `2` indicates direct-color (r, g, b). - // We need at least 3 more params for this to make sense. - 2 => if (self.parseDirectColor( - .direct_color_fg, - slice, - colon, - )) |v| return v, + 38 => if (slice.len >= 2) { + // We are very likely to have enough parameters. + @branchHint(.likely); - // `5` indicates indexed color. - 5 => if (slice.len >= 3) { - self.idx += 2; - return .{ - .@"256_fg" = @truncate(slice[2]), - }; - }, - else => {}, + switch (slice[1]) { + // `2` indicates direct-color (r, g, b). + // We need at least 3 more params for this to make sense. + 2 => if (self.parseDirectColor( + .direct_color_fg, + slice, + colon, + )) |v| return v, + + // `5` indicates indexed color. + 5 => if (slice.len >= 3) { + // We are very likely to have enough parameters. + @branchHint(.likely); + + self.idx += 2; + return .{ + .@"256_fg" = @truncate(slice[2]), + }; + }, + + else => {}, + } }, 39 => return .reset_fg, @@ -311,23 +346,32 @@ pub const Parser = struct { .@"8_bg" = @enumFromInt(slice[0] - 40), }, - 48 => if (slice.len >= 2) switch (slice[1]) { - // `2` indicates direct-color (r, g, b). - // We need at least 3 more params for this to make sense. - 2 => if (self.parseDirectColor( - .direct_color_bg, - slice, - colon, - )) |v| return v, + 48 => if (slice.len >= 2) { + // We are very likely to have enough parameters. + @branchHint(.likely); - // `5` indicates indexed color. - 5 => if (slice.len >= 3) { - self.idx += 2; - return .{ - .@"256_bg" = @truncate(slice[2]), - }; - }, - else => {}, + switch (slice[1]) { + // `2` indicates direct-color (r, g, b). + // We need at least 3 more params for this to make sense. + 2 => if (self.parseDirectColor( + .direct_color_bg, + slice, + colon, + )) |v| return v, + + // `5` indicates indexed color. + 5 => if (slice.len >= 3) { + // We are very likely to have enough parameters. + @branchHint(.likely); + + self.idx += 2; + return .{ + .@"256_bg" = @truncate(slice[2]), + }; + }, + + else => {}, + } }, 49 => return .reset_bg, @@ -335,23 +379,31 @@ pub const Parser = struct { 53 => return .overline, 55 => return .reset_overline, - 58 => if (slice.len >= 2) switch (slice[1]) { - // `2` indicates direct-color (r, g, b). - // We need at least 3 more params for this to make sense. - 2 => if (self.parseDirectColor( - .underline_color, - slice, - colon, - )) |v| return v, + 58 => if (slice.len >= 2) { + // We are very likely to have enough parameters. + @branchHint(.likely); - // `5` indicates indexed color. - 5 => if (slice.len >= 3) { - self.idx += 2; - return .{ - .@"256_underline_color" = @truncate(slice[2]), - }; - }, - else => {}, + switch (slice[1]) { + // `2` indicates direct-color (r, g, b). + // We need at least 3 more params for this to make sense. + 2 => if (self.parseDirectColor( + .underline_color, + slice, + colon, + )) |v| return v, + + // `5` indicates indexed color. + 5 => if (slice.len >= 3) { + // We are very likely to have enough parameters. + @branchHint(.likely); + + self.idx += 2; + return .{ + .@"256_underline_color" = @truncate(slice[2]), + }; + }, + else => {}, + } }, 59 => return .reset_underline_color, @@ -389,6 +441,9 @@ pub const Parser = struct { // If we don't have a colon, then we expect exactly 3 semicolon // separated values. if (!colon) { + // Semicolons are much more common than colons. + @branchHint(.likely); + self.idx += 4; return @unionInit(Attribute, @tagName(tag), .{ .r = @truncate(slice[2]), @@ -402,6 +457,9 @@ pub const Parser = struct { const count = self.countColon(); switch (count) { 3 => { + // This is the much more common case in the wild. + @branchHint(.likely); + self.idx += 4; return @unionInit(Attribute, @tagName(tag), .{ .r = @truncate(slice[2]), @@ -420,6 +478,9 @@ pub const Parser = struct { }, else => { + // Invalid/unknown SGRs just don't happen very often at all. + @branchHint(.cold); + self.consumeUnknownColon(); return null; }, @@ -429,10 +490,13 @@ pub const Parser = struct { /// Returns true if the present position has a colon separator. /// This always returns false for the last value since it has no /// separator. - fn isColon(self: *Parser) bool { - // The `- 1` here is because the last value has no separator. - if (self.idx >= self.params.len - 1) return false; - return self.params_sep.isSet(self.idx); + inline fn isColon(self: *Parser) bool { + // Call inlined for performance reasons. + return @call( + .always_inline, + SepList.isSet, + .{ self.params_sep, self.idx }, + ); } fn countColon(self: *Parser) usize { @@ -458,7 +522,9 @@ fn testParse(params: []const u16) Attribute { } fn testParseColon(params: []const u16) Attribute { - var p: Parser = .{ .params = params, .params_sep = .initFull() }; + var p: Parser = .{ .params = params }; + // Mark all parameters except the last as having a colon after. + for (0..params.len - 1) |i| p.params_sep.set(i); return p.next().?; } @@ -560,7 +626,8 @@ test "sgr: underline" { { const v = testParse(&[_]u16{24}); - try testing.expect(v == .reset_underline); + try testing.expect(v == .underline); + try testing.expect(v.underline == .none); } } @@ -573,7 +640,8 @@ test "sgr: underline styles" { { const v = testParseColon(&[_]u16{ 4, 0 }); - try testing.expect(v == .reset_underline); + try testing.expect(v == .underline); + try testing.expect(v.underline == .none); } { diff --git a/src/terminal/size.zig b/src/terminal/size.zig index 8322ddb41..0dedfcc14 100644 --- a/src/terminal/size.zig +++ b/src/terminal/size.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; /// The maximum size of a page in bytes. We use a u16 here because any /// smaller bit size by Zig is upgraded anyways to a u16 on mainstream @@ -28,6 +28,11 @@ pub fn Offset(comptime T: type) type { pub const Slice = struct { offset: Self = .{}, len: usize = 0, + + /// Returns a slice for the data, properly typed. + pub inline fn slice(self: Slice, base: anytype) []T { + return self.offset.ptr(base)[0..self.len]; + } }; /// Returns a pointer to the start of the data, properly typed. @@ -118,7 +123,7 @@ pub const OffsetBuf = struct { /// Get the offset for a given type from some base pointer to the /// actual pointer to the type. -pub fn getOffset( +pub inline fn getOffset( comptime T: type, base: anytype, ptr: *const T, @@ -129,7 +134,7 @@ pub fn getOffset( return .{ .offset = @intCast(offset) }; } -fn intFromBase(base: anytype) usize { +inline fn intFromBase(base: anytype) usize { const T = @TypeOf(base); return switch (@typeInfo(T)) { .pointer => |v| switch (v.size) { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 23211fa80..ba6b57d5c 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1,7 +1,7 @@ const streampkg = @This(); const std = @import("std"); const build_options = @import("terminal_options"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const simd = @import("../simd/main.zig"); @@ -645,6 +645,11 @@ pub fn Stream(comptime Handler: type) type { try self.handleCodepoint(codepoint); } if (!consumed) { + // We optimize for the scenario where the text being + // printed in the terminal ISN'T full of ill-formed + // UTF-8 sequences. + @branchHint(.unlikely); + const retry = self.utf8decoder.next(c); // It should be impossible for the decoder // to not consume the byte twice in a row. @@ -660,12 +665,21 @@ pub fn Stream(comptime Handler: type) type { /// This function is abstracted this way to handle the case where /// the decoder emits a 0x1B after rejecting an ill-formed sequence. inline fn handleCodepoint(self: *Self, c: u21) !void { + // We need to increase the eval branch limit because a lot of + // tests end up running almost completely at comptime due to + // a chain of inline functions. + @setEvalBranchQuota(100_000); + + // C0 control if (c <= 0xF) { + @branchHint(.unlikely); try self.execute(@intCast(c)); return; } + // ESC if (c == 0x1B) { - try self.nextNonUtf8(@intCast(c)); + self.parser.state = .escape; + self.parser.clear(); return; } try self.print(@intCast(c)); @@ -676,14 +690,8 @@ pub fn Stream(comptime Handler: type) type { /// This assumes that we're not in the UTF-8 decoding state. If /// we may be in the UTF-8 decoding state call nextSlice or next. fn nextNonUtf8(self: *Self, c: u8) !void { - assert(self.parser.state != .ground or c == 0x1B); + assert(self.parser.state != .ground); - // Fast path for ESC - if (self.parser.state == .ground and c == 0x1B) { - self.parser.state = .escape; - self.parser.clear(); - return; - } // Fast path for CSI entry. if (self.parser.state == .escape and c == '[') { self.parser.state = .csi_entry; @@ -691,6 +699,11 @@ pub fn Stream(comptime Handler: type) type { } // Fast path for CSI params. if (self.parser.state == .csi_param) csi_param: { + // csi_param is the most common parser state + // other than ground by a fairly wide margin. + // + // ref: https://github.com/qwerasd205/asciinema-stats + @branchHint(.likely); switch (c) { // A C0 escape (yes, this is valid): 0x00...0x0F => try self.execute(c), @@ -777,6 +790,18 @@ pub fn Stream(comptime Handler: type) type { } pub inline fn execute(self: *Self, c: u8) !void { + // If the character is > 0x7F, it's a C1 (8-bit) control, + // which is strictly equivalent to `ESC` plus `c - 0x40`. + if (c > 0x7F) { + @branchHint(.unlikely); + log.info("executing C1 0x{x} as ESC {c}", .{ c, c - 0x40 }); + try self.escDispatch(.{ + .intermediates = &.{}, + .final = c - 0x40, + }); + return; + } + const c0: ansi.C0 = @enumFromInt(c); if (comptime debug) log.info("execute: {f}", .{c0}); switch (c0) { @@ -797,24 +822,52 @@ pub fn Stream(comptime Handler: type) type { } inline fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void { + // The branch hints here are based on real world data + // which indicates that the most common CSI finals are: + // + // 1. m + // 2. H + // 3. K + // 4. A + // 5. C + // 6. X + // 7. l + // 8. h + // 9. r + // + // Together, these 9 finals make up about 96% of all + // CSI sequences encountered in real world scenarios. + // + // Additionally, within the prongs, unlikely branch + // hints have been added to branches that deal with + // invalid sequences/commands, this is in order to + // optimize for the happy path where we're getting + // valid data from the program we're running. + // + // ref: https://github.com/qwerasd205/asciinema-stats + switch (input.final) { // CUU - Cursor Up - 'A', 'k' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_up, .{ - .value = switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor up command: {f}", .{input}); - return; + 'A', 'k' => { + @branchHint(.likely); + switch (input.intermediates.len) { + 0 => try self.handler.vt(.cursor_up, .{ + .value = switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + @branchHint(.unlikely); + log.warn("invalid cursor up command: {f}", .{input}); + return; + }, }, - }, - }), + }), - else => log.warn( - "ignoring unimplemented CSI A with intermediates: {s}", - .{input.intermediates}, - ), + else => log.warn( + "ignoring unimplemented CSI A with intermediates: {s}", + .{input.intermediates}, + ), + } }, // CUD - Cursor Down @@ -824,6 +877,7 @@ pub fn Stream(comptime Handler: type) type { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid cursor down command: {f}", .{input}); return; }, @@ -837,22 +891,26 @@ pub fn Stream(comptime Handler: type) type { }, // CUF - Cursor Right - 'C' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_right, .{ - .value = switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor right command: {f}", .{input}); - return; + 'C' => { + @branchHint(.likely); + switch (input.intermediates.len) { + 0 => try self.handler.vt(.cursor_right, .{ + .value = switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + @branchHint(.unlikely); + log.warn("invalid cursor right command: {f}", .{input}); + return; + }, }, - }, - }), + }), - else => log.warn( - "ignoring unimplemented CSI C with intermediates: {s}", - .{input.intermediates}, - ), + else => log.warn( + "ignoring unimplemented CSI C with intermediates: {s}", + .{input.intermediates}, + ), + } }, // CUB - Cursor Left @@ -862,6 +920,7 @@ pub fn Stream(comptime Handler: type) type { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid cursor left command: {f}", .{input}); return; }, @@ -882,6 +941,7 @@ pub fn Stream(comptime Handler: type) type { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid cursor up command: {f}", .{input}); return; }, @@ -904,6 +964,7 @@ pub fn Stream(comptime Handler: type) type { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid cursor down command: {f}", .{input}); return; }, @@ -926,6 +987,7 @@ pub fn Stream(comptime Handler: type) type { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid HPA command: {f}", .{input}); return; }, @@ -940,24 +1002,28 @@ pub fn Stream(comptime Handler: type) type { // CUP - Set Cursor Position. // TODO: test - 'H', 'f' => switch (input.intermediates.len) { - 0 => { - const pos: streampkg.Action.CursorPos = switch (input.params.len) { - 0 => .{ .row = 1, .col = 1 }, - 1 => .{ .row = input.params[0], .col = 1 }, - 2 => .{ .row = input.params[0], .col = input.params[1] }, - else => { - log.warn("invalid CUP command: {f}", .{input}); - return; - }, - }; - try self.handler.vt(.cursor_pos, pos); - }, + 'H', 'f' => { + @branchHint(.likely); + switch (input.intermediates.len) { + 0 => { + const pos: streampkg.Action.CursorPos = switch (input.params.len) { + 0 => .{ .row = 1, .col = 1 }, + 1 => .{ .row = input.params[0], .col = 1 }, + 2 => .{ .row = input.params[0], .col = input.params[1] }, + else => { + @branchHint(.unlikely); + log.warn("invalid CUP command: {f}", .{input}); + return; + }, + }; + try self.handler.vt(.cursor_pos, pos); + }, - else => log.warn( - "ignoring unimplemented CSI H with intermediates: {s}", - .{input.intermediates}, - ), + else => log.warn( + "ignoring unimplemented CSI H with intermediates: {s}", + .{input.intermediates}, + ), + } }, // CHT - Cursor Horizontal Tabulation @@ -1012,6 +1078,7 @@ pub fn Stream(comptime Handler: type) type { // Erase Line 'K' => { + @branchHint(.likely); const protected_: ?bool = switch (input.intermediates.len) { 0 => false, 1 => if (input.intermediates[0] == '?') true else null, @@ -1019,6 +1086,7 @@ pub fn Stream(comptime Handler: type) type { }; const protected = protected_ orelse { + @branchHint(.unlikely); log.warn("invalid erase line command: {f}", .{input}); return; }; @@ -1030,6 +1098,7 @@ pub fn Stream(comptime Handler: type) type { }; const mode = mode_ orelse { + @branchHint(.unlikely); log.warn("invalid erase line command: {f}", .{input}); return; }; @@ -1039,7 +1108,10 @@ pub fn Stream(comptime Handler: type) type { .left => try self.handler.vt(.erase_line_left, protected), .complete => try self.handler.vt(.erase_line_complete, protected), .right_unless_pending_wrap => try self.handler.vt(.erase_line_right_unless_pending_wrap, protected), - _ => log.warn("invalid erase line mode: {}", .{mode}), + _ => { + @branchHint(.unlikely); + log.warn("invalid erase line mode: {}", .{mode}); + }, } }, @@ -1172,20 +1244,24 @@ pub fn Stream(comptime Handler: type) type { }, // Erase Characters (ECH) - 'X' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.erase_chars, switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid erase characters command: {f}", .{input}); - return; - }, - }), + 'X' => { + @branchHint(.likely); + switch (input.intermediates.len) { + 0 => try self.handler.vt(.erase_chars, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + @branchHint(.unlikely); + log.warn("invalid erase characters command: {f}", .{input}); + return; + }, + }), - else => log.warn( - "ignoring unimplemented CSI X with intermediates: {s}", - .{input.intermediates}, - ), + else => log.warn( + "ignoring unimplemented CSI X with intermediates: {s}", + .{input.intermediates}, + ), + } }, // CHT - Cursor Horizontal Tabulation Back @@ -1325,6 +1401,7 @@ pub fn Stream(comptime Handler: type) type { // SM - Set Mode 'h' => mode: { + @branchHint(.likely); const ansi_mode = ansi: { if (input.intermediates.len == 0) break :ansi true; if (input.intermediates.len == 1 and @@ -1345,6 +1422,7 @@ pub fn Stream(comptime Handler: type) type { // RM - Reset Mode 'l' => mode: { + @branchHint(.likely); const ansi_mode = ansi: { if (input.intermediates.len == 0) break :ansi true; if (input.intermediates.len == 1 and @@ -1364,81 +1442,86 @@ pub fn Stream(comptime Handler: type) type { }, // SGR - Select Graphic Rendition - 'm' => switch (input.intermediates.len) { - 0 => { - // log.info("parse SGR params={any}", .{input.params}); - var p: sgr.Parser = .{ - .params = input.params, - .params_sep = input.params_sep, - }; - while (p.next()) |attr| { - // log.info("SGR attribute: {}", .{attr}); - try self.handler.vt(.set_attribute, attr); - } - }, - - 1 => switch (input.intermediates[0]) { - '>' => blk: { - if (input.params.len == 0) { - // Reset - try self.handler.vt(.modify_key_format, .legacy); - break :blk; - } - - var format: ansi.ModifyKeyFormat = switch (input.params[0]) { - 0 => .legacy, - 1 => .cursor_keys, - 2 => .function_keys, - 4 => .other_keys_none, - else => { - log.warn("invalid setModifyKeyFormat: {f}", .{input}); - break :blk; - }, + 'm' => { + @branchHint(.likely); + switch (input.intermediates.len) { + 0 => { + // This is the most common case. + @branchHint(.likely); + // log.info("parse SGR params={any}", .{input.params}); + var p: sgr.Parser = .{ + .params = input.params, + .params_sep = input.params_sep, }; - - if (input.params.len > 2) { - log.warn("invalid setModifyKeyFormat: {f}", .{input}); - break :blk; + while (p.next()) |attr| { + // log.info("SGR attribute: {}", .{attr}); + try self.handler.vt(.set_attribute, attr); } - - if (input.params.len == 2) { - switch (format) { - // We don't support any of the subparams yet for these. - .legacy => {}, - .cursor_keys => {}, - .function_keys => {}, - - // We only support the numeric form. - .other_keys_none => switch (input.params[1]) { - 2 => format = .other_keys_numeric, - else => {}, - }, - .other_keys_numeric_except => {}, - .other_keys_numeric => {}, - } - } - - try self.handler.vt(.modify_key_format, format); }, - else => log.warn( - "unknown CSI m with intermediate: {}", - .{input.intermediates[0]}, - ), - }, + 1 => switch (input.intermediates[0]) { + '>' => blk: { + if (input.params.len == 0) { + // Reset + try self.handler.vt(.modify_key_format, .legacy); + break :blk; + } - else => { - // Nothing, but I wanted a place to put this comment: - // there are others forms of CSI m that have intermediates. - // `vim --clean` uses `CSI ? 4 m` and I don't know what - // that means. And there is also `CSI > m` which is used - // to control modifier key reporting formats that we don't - // support yet. - log.warn( - "ignoring unimplemented CSI m with intermediates: {s}", - .{input.intermediates}, - ); - }, + var format: ansi.ModifyKeyFormat = switch (input.params[0]) { + 0 => .legacy, + 1 => .cursor_keys, + 2 => .function_keys, + 4 => .other_keys_none, + else => { + @branchHint(.unlikely); + log.warn("invalid setModifyKeyFormat: {f}", .{input}); + break :blk; + }, + }; + + if (input.params.len > 2) { + @branchHint(.unlikely); + log.warn("invalid setModifyKeyFormat: {f}", .{input}); + break :blk; + } + + if (input.params.len == 2) { + switch (format) { + // We don't support any of the subparams yet for these. + .legacy => {}, + .cursor_keys => {}, + .function_keys => {}, + + // We only support the numeric form. + .other_keys_none => switch (input.params[1]) { + 2 => format = .other_keys_numeric, + else => {}, + }, + .other_keys_numeric_except => {}, + .other_keys_numeric => {}, + } + } + + try self.handler.vt(.modify_key_format, format); + }, + + else => log.warn( + "unknown CSI m with intermediate: {}", + .{input.intermediates[0]}, + ), + }, + + else => { + // Nothing, but I wanted a place to put this comment: + // there are others forms of CSI m that have intermediates. + // `vim --clean` uses `CSI ? 4 m` and I don't know what + // that means. + log.warn( + "ignoring unimplemented CSI m with intermediates: {s}", + .{input.intermediates}, + ); + }, + } }, // TODO: test @@ -1605,40 +1688,46 @@ pub fn Stream(comptime Handler: type) type { ), }, - 'r' => switch (input.intermediates.len) { - // DECSTBM - Set Top and Bottom Margins - 0 => switch (input.params.len) { - 0 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = 0, .bottom_right = 0 }), - 1 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), - 2 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), - else => log.warn("invalid DECSTBM command: {f}", .{input}), - }, + 'r' => { + @branchHint(.likely); + switch (input.intermediates.len) { + // DECSTBM - Set Top and Bottom Margins + 0 => switch (input.params.len) { + 0 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = 0, .bottom_right = 0 }), + 1 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), + 2 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), + else => { + @branchHint(.unlikely); + log.warn("invalid DECSTBM command: {f}", .{input}); + }, + }, - 1 => switch (input.intermediates[0]) { - // Restore Mode - '?' => { - for (input.params) |mode_int| { - if (modes.modeFromInt(mode_int, false)) |mode| { - try self.handler.vt(.restore_mode, .{ .mode = mode }); - } else { - log.warn( - "unimplemented restore mode: {}", - .{mode_int}, - ); + 1 => switch (input.intermediates[0]) { + // Restore Mode + '?' => { + for (input.params) |mode_int| { + if (modes.modeFromInt(mode_int, false)) |mode| { + try self.handler.vt(.restore_mode, .{ .mode = mode }); + } else { + log.warn( + "unimplemented restore mode: {}", + .{mode_int}, + ); + } } - } + }, + + else => log.warn( + "unknown CSI s with intermediate: {f}", + .{input}, + ), }, else => log.warn( - "unknown CSI s with intermediate: {f}", + "ignoring unimplemented CSI s with intermediates: {f}", .{input}, ), - }, - - else => log.warn( - "ignoring unimplemented CSI s with intermediates: {f}", - .{input}, - ), + } }, 's' => switch (input.intermediates.len) { @@ -1849,6 +1938,7 @@ pub fn Stream(comptime Handler: type) type { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid ICH command: {f}", .{input}); return; }, @@ -1889,9 +1979,34 @@ pub fn Stream(comptime Handler: type) type { } inline fn oscDispatch(self: *Self, cmd: osc.Command) !void { + // The branch hints here are based on real world data + // which indicates that the most common OSC commands are: + // + // 1. hyperlink_end + // 2. change_window_title + // 3. change_window_icon + // 4. hyperlink_start + // 5. report_pwd + // 6. color_operation + // 7. prompt_start + // 8. prompt_end + // + // Together, these 8 commands make up about 96% of all + // OSC commands encountered in real world scenarios. + // + // Additionally, within the prongs, unlikely branch + // hints have been added to branches that deal with + // invalid sequences/commands, this is in order to + // optimize for the happy path where we're getting + // valid data from the program we're running. + // + // ref: https://github.com/qwerasd205/asciinema-stats + switch (cmd) { .change_window_title => |title| { + @branchHint(.likely); if (!std.unicode.utf8ValidateSlice(title)) { + @branchHint(.unlikely); log.warn("change title request: invalid utf-8, ignoring request", .{}); return; } @@ -1900,6 +2015,7 @@ pub fn Stream(comptime Handler: type) type { }, .change_window_icon => |icon| { + @branchHint(.likely); log.info("OSC 1 (change icon) received and ignored icon={s}", .{icon}); }, @@ -1911,6 +2027,7 @@ pub fn Stream(comptime Handler: type) type { }, .prompt_start => |v| { + @branchHint(.likely); switch (v.kind) { .primary, .right => try self.handler.vt(.prompt_start, .{ .aid = v.aid, @@ -1922,7 +2039,10 @@ pub fn Stream(comptime Handler: type) type { } }, - .prompt_end => try self.handler.vt(.prompt_end, {}), + .prompt_end => { + @branchHint(.likely); + try self.handler.vt(.prompt_end, {}); + }, .end_of_input => try self.handler.vt(.end_of_input, {}), @@ -1931,11 +2051,13 @@ pub fn Stream(comptime Handler: type) type { }, .report_pwd => |v| { + @branchHint(.likely); try self.handler.vt(.report_pwd, .{ .url = v.value }); }, .mouse_shape => |v| { const shape = MouseShape.fromString(v.value) orelse { + @branchHint(.unlikely); log.warn("unknown cursor shape: {s}", .{v.value}); return; }; @@ -1944,6 +2066,7 @@ pub fn Stream(comptime Handler: type) type { }, .color_operation => |v| { + @branchHint(.likely); try self.handler.vt(.color_operation, .{ .op = v.op, .requests = v.requests, @@ -1963,6 +2086,7 @@ pub fn Stream(comptime Handler: type) type { }, .hyperlink_start => |v| { + @branchHint(.likely); try self.handler.vt(.start_hyperlink, .{ .uri = v.uri, .id = v.id, @@ -1970,6 +2094,7 @@ pub fn Stream(comptime Handler: type) type { }, .hyperlink_end => { + @branchHint(.likely); try self.handler.vt(.end_hyperlink, {}); }, @@ -1987,6 +2112,7 @@ pub fn Stream(comptime Handler: type) type { }, .invalid => { + @branchHint(.cold); // This is an invalid internal state, not an invalid OSC // string being parsed. We shouldn't see this. log.warn("invalid OSC, should never happen", .{}); @@ -2012,6 +2138,7 @@ pub fn Stream(comptime Handler: type) type { '*' => .G2, '+' => .G3, else => { + @branchHint(.unlikely); log.warn("invalid charset intermediate: {any}", .{intermediates}); return; }, @@ -2027,22 +2154,56 @@ pub fn Stream(comptime Handler: type) type { self: *Self, action: Parser.Action.ESC, ) !void { + // The branch hints here are based on real world data + // which indicates that the most common ESC finals are: + // + // 1. B + // 2. \ + // 3. 0 + // 4. M + // 5. 8 + // 6. 7 + // 7. > + // 8. = + // + // Together, these 8 finals make up nearly 99% of all + // ESC sequences encountered in real world scenarios. + // + // Additionally, within the prongs, unlikely branch + // hints have been added to branches that deal with + // invalid sequences/commands, this is in order to + // optimize for the happy path where we're getting + // valid data from the program we're running. + // + // ref: https://github.com/qwerasd205/asciinema-stats + switch (action.final) { // Charsets - 'B' => try self.configureCharset(action.intermediates, .ascii), + 'B' => { + @branchHint(.likely); + try self.configureCharset(action.intermediates, .ascii); + }, 'A' => try self.configureCharset(action.intermediates, .british), - '0' => try self.configureCharset(action.intermediates, .dec_special), + '0' => { + @branchHint(.likely); + try self.configureCharset(action.intermediates, .dec_special); + }, // DECSC - Save Cursor - '7' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.save_cursor, {}), - else => { - log.warn("invalid command: {f}", .{action}); - return; - }, + '7' => { + @branchHint(.likely); + switch (action.intermediates.len) { + 0 => try self.handler.vt(.save_cursor, {}), + else => { + @branchHint(.unlikely); + log.warn("invalid command: {f}", .{action}); + return; + }, + } }, '8' => blk: { + @branchHint(.likely); switch (action.intermediates.len) { // DECRC - Restore Cursor 0 => { @@ -2070,6 +2231,7 @@ pub fn Stream(comptime Handler: type) type { 'D' => switch (action.intermediates.len) { 0 => try self.handler.vt(.index, {}), else => { + @branchHint(.unlikely); log.warn("invalid index command: {f}", .{action}); return; }, @@ -2079,6 +2241,7 @@ pub fn Stream(comptime Handler: type) type { 'E' => switch (action.intermediates.len) { 0 => try self.handler.vt(.next_line, {}), else => { + @branchHint(.unlikely); log.warn("invalid next line command: {f}", .{action}); return; }, @@ -2088,18 +2251,23 @@ pub fn Stream(comptime Handler: type) type { 'H' => switch (action.intermediates.len) { 0 => try self.handler.vt(.tab_set, {}), else => { + @branchHint(.unlikely); log.warn("invalid tab set command: {f}", .{action}); return; }, }, // RI - Reverse Index - 'M' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.reverse_index, {}), - else => { - log.warn("invalid reverse index command: {f}", .{action}); - return; - }, + 'M' => { + @branchHint(.likely); + switch (action.intermediates.len) { + 0 => try self.handler.vt(.reverse_index, {}), + else => { + @branchHint(.unlikely); + log.warn("invalid reverse index command: {f}", .{action}); + return; + }, + } }, // SS2 - Single Shift 2 @@ -2110,6 +2278,7 @@ pub fn Stream(comptime Handler: type) type { .locking = true, }), else => { + @branchHint(.unlikely); log.warn("invalid single shift 2 command: {f}", .{action}); return; }, @@ -2123,6 +2292,7 @@ pub fn Stream(comptime Handler: type) type { .locking = true, }), else => { + @branchHint(.unlikely); log.warn("invalid single shift 3 command: {f}", .{action}); return; }, @@ -2162,6 +2332,7 @@ pub fn Stream(comptime Handler: type) type { .locking = false, }), else => { + @branchHint(.unlikely); log.warn("invalid single shift 2 command: {f}", .{action}); return; }, @@ -2175,6 +2346,7 @@ pub fn Stream(comptime Handler: type) type { .locking = false, }), else => { + @branchHint(.unlikely); log.warn("invalid single shift 3 command: {f}", .{action}); return; }, @@ -2188,6 +2360,7 @@ pub fn Stream(comptime Handler: type) type { .locking = false, }), else => { + @branchHint(.unlikely); log.warn("invalid locking shift 1 right command: {f}", .{action}); return; }, @@ -2201,6 +2374,7 @@ pub fn Stream(comptime Handler: type) type { .locking = false, }), else => { + @branchHint(.unlikely); log.warn("invalid locking shift 2 right command: {f}", .{action}); return; }, @@ -2214,26 +2388,35 @@ pub fn Stream(comptime Handler: type) type { .locking = false, }), else => { + @branchHint(.unlikely); log.warn("invalid locking shift 3 right command: {f}", .{action}); return; }, }, // Set application keypad mode - '=' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.set_mode, .{ .mode = .keypad_keys }), - else => log.warn("unimplemented setMode: {f}", .{action}), + '=' => { + @branchHint(.likely); + switch (action.intermediates.len) { + 0 => try self.handler.vt(.set_mode, .{ .mode = .keypad_keys }), + else => log.warn("unimplemented setMode: {f}", .{action}), + } }, // Reset application keypad mode - '>' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.reset_mode, .{ .mode = .keypad_keys }), - else => log.warn("unimplemented setMode: {f}", .{action}), + '>' => { + @branchHint(.likely); + switch (action.intermediates.len) { + 0 => try self.handler.vt(.reset_mode, .{ .mode = .keypad_keys }), + else => log.warn("unimplemented setMode: {f}", .{action}), + } }, // Sets ST (string terminator). We don't have to do anything // because our parser always accepts ST. - '\\' => {}, + '\\' => { + @branchHint(.likely); + }, else => log.warn("unimplemented ESC action: {f}", .{action}), } diff --git a/src/terminal/style.zig b/src/terminal/style.zig index d7e6b03ab..e5c47b9fe 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const configpkg = @import("../config.zig"); const color = @import("color.zig"); const sgr = @import("sgr.zig"); @@ -54,6 +54,15 @@ pub const Style = struct { rgb, }; + /// True if the color is equal to another color. + pub fn eql(self: Color, other: Color) bool { + return @as(Tag, self) == @as(Tag, other) and switch (self) { + .none => true, + .palette => self.palette == other.palette, + .rgb => self.rgb == other.rgb, + }; + } + /// Formatting to make debug logs easier to read /// by only including non-default attributes. pub fn format( @@ -79,28 +88,16 @@ pub const Style = struct { }; /// True if the style is the default style. - pub fn default(self: Style) bool { + pub inline fn default(self: Style) bool { return self.eql(.{}); } /// True if the style is equal to another style. - /// For performance do direct comparisons first. pub fn eql(self: Style, other: Style) bool { - inline for (comptime std.meta.fields(Style)) |field| { - if (comptime std.meta.hasUniqueRepresentation(field.type)) { - if (@field(self, field.name) != @field(other, field.name)) { - return false; - } - } - } - inline for (comptime std.meta.fields(Style)) |field| { - if (comptime !std.meta.hasUniqueRepresentation(field.type)) { - if (!std.meta.eql(@field(self, field.name), @field(other, field.name))) { - return false; - } - } - } - return true; + return self.flags == other.flags and + self.fg_color.eql(other.fg_color) and + self.bg_color.eql(other.bg_color) and + self.underline_color.eql(other.underline_color); } /// Returns the bg color for a cell with this style given the cell @@ -509,12 +506,12 @@ pub const Style = struct { } }; - fn fromStyle(style: Style) PackedStyle { + inline fn fromStyle(style: Style) PackedStyle { return .{ .tags = .{ - .fg = std.meta.activeTag(style.fg_color), - .bg = std.meta.activeTag(style.bg_color), - .underline = std.meta.activeTag(style.underline_color), + .fg = @as(Color.Tag, style.fg_color), + .bg = @as(Color.Tag, style.bg_color), + .underline = @as(Color.Tag, style.underline_color), }, .data = .{ .fg = .fromColor(style.fg_color), @@ -527,8 +524,11 @@ pub const Style = struct { }; pub fn hash(self: *const Style) u64 { - const packed_style = PackedStyle.fromStyle(self.*); - return std.hash.XxHash3.hash(0, std.mem.asBytes(&packed_style)); + // We pack the style in to 128 bits, fold it to 64 bits, + // then use std.hash.int to make it sufficiently uniform. + const packed_style: PackedStyle = .fromStyle(self.*); + const wide: [2]u64 = @bitCast(packed_style); + return @call(.always_inline, std.hash.int, .{wide[0] ^ wide[1]}); } comptime { diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 67c5a979c..56d4c5fe2 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -4,7 +4,7 @@ //! documentation. const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const oni = @import("oniguruma"); const log = std.log.scoped(.terminal_tmux); @@ -33,7 +33,8 @@ pub const Client = struct { idle, /// We experienced unexpected input and are in a broken state - /// so we cannot continue processing. + /// so we cannot continue processing. When this state is set, + /// the buffer has been deinited and must not be accessed. broken, /// Inside an active notification (started with '%'). @@ -44,11 +45,21 @@ pub const Client = struct { }; pub fn deinit(self: *Client) void { + // If we're in a broken state, we already deinited + // the buffer, so we don't need to do anything. + if (self.state == .broken) return; + self.buffer.deinit(); } // Handle a byte of input. pub fn put(self: *Client, byte: u8) !?Notification { + // If we're in a broken state, just do nothing. + // + // We have to do this check here before we check the buffer, because if + // we're in a broken state then we'd have already deinited the buffer. + if (self.state == .broken) return null; + if (self.buffer.written().len >= self.max_bytes) { self.broken(); return error.OutOfMemory; diff --git a/src/terminal/x11_color.zig b/src/terminal/x11_color.zig index 977cd4538..477218d6f 100644 --- a/src/terminal/x11_color.zig +++ b/src/terminal/x11_color.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const RGB = @import("color.zig").RGB; /// The map of all available X11 colors. diff --git a/src/termio.zig b/src/termio.zig index c69785b25..b16885109 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -30,7 +30,6 @@ pub const Backend = backend.Backend; pub const DerivedConfig = Termio.DerivedConfig; pub const Mailbox = mailbox.Mailbox; pub const Message = message.Message; -pub const MessageData = message.MessageData; pub const StreamHandler = stream_handler.StreamHandler; test { diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 5dfda9a14..7c7b711fd 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -5,7 +5,7 @@ const Exec = @This(); const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const posix = std.posix; diff --git a/src/termio/Options.zig b/src/termio/Options.zig index 7484fd087..f41709f4a 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -1,10 +1,8 @@ //! The options that are used to configure a terminal IO implementation. -const builtin = @import("builtin"); const xev = @import("../global.zig").xev; const apprt = @import("../apprt.zig"); const renderer = @import("../renderer.zig"); -const Command = @import("../Command.zig"); const Config = @import("../config.zig").Config; const termio = @import("../termio.zig"); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 9bcbd38ca..53df00433 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -5,27 +5,20 @@ pub const Termio = @This(); const std = @import("std"); -const builtin = @import("builtin"); -const build_config = @import("../build_config.zig"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const EnvMap = std.process.EnvMap; const posix = std.posix; const termio = @import("../termio.zig"); -const Command = @import("../Command.zig"); -const Pty = @import("../pty.zig").Pty; const StreamHandler = @import("stream_handler.zig").StreamHandler; const terminalpkg = @import("../terminal/main.zig"); -const terminfo = @import("../terminfo/main.zig"); const xev = @import("../global.zig").xev; const renderer = @import("../renderer.zig"); const apprt = @import("../apprt.zig"); -const fastmem = @import("../fastmem.zig"); const internal_os = @import("../os/main.zig"); const windows = internal_os.windows; const configpkg = @import("../config.zig"); -const shell_integration = @import("shell_integration.zig"); const log = std.log.scoped(.io_exec); diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index bb616e623..b111d5a52 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -19,7 +19,6 @@ const crash = @import("../crash/main.zig"); const internal_os = @import("../os/main.zig"); const termio = @import("../termio.zig"); const renderer = @import("../renderer.zig"); -const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue; const Allocator = std.mem.Allocator; const log = std.log.scoped(.io_thread); diff --git a/src/termio/backend.zig b/src/termio/backend.zig index 280fcbde1..ae0e2004f 100644 --- a/src/termio/backend.zig +++ b/src/termio/backend.zig @@ -1,18 +1,9 @@ const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const posix = std.posix; -const xev = @import("../global.zig").xev; -const build_config = @import("../build_config.zig"); -const configpkg = @import("../config.zig"); -const internal_os = @import("../os/main.zig"); const renderer = @import("../renderer.zig"); -const shell_integration = @import("shell_integration.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); -const Command = @import("../Command.zig"); -const Pty = @import("../pty.zig").Pty; // The preallocation size for the write request pool. This should be big // enough to satisfy most write requests. It must be a power of 2. diff --git a/src/termio/mailbox.zig b/src/termio/mailbox.zig index b144b512a..2725d0241 100644 --- a/src/termio/mailbox.zig +++ b/src/termio/mailbox.zig @@ -1,6 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; const xev = @import("../global.zig").xev; const renderer = @import("../renderer.zig"); diff --git a/src/termio/message.zig b/src/termio/message.zig index ee6dbcc0f..f78da2058 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -1,10 +1,9 @@ const std = @import("std"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const apprt = @import("../apprt.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); +const MessageData = @import("../datastruct/main.zig").MessageData; /// The messages that can be sent to an IO thread. /// @@ -97,95 +96,6 @@ pub const Message = union(enum) { }; }; -/// Creates a union that can be used to accommodate data that fit within an array, -/// are a stable pointer, or require deallocation. This is helpful for thread -/// messaging utilities. -pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type { - return union(enum) { - pub const Self = @This(); - - pub const Small = struct { - pub const Max = small_size; - pub const Array = [Max]Elem; - pub const Len = std.math.IntFittingRange(0, small_size); - data: Array = undefined, - len: Len = 0, - }; - - pub const Alloc = struct { - alloc: Allocator, - data: []Elem, - }; - - pub const Stable = []const Elem; - - /// A small write where the data fits into this union size. - small: Small, - - /// A stable pointer so we can just pass the slice directly through. - /// This is useful i.e. for const data. - stable: Stable, - - /// Allocated and must be freed with the provided allocator. This - /// should be rarely used. - alloc: Alloc, - - /// Initializes the union for a given data type. This will - /// attempt to fit into a small value if possible, otherwise - /// will allocate and put into alloc. - /// - /// This can't and will never detect stable pointers. - pub fn init(alloc: Allocator, data: anytype) !Self { - switch (@typeInfo(@TypeOf(data))) { - .pointer => |info| { - assert(info.size == .slice); - assert(info.child == Elem); - - // If it fits in our small request, do that. - if (data.len <= Small.Max) { - var buf: Small.Array = undefined; - @memcpy(buf[0..data.len], data); - return Self{ - .small = .{ - .data = buf, - .len = @intCast(data.len), - }, - }; - } - - // Otherwise, allocate - const buf = try alloc.dupe(Elem, data); - errdefer alloc.free(buf); - return Self{ - .alloc = .{ - .alloc = alloc, - .data = buf, - }, - }; - }, - - else => unreachable, - } - } - - pub fn deinit(self: Self) void { - switch (self) { - .small, .stable => {}, - .alloc => |v| v.alloc.free(v.data), - } - } - - /// Returns a const slice of the data pointed to by this request. - pub fn slice(self: *const Self) []const Elem { - return switch (self.*) { - .small => |*v| v.data[0..v.len], - .stable => |v| v, - .alloc => |v| v.data, - }; - } - }; -} - test { std.testing.refAllDecls(@This()); } @@ -195,35 +105,3 @@ test { const testing = std.testing; try testing.expectEqual(@as(usize, 40), @sizeOf(Message)); } - -test "MessageData init small" { - const testing = std.testing; - const alloc = testing.allocator; - - const Data = MessageData(u8, 10); - const input = "hello!"; - const io = try Data.init(alloc, @as([]const u8, input)); - try testing.expect(io == .small); -} - -test "MessageData init alloc" { - const testing = std.testing; - const alloc = testing.allocator; - - const Data = MessageData(u8, 10); - const input = "hello! " ** 100; - const io = try Data.init(alloc, @as([]const u8, input)); - try testing.expect(io == .alloc); - io.alloc.alloc.free(io.alloc.data); -} - -test "MessageData small fits non-u8 sized data" { - const testing = std.testing; - const alloc = testing.allocator; - - const len = 500; - const Data = MessageData(u8, len); - const input: []const u8 = "X" ** len; - const io = try Data.init(alloc, input); - try testing.expect(io == .small); -} diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 570ec85cc..b93f2ea31 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -672,12 +672,12 @@ fn setupZsh( resource_dir: []const u8, env: *EnvMap, ) !void { - // Preserve the old zdotdir value so we can recover it. + // Preserve an existing ZDOTDIR value. We're about to overwrite it. if (env.get("ZDOTDIR")) |old| { try env.put("GHOSTTY_ZSH_ZDOTDIR", old); } - // Set our new ZDOTDIR + // Set our new ZDOTDIR to point to our shell resource directory. var path_buf: [std.fs.max_path_bytes]u8 = undefined; const integ_dir = try std.fmt.bufPrint( &path_buf, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index fd94f77bc..6e125e100 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; +const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const xev = @import("../global.zig").xev; const apprt = @import("../apprt.zig"); @@ -165,22 +165,47 @@ pub const StreamHandler = struct { comptime action: Stream.Action.Tag, value: Stream.Action.Value(action), ) !void { + // The branch hints here are based on real world data + // which indicates that the most common actions are: + // + // 1. print + // 2. set_attribute + // 3. carriage_return + // 4. line_feed + // 5. cursor_pos + // + // Together, these 5 actions make up nearly 98% of + // all actions encountered in real world scenarios. + // + // ref: https://github.com/qwerasd205/asciinema-stats switch (action) { - .print => try self.terminal.print(value.cp), + .print => { + @branchHint(.likely); + try self.terminal.print(value.cp); + }, .print_repeat => try self.terminal.printRepeat(value), .bell => self.bell(), .backspace => self.terminal.backspace(), .horizontal_tab => try self.horizontalTab(value), .horizontal_tab_back => try self.horizontalTabBack(value), - .linefeed => try self.linefeed(), - .carriage_return => self.terminal.carriageReturn(), + .linefeed => { + @branchHint(.likely); + try self.linefeed(); + }, + .carriage_return => { + @branchHint(.likely); + self.terminal.carriageReturn(); + }, .enquiry => try self.enquiry(), .invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking), .cursor_up => self.terminal.cursorUp(value.value), .cursor_down => self.terminal.cursorDown(value.value), .cursor_left => self.terminal.cursorLeft(value.value), .cursor_right => self.terminal.cursorRight(value.value), - .cursor_pos => self.terminal.setCursorPos(value.row, value.col), + .cursor_pos => { + @branchHint(.likely); + self.terminal.setCursorPos(value.row, value.col); + }, .cursor_col => self.terminal.setCursorPos(self.terminal.screens.active.cursor.y + 1, value.value), .cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screens.active.cursor.x + 1), .cursor_col_relative => self.terminal.setCursorPos( @@ -290,10 +315,23 @@ pub const StreamHandler = struct { .end_of_command => self.endOfCommand(value.exit_code), .mouse_shape => try self.setMouseShape(value), .configure_charset => self.configureCharset(value.slot, value.charset), - .set_attribute => switch (value) { - .unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}), - else => self.terminal.setAttribute(value) catch |err| - log.warn("error setting attribute {}: {}", .{ value, err }), + .set_attribute => { + @branchHint(.likely); + switch (value) { + .unknown => |unk| { + // We optimize for the happy path scenario here, since + // unknown/invalid SGRs aren't that common in the wild. + @branchHint(.unlikely); + log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}); + }, + else => { + @branchHint(.likely); + self.terminal.setAttribute(value) catch |err| { + @branchHint(.cold); + log.warn("error setting attribute {}: {}", .{ value, err }); + }; + }, + } }, .dcs_hook => try self.dcsHook(value), .dcs_put => try self.dcsPut(value), diff --git a/src/unicode/props.zig b/src/unicode/props.zig index 7099e79cd..492dad34a 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -16,15 +16,13 @@ pub const Properties = packed struct { grapheme_boundary_class: GraphemeBoundaryClass = .invalid, /// Emoji VS compatibility - emoji_vs_text: bool = false, - emoji_vs_emoji: bool = false, + emoji_vs_base: bool = false, // Needed for lut.Generator pub fn eql(a: Properties, b: Properties) bool { return a.width == b.width and a.grapheme_boundary_class == b.grapheme_boundary_class and - a.emoji_vs_text == b.emoji_vs_text and - a.emoji_vs_emoji == b.emoji_vs_emoji; + a.emoji_vs_base == b.emoji_vs_base; } // Needed for lut.Generator @@ -36,14 +34,12 @@ pub const Properties = packed struct { \\.{{ \\ .width= {}, \\ .grapheme_boundary_class= .{s}, - \\ .emoji_vs_text= {}, - \\ .emoji_vs_emoji= {}, + \\ .emoji_vs_base= {}, \\}} , .{ self.width, @tagName(self.grapheme_boundary_class), - self.emoji_vs_text, - self.emoji_vs_emoji, + self.emoji_vs_base, }); } }; diff --git a/src/unicode/props_uucode.zig b/src/unicode/props_uucode.zig index 84aafd0be..2440d437c 100644 --- a/src/unicode/props_uucode.zig +++ b/src/unicode/props_uucode.zig @@ -11,11 +11,6 @@ const GraphemeBoundaryClass = @import("props.zig").GraphemeBoundaryClass; fn graphemeBoundaryClass(cp: u21) GraphemeBoundaryClass { if (cp > uucode.config.max_code_point) return .invalid; - // We special-case modifier bases because we should not break - // if a modifier isn't next to a base. - if (uucode.get(.is_emoji_modifier, cp)) return .emoji_modifier; - if (uucode.get(.is_emoji_modifier_base, cp)) return .extended_pictographic_base; - return switch (uucode.get(.grapheme_break, cp)) { .extended_pictographic => .extended_pictographic, .l => .L, @@ -27,6 +22,8 @@ fn graphemeBoundaryClass(cp: u21) GraphemeBoundaryClass { .zwj => .zwj, .spacing_mark => .spacing_mark, .regional_indicator => .regional_indicator, + .emoji_modifier => .emoji_modifier, + .emoji_modifier_base => .extended_pictographic_base, .zwnj, .indic_conjunct_break_extend, @@ -51,15 +48,13 @@ pub fn get(cp: u21) Properties { if (cp > uucode.config.max_code_point) return .{ .width = 1, .grapheme_boundary_class = .invalid, - .emoji_vs_text = false, - .emoji_vs_emoji = false, + .emoji_vs_base = false, }; return .{ .width = uucode.get(.width, cp), .grapheme_boundary_class = graphemeBoundaryClass(cp), - .emoji_vs_text = uucode.get(.is_emoji_vs_text, cp), - .emoji_vs_emoji = uucode.get(.is_emoji_vs_emoji, cp), + .emoji_vs_base = uucode.get(.is_emoji_vs_base, cp), }; }