diff --git a/.gitattributes b/.gitattributes index 6bf5ceb13..87f1eb32e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,4 +9,6 @@ pkg/glfw/wayland-headers/** linguist-vendored pkg/libintl/config.h linguist-generated=true pkg/libintl/libintl.h linguist-generated=true pkg/simdutf/vendor/** linguist-vendored +src/font/nerd_font_attributes.zig linguist-generated=true +src/font/nerd_font_codepoint_tables.py linguist-generated=true src/terminal/res/** linguist-vendored diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml new file mode 100644 index 000000000..7c4256e0e --- /dev/null +++ b/.github/workflows/flatpak.yml @@ -0,0 +1,50 @@ +on: + workflow_dispatch: + inputs: + source-run-id: + description: run id of the workflow that generated the artifact + required: true + type: string + source-artifact-id: + description: source tarball built during build-dist + required: true + type: string + +name: Flatpak + +jobs: + build: + if: github.repository == 'ghostty-org/ghostty' + name: "Flatpak" + container: + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47 + options: --privileged + strategy: + fail-fast: false + matrix: + variant: + - arch: x86_64 + runner: namespace-profile-ghostty-md + - arch: aarch64 + runner: namespace-profile-ghostty-md-arm64 + runs-on: ${{ matrix.variant.runner }} + steps: + - name: Download Source Tarball Artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + run-id: ${{ inputs.source-run-id }} + artifact-ids: ${{ inputs.source-artifact-id }} + github-token: ${{ github.token }} + + - name: Extract tarball + run: | + mkdir dist + tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz + + - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 + with: + bundle: com.mitchellh.ghostty + manifest-path: dist/flatpak/com.mitchellh.ghostty.yml + cache-key: flatpak-builder-${{ github.sha }} + arch: ${{ matrix.variant.arch }} + verbose: true diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml index d12418d9c..74f2dd7ce 100644 --- a/.github/workflows/milestone.yml +++ b/.github/workflows/milestone.yml @@ -15,7 +15,7 @@ jobs: name: Milestone Update steps: - name: Set Milestone for PR - uses: hustcer/milestone-action@b57a7e52e9913b6b0cdefb10add762af0398659d # v2.9 + uses: hustcer/milestone-action@dcd6c3742acc1846929c054251c64cccd555a00d # v3.0 if: github.event.pull_request.merged == true with: action: bind-pr # `bind-pr` is the default action @@ -24,7 +24,7 @@ jobs: # Bind milestone to closed issue that has a merged PR fix - name: Set Milestone for Issue - uses: hustcer/milestone-action@b57a7e52e9913b6b0cdefb10add762af0398659d # v2.9 + uses: hustcer/milestone-action@dcd6c3742acc1846929c054251c64cccd555a00d # v3.0 if: github.event.issue.state == 'closed' with: action: bind-issue diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index ef6f96555..d992ba034 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -1,5 +1,10 @@ on: [push, pull_request] name: Nix + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name != 'main' && github.ref || github.run_id }} + cancel-in-progress: true + jobs: required: name: "Required Checks: Nix" @@ -34,15 +39,15 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/publish-tag.yml b/.github/workflows/publish-tag.yml index 710d04647..c433e7484 100644 --- a/.github/workflows/publish-tag.yml +++ b/.github/workflows/publish-tag.yml @@ -28,7 +28,7 @@ jobs: echo "Version is valid: ${{ github.event.inputs.version }}" - - name: Exract the Version + - name: Extract the Version id: extract_version run: | VERSION=${{ github.event.inputs.version }} diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index af912215c..748965513 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,16 +80,16 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable @@ -113,7 +113,7 @@ jobs: nix develop -c minisign -S -m "ghostty-source.tar.gz" -s minisign.key < minisign.password - name: Upload artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: source-tarball path: |- @@ -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: @@ -269,7 +269,7 @@ jobs: zip -9 -r --symlinks ../../../ghostty-macos-universal-dsym.zip Ghostty.app.dSYM/ - name: Upload artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: macos path: |- @@ -286,7 +286,7 @@ jobs: curl -sL https://sentry.io/get-cli/ | bash - name: Download macOS Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos @@ -306,10 +306,10 @@ 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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos @@ -340,7 +340,7 @@ jobs: mv appcast_new.xml appcast.xml - name: Upload artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: sparkle path: |- @@ -357,17 +357,17 @@ jobs: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} steps: - name: Download macOS Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos - name: Download Sparkle Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: sparkle - name: Download Source Tarball Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: source-tarball diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 7f7b85e2f..fb6aef87d 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -19,7 +19,6 @@ jobs: if: | github.event_name == 'workflow_dispatch' || ( - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' ) @@ -30,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@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -67,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]" @@ -82,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: | @@ -105,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: | @@ -128,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: | @@ -151,7 +150,6 @@ jobs: ( github.event_name == 'workflow_dispatch' || ( - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' ) @@ -161,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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -188,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@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -206,7 +204,6 @@ jobs: ( github.event_name == 'workflow_dispatch' || ( - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' ) @@ -220,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 @@ -359,7 +356,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -373,7 +370,6 @@ jobs: # Create our appcast for Sparkle - name: Generate Appcast if: | - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' env: @@ -408,7 +404,6 @@ jobs: # gets out of sync with the binaries. - name: Prep R2 Storage for Appcast if: | - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' run: | @@ -418,7 +413,6 @@ jobs: - name: Upload Appcast to R2 if: | - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4 @@ -444,7 +438,6 @@ jobs: ( github.event_name == 'workflow_dispatch' || ( - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' ) @@ -458,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 @@ -590,7 +583,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -629,7 +622,6 @@ jobs: ( github.event_name == 'workflow_dispatch' || ( - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' ) @@ -643,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 @@ -775,7 +767,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index 4e9aa168c..6fc7e0fb4 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -26,7 +26,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Download Source Tarball Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: run-id: ${{ inputs.source-run-id }} artifact-ids: ${{ inputs.source-artifact-id }} @@ -38,7 +38,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9893106dd..30f34120a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,11 @@ on: name: Test +# We only want the latest commit to test for any non-main ref. +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name != 'main' && github.ref || github.run_id }} + cancel-in-progress: true + jobs: required: name: "Required Checks: Test" @@ -19,7 +24,7 @@ jobs: - build-linux-libghostty - build-nix - build-macos - - build-macos-matrix + - build-macos-freetype - build-snap - build-windows - test @@ -39,7 +44,7 @@ jobs: - test-debian-13 - valgrind - zig-fmt - - flatpak + steps: - id: status name: Determine status @@ -69,17 +74,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -94,7 +99,16 @@ jobs: strategy: fail-fast: false matrix: - dir: [c-vt, zig-vt] + dir: + [ + c-vt, + c-vt-key-encode, + c-vt-paste, + c-vt-sgr, + zig-formatter, + zig-vt, + zig-vt-stream, + ] name: Example ${{ matrix.dir }} runs-on: namespace-profile-ghostty-sm needs: test @@ -103,17 +117,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -136,17 +150,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -170,17 +184,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -204,6 +218,7 @@ jobs: aarch64-linux, x86_64-linux, x86_64-windows, + wasm32-freestanding, ] runs-on: namespace-profile-ghostty-sm needs: test @@ -212,17 +227,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -248,17 +263,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -277,17 +292,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -310,17 +325,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -356,17 +371,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -382,7 +397,7 @@ jobs: - name: Upload artifact id: upload-artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: source-tarball path: |- @@ -394,7 +409,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: | @@ -406,12 +421,30 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + trigger-flatpak: + if: github.event_name != 'pull_request' + runs-on: namespace-profile-ghostty-xsm + needs: [build-dist, build-flatpak] + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Trigger Flatpak workflow + run: | + gh workflow run \ + flatpak.yml \ + --ref ${{ github.ref_name || 'main' }} \ + --field source-run-id=${{ github.run_id }} \ + --field source-artifact-id=${{ needs.build-dist.outputs.artifact-id }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + build-macos: runs-on: namespace-profile-ghostty-macos-tahoe 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 @@ -449,12 +482,12 @@ jobs: cd macos xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" - build-macos-matrix: + build-macos-freetype: runs-on: namespace-profile-ghostty-macos-tahoe 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 @@ -478,18 +511,10 @@ jobs: - name: Test All run: | nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=freetype - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_freetype - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_harfbuzz - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_noshape - name: Build All run: | nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=freetype - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_freetype - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_harfbuzz - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_noshape build-windows: runs-on: windows-2022 @@ -499,7 +524,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. @@ -508,9 +533,9 @@ jobs: - name: Install zig shell: pwsh run: | - # Get the zig version from build.zig so that it only needs to be updated - $fileContent = Get-Content -Path "build.zig" -Raw - $pattern = 'buildpkg\.requireZig\("(.*?)"\);' + # Get the zig version from build.zig.zon so that it only needs to be updated + $fileContent = Get-Content -Path "build.zig.zon" -Raw + $pattern = 'minimum_zig_version\s*=\s*"([^"]+)"' $zigVersion = [regex]::Match($fileContent, $pattern).Groups[1].Value $version = "zig-x86_64-windows-$zigVersion" Write-Output $version @@ -563,22 +588,29 @@ jobs: test: if: github.repository == 'ghostty-org/ghostty' runs-on: namespace-profile-ghostty-md + outputs: + zig_version: ${{ steps.zig.outputs.version }} env: ZIG_LOCAL_CACHE_DIR: /zig/local-cache 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 + run: | + echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -610,17 +642,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -658,17 +690,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -693,17 +725,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -720,7 +752,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 @@ -757,17 +789,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -787,14 +819,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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -815,14 +847,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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -842,14 +874,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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -869,14 +901,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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -896,14 +928,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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -923,14 +955,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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -957,14 +989,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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -984,14 +1016,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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1018,17 +1050,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1050,10 +1082,10 @@ jobs: uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10 - name: Configure Namespace powered Buildx - uses: namespacelabs/nscloud-setup-buildx-action@01628ae51ea5d6b0c90109c7dccbf511953aff29 # v0.0.18 + uses: namespacelabs/nscloud-setup-buildx-action@91c2e6537780e3b092cb8476406be99a8f91bd5e # v0.0.20 - name: Download Source Tarball Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: source-tarball @@ -1070,32 +1102,6 @@ jobs: build-args: | DISTRO_VERSION=13 - flatpak: - if: github.repository == 'ghostty-org/ghostty' - name: "Flatpak" - container: - image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47 - options: --privileged - strategy: - fail-fast: false - matrix: - variant: - - arch: x86_64 - runner: namespace-profile-ghostty-md - - arch: aarch64 - runner: namespace-profile-ghostty-md-arm64 - runs-on: ${{ matrix.variant.runner }} - needs: test - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - uses: flatpak/flatpak-github-actions/flatpak-builder@10a3c29f0162516f0f68006be14c92f34bd4fa6c # v6.5 - with: - bundle: com.mitchellh.ghostty - manifest-path: flatpak/com.mitchellh.ghostty.yml - cache-key: flatpak-builder-${{ github.sha }} - arch: ${{ matrix.variant.arch }} - verbose: true - valgrind: if: github.repository == 'ghostty-org/ghostty' runs-on: namespace-profile-ghostty-lg @@ -1106,17 +1112,17 @@ 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@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1133,57 +1139,62 @@ jobs: run: | nix develop -c zig build test-valgrind - build-freebsd: - name: Build on FreeBSD - needs: test - runs-on: namespace-profile-mitchellh-sm-systemd - if: false # FIXME: FreeBSD does not yet ship with Zig 0.15 - strategy: - matrix: - release: - - "14.3" - # - "15.0" # disable until fixed: https://github.com/vmactions/freebsd-vm/issues/108 - steps: - - name: Checkout Ghostty - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - - name: Start SSH - run: | - sudo systemctl start ssh - - - name: Set up FreeBSD VM - uses: vmactions/freebsd-vm@487ce35b96fae3e60d45b521735f5aa436ecfade # v1.2.4 - with: - release: ${{ matrix.release }} - copyback: false - usesh: true - prepare: | - pkg install -y \ - devel/blueprint-compiler \ - devel/gettext \ - devel/git \ - devel/pkgconf \ - graphics/wayland \ - lang/zig \ - security/ca_root_nss \ - textproc/hs-pandoc \ - x11-fonts/jetbrains-mono \ - x11-toolkits/libadwaita \ - x11-toolkits/gtk40 \ - x11-toolkits/gtk4-layer-shell - - run: | - zig env - - - name: Run tests - shell: freebsd {0} - run: | - cd $GITHUB_WORKSPACE - zig build test - - - name: Build GTK app runtime - shell: freebsd {0} - run: | - cd $GITHUB_WORKSPACE - zig build - ./zig-out/bin/ghostty +version + # build-freebsd: + # name: Build on FreeBSD + # needs: test + # runs-on: namespace-profile-mitchellh-sm-systemd + # strategy: + # matrix: + # release: + # - "14.3" + # - "15.0" + # timeout-minutes: 10 + # steps: + # - name: Checkout Ghostty + # uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + # + # - name: Start SSH + # run: | + # sudo systemctl start ssh + # + # - name: Set up FreeBSD VM + # uses: vmactions/freebsd-vm@487ce35b96fae3e60d45b521735f5aa436ecfade # v1.2.4 + # with: + # release: ${{ matrix.release }} + # copyback: false + # usesh: true + # prepare: | + # pkg install -y \ + # devel/blueprint-compiler \ + # devel/gettext \ + # devel/git \ + # devel/pkgconf \ + # ftp/curl \ + # graphics/wayland \ + # security/ca_root_nss \ + # textproc/hs-pandoc \ + # x11-fonts/jetbrains-mono \ + # x11-toolkits/libadwaita \ + # x11-toolkits/gtk40 \ + # x11-toolkits/gtk4-layer-shell + # curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/${{ needs.test.outputs.zig_version }}/zig-x86_64-freebsd-${{ needs.test.outputs.zig_version }}.tar.xz" && \ + # mkdir /opt && \ + # tar -xf /tmp/zig.tar.xz -C /opt && \ + # rm /tmp/zig.tar.xz && \ + # ln -s "/opt/zig-x86_64-freebsd-${{ needs.test.outputs.zig_version }}/zig" /usr/local/bin/zig + # + # run: | + # zig env + # + # - name: Run tests + # shell: freebsd {0} + # run: | + # cd $GITHUB_WORKSPACE + # zig build test + # + # - name: Build GTK app runtime + # shell: freebsd {0} + # run: | + # cd $GITHUB_WORKSPACE + # zig build + # ./zig-out/bin/ghostty +version diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 4e9db4225..4ca4d2901 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -17,19 +17,19 @@ 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 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -37,16 +37,33 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Run zig fetch - id: zig_fetch + - name: Download colorschemes + id: download env: GH_TOKEN: ${{ github.token }} run: | # Get the latest release from iTerm2-Color-Schemes RELEASE_INFO=$(gh api repos/mbadolato/iTerm2-Color-Schemes/releases/latest) TAG_NAME=$(echo "$RELEASE_INFO" | jq -r '.tag_name') - nix develop -c zig fetch --save="iterm2_themes" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz" + FILENAME="ghostty-themes-${TAG_NAME}.tgz" + mkdir -p upload + curl -L -o "upload/${FILENAME}" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz" echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT + echo "filename=$FILENAME" >> $GITHUB_OUTPUT + + - name: Upload to R2 + uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4 + with: + r2-account-id: ${{ secrets.CF_R2_DEPS_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.CF_R2_DEPS_AWS_KEY }} + r2-secret-access-key: ${{ secrets.CF_R2_DEPS_SECRET_KEY }} + r2-bucket: ghostty-deps + source-dir: upload + destination-dir: ./ + + - name: Run zig fetch + run: | + nix develop -c zig fetch --save="iterm2_themes" "https://deps.files.ghostty.org/${{ steps.download.outputs.filename }}" - name: Update zig cache hash run: | @@ -62,7 +79,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@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: title: Update iTerm2 colorschemes base: main @@ -75,5 +92,5 @@ jobs: build.zig.zon.json flatpak/zig-packages.json body: | - Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.zig_fetch.outputs.tag_name }} + Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.download.outputs.tag_name }} labels: dependencies diff --git a/.gitignore b/.gitignore index e451b171a..e521f8851 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ zig-cache/ .zig-cache/ zig-out/ /result* +/.nixos-test-history example/*.wasm test/ghostty test/cases/**/*.actual.png diff --git a/AGENTS.md b/AGENTS.md index 2e90fd94e..dc2b47a70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,11 +13,22 @@ A file for [guiding coding agents](https://agents.md/). ## Directory Structure - Shared Zig core: `src/` -- C API: `include/ghostty.h` +- C API: `include` - macOS app: `macos/` - GTK (Linux and FreeBSD) app: `src/apprt/gtk` +## libghostty-vt + +- Build: `zig build lib-vt` +- Build Wasm Module: `zig build lib-vt -Dtarget=wasm32-freestanding` +- Test: `zig build test-lib-vt` +- Test filter: `zig build test-lib-vt -Dtest-filter=` +- When working on libghostty-vt, do not build the full app. +- For C only changes, don't run the Zig tests. Build all the examples. + ## macOS App - Do not use `xcodebuild` - Use `zig build` to build the macOS app and any shared Zig code +- Use `zig build run` to build and run the macOS app +- Run Xcode tests using `zig build test` diff --git a/CODEOWNERS b/CODEOWNERS index 9e854b06c..8a4f797d8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -184,6 +184,7 @@ /po/ko_KR.UTF-8.po @ghostty-org/ko_KR /po/he_IL.UTF-8.po @ghostty-org/he_IL /po/it_IT.UTF-8.po @ghostty-org/it_IT +/po/lt_LT.UTF-8.po @ghostty-org/lt_LT /po/zh_TW.UTF-8.po @ghostty-org/zh_TW /po/hr_HR.UTF-8.po @ghostty-org/hr_HR diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de7df4b71..cbb6927d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,15 +17,62 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well. > [!IMPORTANT] > -> If you are using **any kind of AI assistance** to contribute to Ghostty, -> it must be disclosed in the pull request. +> The Ghostty project allows AI-**assisted** _code contributions_, which +> must be properly disclosed in the pull request. If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -If PR responses are being generated by an AI, disclose that as well. -As a small exception, trivial tab-completion doesn't need to be disclosed, -so long as it is limited to single keywords or short phrases. + +The submitter must have also tested the pull request on all impacted +platforms, and it's **highly discouraged** to code for an unfamiliar platform +with AI assistance alone: if you only have a macOS machine, do **not** ask AI +to write the equivalent GTK code, and vice versa — someone else with more +expertise will eventually get to it and do it for you. + +> [!WARNING] +> **Note that AI _assistance_ does not equal AI _generation_**. We require +> a significant amount of human accountability, involvement and interaction +> even within AI-assisted contributions. Contributors are required to be able +> to understand the AI-assisted output, reason with it and answer critical +> questions about it. Should a PR see no visible human accountability and +> involvement, or it is so broken that it requires significant rework to be +> acceptable, **we reserve the right to close it without hesitation**. + +**In addition, we currently restrict AI assistance to code changes only.** +No AI-generated media, e.g. artwork, icons, videos and other assets is +allowed, as it goes against the methodology and ethos behind Ghostty. +While AI-assisted code can help with productive prototyping, creative +inspiration and even automated bugfinding, we have currently found zero +benefit to AI-generated assets. Instead, we are far more interested and +invested in funding professional work done by human designers and artists. +If you intend to submit AI-generated assets to Ghostty, sorry, +we are not interested. + +Likewise, all community interactions, including all comments on issues and +discussions and all PR titles and descriptions **must be composed by a human**. +Community moderators and Ghostty maintainers reserve the right to mark +AI-generated responses as spam or disruptive content, and ban users who have +been repeatedly caught relying entirely on LLMs during interactions. + +> [!NOTE] +> If your English isn't the best and you are currently relying on an LLM to +> translate your responses, don't fret — usually we maintainers will be able +> to understand your messages well enough. We'd like to encourage real humans +> to interact with each other more, and the positive impact of genuine, +> responsive yet imperfect human interaction more than makes up for any +> language barrier. +> +> Please write your responses yourself, to the best of your ability. +> If you do feel the need to polish your sentences, however, please use +> dedicated translation software rather than an LLM. +> +> We greatly appreciate it. Thank you. ❤️ + +Minor exceptions to this policy include trivial AI-generated tab completion +functionality, as it usually does not impact the quality of the code and +do not need to be disclosed, and commit titles and messages, which are often +generated by AI coding agents. An example disclosure: @@ -36,6 +83,11 @@ Or a more detailed disclosure: > I consulted ChatGPT to understand the codebase but the solution > was fully authored manually by myself. +An example of a **problematic** disclosure (not having tested all platforms): + +> I used Amp to code both macOS and GTK UIs, but I have not yet tested +> the GTK UI as I don't have a Linux setup. + Failure to disclose this is first and foremost rude to the human operators on the other end of the pull request, but it also makes it difficult to determine how much scrutiny to apply to the contribution. @@ -45,11 +97,6 @@ work than any human. That isn't the world we live in today, and in most cases it's generating slop. I say this despite being a fan of and using them successfully myself (with heavy supervision)! -When using AI assistance, we expect contributors to understand the code -that is produced and be able to answer critical questions about it. It -isn't a maintainers job to review a PR so broken that it requires -significant rework to be acceptable. - Please be respectful to maintainers and disclose AI assistance. ## Quick Guide @@ -74,22 +121,47 @@ submission. ### I have a bug! / Something isn't working -1. Search the issue tracker and discussions for similar issues. Tip: also - search for [closed issues] and [discussions] — your issue might have already - been fixed! -2. If your issue hasn't been reported already, open an ["Issue Triage" discussion] - and make sure to fill in the template **completely**. They are vital for - maintainers to figure out important details about your setup. Because of - this, please make sure that you _only_ use the "Issue Triage" category for - reporting bugs — thank you! +First, search the issue tracker and discussions for similar issues. Tip: also +search for [closed issues] and [discussions] — your issue might have already +been fixed! + +> [!NOTE] +> +> If there is an _open_ issue or discussion that matches your problem, +> **please do not comment on it unless you have valuable insight to add**. +> +> GitHub has a very _noisy_ set of default notification settings which +> sends an email to _every participant_ in an issue/discussion every time +> someone adds a comment. Instead, use the handy upvote button for discussions, +> and/or emoji reactions on both discussions and issues, which are a visible +> yet non-disruptive way to show your support. + +If your issue hasn't been reported already, open an ["Issue Triage"] discussion +and make sure to fill in the template **completely**. They are vital for +maintainers to figure out important details about your setup. + +> [!WARNING] +> +> A _very_ common mistake is to file a bug report either as a Q&A or a Feature +> Request. **Please don't do this.** Otherwise, maintainers would have to ask +> for your system information again manually, and sometimes they will even ask +> you to create a new discussion because of how few detailed information is +> required for other discussion types compared to Issue Triage. +> +> Because of this, please make sure that you _only_ use the "Issue Triage" +> category for reporting bugs — thank you! [closed issues]: https://github.com/ghostty-org/ghostty/issues?q=is%3Aissue%20state%3Aclosed [discussions]: https://github.com/ghostty-org/ghostty/discussions?discussions_q=is%3Aclosed -["Issue Triage" discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage +["Issue Triage"]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage ### I have an idea for a feature -Open a discussion in the ["Feature Requests, Ideas" category](https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas). +Like bug reports, first search through both issues and discussions and try to +find if your feature has already been requested. Otherwise, open a discussion +in the ["Feature Requests, Ideas"] category. + +["Feature Requests, Ideas"]: https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas ### I've implemented a feature @@ -98,10 +170,28 @@ Open a discussion in the ["Feature Requests, Ideas" category](https://github.com 3. If you want to live dangerously, open a pull request and [hope for the best](#pull-requests-implement-an-issue). -### I have a question +### I have a question which is neither a bug report nor a feature request Open an [Q&A discussion], or join our [Discord Server] and ask away in the -`#help` channel. +`#help` forum channel. + +Do not use the `#terminals` or `#development` channels to ask for help — +those are for general discussion about terminals and Ghostty development +respectively. If you do ask a question there, you will be redirected to +`#help` instead. + +> [!NOTE] +> If your question is about a missing feature, please open a discussion under +> the ["Feature Requests, Ideas"] category. If Ghostty is behaving +> unexpectedly, use the ["Issue Triage"] category. +> +> The "Q&A" category is strictly for other kinds of discussions and do not +> require detailed information unlike the two other categories, meaning that +> maintainers would have to spend the extra effort to ask for basic information +> if you submit a bug report under this category. +> +> Therefore, please **pay attention to the category** before opening +> discussions to save us all some time and energy. Thank you! [Q&A discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=q-a [Discord Server]: https://discord.gg/ghostty @@ -142,3 +232,266 @@ pull request will be accepted with a high degree of certainty. > **Pull requests are NOT a place to discuss feature design.** Please do > not open a WIP pull request to discuss a feature. Instead, use a discussion > and link to your branch. + +# Developer Guide + +> [!NOTE] +> +> **The remainder of this file is dedicated to developers actively +> working on Ghostty.** If you're a user reporting an issue, you can +> ignore the rest of this document. + +## Including and Updating Translations + +See the [Contributor's Guide](po/README_CONTRIBUTORS.md) for more details. + +## Checking for Memory Leaks + +While Zig does an amazing job of finding and preventing memory leaks, +Ghostty uses many third-party libraries that are written in C. Improper usage +of those libraries or bugs in those libraries can cause memory leaks that +Zig cannot detect by itself. + +### On Linux + +On Linux the recommended tool to check for memory leaks is Valgrind. The +recommended way to run Valgrind is via `zig build`: + +```sh +zig build run-valgrind +``` + +This builds a Ghostty executable with Valgrind support and runs Valgrind +with the proper flags to ensure we're suppressing known false positives. + +You can combine the same build args with `run-valgrind` that you can with +`run`, such as specifying additional configurations after a trailing `--`. + +## Input Stack Testing + +The input stack is the part of the codebase that starts with a +key event and ends with text encoding being sent to the pty (it +does not include _rendering_ the text, which is part of the +font or rendering stack). + +If you modify any part of the input stack, you must manually verify +all the following input cases work properly. We unfortunately do +not automate this in any way, but if we can do that one day that'd +save a LOT of grief and time. + +Note: this list may not be exhaustive, I'm still working on it. + +### Linux IME + +IME (Input Method Editors) are a common source of bugs in the input stack, +especially on Linux since there are multiple different IME systems +interacting with different windowing systems and application frameworks +all written by different organizations. + +The following matrix should be tested to ensure that all IME input works +properly: + +1. Wayland, X11 +2. ibus, fcitx, none +3. Dead key input (e.g. Spanish), CJK (e.g. Japanese), Emoji, Unicode Hex +4. ibus versions: 1.5.29, 1.5.30, 1.5.31 (each exhibit slightly different behaviors) + +> [!NOTE] +> +> This is a **work in progress**. I'm still working on this list and it +> is not complete. As I find more test cases, I will add them here. + +#### Dead Key Input + +Set your keyboard layout to "Spanish" (or another layout that uses dead keys). + +1. Launch Ghostty +2. Press `'` +3. Press `a` +4. Verify that `á` is displayed + +Note that the dead key may or may not show a preedit state visually. +For ibus and fcitx it does but for the "none" case it does not. Importantly, +the text should be correct when it is sent to the pty. + +We should also test canceling dead key input: + +1. Launch Ghostty +2. Press `'` +3. Press escape +4. Press `a` +5. Verify that `a` is displayed (no diacritic) + +#### CJK Input + +Configure fcitx or ibus with a keyboard layout like Japanese or Mozc. The +exact layout doesn't matter. + +1. Launch Ghostty +2. Press `Ctrl+Shift` to switch to "Hiragana" +3. On a US physical layout, type: `konn`, you should see `こん` in preedit. +4. Press `Enter` +5. Verify that `こん` is displayed in the terminal. + +We should also test switching input methods while preedit is active, which +should commit the text: + +1. Launch Ghostty +2. Press `Ctrl+Shift` to switch to "Hiragana" +3. On a US physical layout, type: `konn`, you should see `こん` in preedit. +4. Press `Ctrl+Shift` to switch to another layout (any) +5. Verify that `こん` is displayed in the terminal as committed text. + +## Nix Virtual Machines + +Several Nix virtual machine definitions are provided by the project for testing +and developing Ghostty against multiple different Linux desktop environments. + +Running these requires a working Nix installation, either Nix on your +favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further +requirements for macOS are detailed below. + +VMs should only be run on your local desktop and then powered off when not in +use, which will discard any changes to the VM. + +The VM definitions provide minimal software "out of the box" but additional +software can be installed by using standard Nix mechanisms like `nix run nixpkgs#`. + +### Linux + +1. Check out the Ghostty source and change to the directory. +2. Run `nix run .#`. `` can be any of the VMs defined in the + `nix/vm` directory (without the `.nix` suffix) excluding any file prefixed + with `common` or `create`. +3. The VM will build and then launch. Depending on the speed of your system, this + can take a while, but eventually you should get a new VM window. +4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending + on what UID and GID of the user that you launched the VM as, `/tmp/shared` _may_ be + writable by the VM user, so be careful! + +### macOS + +1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin` + config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your + configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/) + blog post for more information about the Linux builder and how to tune the performance. +2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions + above to launch a VM. + +### Custom VMs + +To easily create a custom VM without modifying the Ghostty source, create a new +directory, then create a file called `flake.nix` with the following text in the +new directory. + +``` +{ + inputs = { + nixpkgs.url = "nixpkgs/nixpkgs-unstable"; + ghostty.url = "github:ghostty-org/ghostty"; + }; + outputs = { + nixpkgs, + ghostty, + ... + }: { + nixosConfigurations.custom-vm = ghostty.create-gnome-vm { + nixpkgs = nixpkgs; + system = "x86_64-linux"; + overlay = ghostty.overlays.releasefast; + # module = ./configuration.nix # also works + module = {pkgs, ...}: { + environment.systemPackages = [ + pkgs.btop + ]; + }; + }; + }; +} +``` + +The custom VM can then be run with a command like this: + +``` +nix run .#nixosConfigurations.custom-vm.config.system.build.vm +``` + +A file named `ghostty.qcow2` will be created that is used to persist any changes +made in the VM. To "reset" the VM to default delete the file and it will be +recreated the next time you run the VM. + +### Contributing new VM definitions + +#### VM Acceptance Criteria + +We welcome the contribution of new VM definitions, as long as they meet the following criteria: + +1. They should be different enough from existing VM definitions that they represent a distinct + user (and developer) experience. +2. There's a significant Ghostty user population that uses a similar environment. +3. The VMs can be built using only packages from the current stable NixOS release. + +#### VM Definition Criteria + +1. VMs should be as minimal as possible so that they build and launch quickly. + Additional software can be added at runtime with a command like `nix run nixpkgs#`. +2. VMs should not expose any services to the network, or run any remote access + software like SSH daemons, VNC or RDP. +3. VMs should auto-login using the "ghostty" user. + +## Nix VM Integration Tests + +Several Nix VM tests are provided by the project for testing Ghostty in a "live" +environment rather than just unit tests. + +Running these requires a working Nix installation, either Nix on your +favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further +requirements for macOS are detailed below. + +### Linux + +1. Check out the Ghostty source and change to the directory. +2. Run `nix run .#check...driver`. `` should be + `x86_64-linux` or `aarch64-linux` (even on macOS, this launches a Linux + VM, not a macOS one). `` should be one of the tests defined in + `nix/tests.nix`. The test will build and then launch. Depending on the speed + of your system, this can take a while. Eventually though the test should + complete. Hopefully successfully, but if not error messages should be printed + out that can be used to diagnose the issue. +3. To run _all_ of the tests, run `nix flake check`. + +### macOS + +1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin` + config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your + configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/) + blog post for more information about the Linux builder and how to tune the performance. +2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions + above to launch a test. + +### Interactively Running Test VMs + +To run a test interactively, run `nix run +.#check...driverInteractive`. This will load a Python console +that can be used to manage the test VMs. In this console run `start_all()` to +start the VM(s). The VMs should boot up and a window should appear showing the +VM's console. + +For more information about the Nix test console, see [the NixOS manual](https://nixos.org/manual/nixos/stable/index.html#sec-call-nixos-test-outside-nixos) + +### SSH Access to Test VMs + +Some test VMs are configured to allow outside SSH access for debugging. To +access the VM, use a command like the following: + +``` +ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 root@192.168.122.1 +ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 ghostty@192.168.122.1 +``` + +The SSH options are important because the SSH host keys will be regenerated +every time the test is started. Without them, your personal SSH known hosts file +will become difficult to manage. The port that is needed to access the VM may +change depending on the test. + +None of the users in the VM have passwords so do not expose these VMs to the Internet. diff --git a/Doxyfile b/Doxyfile index fccd4a493..1703e6fac 100644 --- a/Doxyfile +++ b/Doxyfile @@ -2,9 +2,52 @@ DOXYFILE_ENCODING = UTF-8 PROJECT_NAME = "libghostty" -INPUT = include/ghostty/vt.h +PROJECT_LOGO = images/gnome/64.png +INPUT = include/ghostty INPUT_ENCODING = UTF-8 -RECURSIVE = NO +RECURSIVE = YES +FILE_PATTERNS = *.h +EXAMPLE_PATH = example +EXAMPLE_RECURSIVE = YES +EXAMPLE_PATTERNS = * +FULL_PATH_NAMES = NO +STRIP_FROM_INC_PATH = include +SOURCE_BROWSER = YES +INLINE_SOURCES = NO +REFERENCES_RELATION = YES +REFERENCED_BY_RELATION = YES + +#--------------------------------------------------------------------------- +# Preprocessor +#--------------------------------------------------------------------------- + +# Enable preprocessing to handle #ifdef guards +ENABLE_PREPROCESSING = YES +MACRO_EXPANSION = YES +EXPAND_ONLY_PREDEF = YES +PREDEFINED = __wasm__ + +#--------------------------------------------------------------------------- +# C API Optimization +#--------------------------------------------------------------------------- + +# Optimize output for C API documentation +OPTIMIZE_OUTPUT_FOR_C = YES +TYPEDEF_HIDES_STRUCT = YES +HIDE_SCOPE_NAMES = YES + +# Clean path names +FULL_PATH_NAMES = NO +STRIP_FROM_PATH = . +STRIP_FROM_INC_PATH = include + +# Hide undocumented and internal APIs +HIDE_UNDOC_MEMBERS = YES +HIDE_UNDOC_CLASSES = YES +EXTRACT_ALL = NO +INTERNAL_DOCS = NO +EXTRACT_PRIVATE = NO +EXTRACT_LOCAL_CLASSES = NO #--------------------------------------------------------------------------- # HTML Output @@ -12,6 +55,26 @@ RECURSIVE = NO GENERATE_HTML = YES HTML_OUTPUT = zig-out/share/ghostty/doc/libghostty +HTML_EXTRA_STYLESHEET = dist/doxygen/ghostty.css +HTML_EXTRA_FILES = dist/doxygen/favicon.png \ + dist/doxygen/mobile-nav.js +HTML_COLORSTYLE = DARK +HTML_CODE_FOLDING = NO +HTML_HEADER = dist/doxygen/header.html +LAYOUT_FILE = DoxygenLayout.xml +GENERATE_TREEVIEW = YES +HTML_DYNAMIC_SECTIONS = YES +SEARCHENGINE = YES +ALPHABETICAL_INDEX = YES +HTML_TIMESTAMP = NO +DISABLE_INDEX = NO +FULL_SIDEBAR = NO + +#--------------------------------------------------------------------------- +# Graphs and Diagrams +#--------------------------------------------------------------------------- + +HAVE_DOT = NO #--------------------------------------------------------------------------- # Man Output @@ -20,6 +83,7 @@ HTML_OUTPUT = zig-out/share/ghostty/doc/libghostty GENERATE_MAN = YES MAN_OUTPUT = zig-out/share/man MAN_EXTENSION = .3 +MAN_LINKS = YES #--------------------------------------------------------------------------- # Other Output diff --git a/DoxygenLayout.xml b/DoxygenLayout.xml new file mode 100644 index 000000000..ae9c52684 --- /dev/null +++ b/DoxygenLayout.xml @@ -0,0 +1,247 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HACKING.md b/HACKING.md index 905a244e8..bde50ec99 100644 --- a/HACKING.md +++ b/HACKING.md @@ -50,24 +50,22 @@ macOS users don't require any additional dependencies. ## Xcode Version and SDKs Building the Ghostty macOS app requires that Xcode, the macOS SDK, -and the iOS SDK are all installed. +the iOS SDK, and Metal Toolchain are all installed. A common issue is that the incorrect version of Xcode is either installed or selected. Use the `xcode-select` command to ensure that the correct version of Xcode is selected: ```shell-session -sudo xcode-select --switch /Applications/Xcode-beta.app +sudo xcode-select --switch /Applications/Xcode.app ``` > [!IMPORTANT] > -> Main branch development of Ghostty is preparing for the next major -> macOS release, Tahoe (macOS 26). Therefore, the main branch requires -> **Xcode 26 and the macOS 26 SDK**. +> Main branch development of Ghostty requires **Xcode 26 and the macOS 26 SDK**. > > You do not need to be running on macOS 26 to build Ghostty, you can -> still use Xcode 26 beta on macOS 15 stable. +> still use Xcode 26 on macOS 15 stable. ## AI and Agents @@ -95,6 +93,36 @@ produced. > may ask you to fix it and close the issue. It isn't a maintainers job to > review a PR so broken that it requires significant rework to be acceptable. +## Logging + +Ghostty can write logs to a number of destinations. On all platforms, logging to +`stderr` is available. Depending on the platform and how Ghostty was launched, +logs sent to `stderr` may be stored by the system and made available for later +retrieval. + +On Linux if Ghostty is launched by the default `systemd` user service, you can use +`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`. + +On macOS logging to the macOS unified log is available and enabled by default. +Use the system `log` CLI to view Ghostty's logs: `sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`. + +Ghostty's logging can be configured in two ways. The first is by what +optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug` +optimizations debug logs will be output to `stderr`. If Ghostty is compiled with +any other optimization the debug logs will not be output to `stderr`. + +Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used +to control which destinations receive logs. Ghostty currently defines two +destinations: + +- `stderr` - logging to `stderr`. +- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. + ## Linting ### Prettier diff --git a/Makefile b/Makefile index ad8379f7e..c5511a62e 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ vendor/glad/include/glad/glad.h: vendor/glad/include/glad/gl.h clean: rm -rf \ - zig-out zig-cache \ + zig-out .zig-cache \ macos/build \ macos/GhosttyKit.xcframework .PHONY: clean diff --git a/README.md b/README.md index df86f7830..961968097 100644 --- a/README.md +++ b/README.md @@ -144,16 +144,23 @@ In addition to being a standalone terminal emulator, Ghostty is a C-compatible library for embedding a fast, feature-rich terminal emulator in any 3rd party project. This library is called `libghostty`. -This goal is not hypothetical! The macOS app is a `libghostty` consumer. +Due to the scope of this project, we're breaking libghostty down into +separate actually libraries, starting with `libghostty-vt`. The goal of +this project is to focus on parsing terminal sequences and maintaining +terminal state. This is covered in more detail in this +[blog post](https://mitchellh.com/writing/libghostty-is-coming). + +`libghostty-vt` is already available and usable today for Zig and C and +is compatible for macOS, Linux, Windows, and WebAssembly. At the time of +writing this, the API isn't stable yet and we haven't tagged an official +release, but the core logic is well proven (since Ghostty uses it) and +we're working hard on it now. + +The ultimate goal is not hypothetical! The macOS app is a `libghostty` consumer. The macOS app is a native Swift app developed in Xcode and `main()` is within Swift. The Swift app links to `libghostty` and uses the C API to render terminals. -This step encompasses expanding `libghostty` support to more platforms -and more use cases. At the time of writing this, `libghostty` is very -Mac-centric -- particularly around rendering -- and we have work to do to -expand this to other platforms. - ## Crash Reports Ghostty has a built-in crash reporter that will generate and save crash @@ -193,4 +200,4 @@ SENTRY_DSN=https://e914ee84fd895c4fe324afa3e53dac76@o4507352570920960.ingest.us. > purposely contain sensitive information, but it does contain the full > stack memory of each thread at the time of the crash. This information > is used to rebuild the stack trace but can also contain sensitive data -> depending when the crash occurred. +> depending on when the crash occurred. diff --git a/build.zig b/build.zig index 7b66af81a..fa68b91b4 100644 --- a/build.zig +++ b/build.zig @@ -3,19 +3,19 @@ const assert = std.debug.assert; const builtin = @import("builtin"); const buildpkg = @import("src/build/main.zig"); +const appVersion = @import("build.zig.zon").version; +const minimumZigVersion = @import("build.zig.zon").minimum_zig_version; + comptime { - buildpkg.requireZig("0.15.1"); + buildpkg.requireZig(minimumZigVersion); } pub fn build(b: *std.Build) !void { - // Works around a Zig but still present in 0.15.1. Remove when fixed. - // https://github.com/ghostty-org/ghostty/issues/8924 - try limitCoresForZigBug(); - // This defines all the available build options (e.g. `-D`). If you // want to know what options are available, you can run `--help` or // you can read `src/build/Config.zig`. - const config = try buildpkg.Config.init(b); + + const config = try buildpkg.Config.init(b, appVersion); const test_filters = b.option( [][]const u8, "test-filter", @@ -56,7 +56,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. @@ -102,10 +102,19 @@ pub fn build(b: *std.Build) !void { ); // libghostty-vt - const libghostty_vt_shared = try buildpkg.GhosttyLibVt.initShared( - b, - &mod, - ); + const libghostty_vt_shared = shared: { + if (config.target.result.cpu.arch.isWasm()) { + break :shared try buildpkg.GhosttyLibVt.initWasm( + b, + &mod, + ); + } + + break :shared try buildpkg.GhosttyLibVt.initShared( + b, + &mod, + ); + }; libghostty_vt_shared.install(libvt_step); libghostty_vt_shared.install(b.getInstallStep()); @@ -309,13 +318,3 @@ pub fn build(b: *std.Build) !void { try translations_step.addError("cannot update translations when i18n is disabled", .{}); } } - -// WARNING: Remove this when https://github.com/ghostty-org/ghostty/issues/8924 is resolved! -// Limit ourselves to 32 cpus on Linux because of an upstream Zig bug. -fn limitCoresForZigBug() !void { - if (comptime builtin.os.tag != .linux) return; - const pid = std.os.linux.getpid(); - var set: std.bit_set.ArrayBitSet(usize, std.os.linux.CPU_SETSIZE * 8) = .initEmpty(); - for (0..32) |cpu| set.set(cpu); - try std.os.linux.sched_setaffinity(pid, &set.masks); -} diff --git a/build.zig.zon b/build.zig.zon index e76c5e354..271428778 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,9 +1,9 @@ .{ .name = .ghostty, - .version = "1.2.1", + .version = "1.3.0-dev", .paths = .{""}, .fingerprint = 0x64407a2a0b4147e5, - .minimum_zig_version = "0.15.1", + .minimum_zig_version = "0.15.2", .dependencies = .{ // Zig libs @@ -15,14 +15,14 @@ }, .vaxis = .{ // rockorager/libvaxis - .url = "https://deps.files.ghostty.org/vaxis-9fc9015d5f147568e18c5e7ca28f15bf8b293760.tar.gz", - .hash = "vaxis-0.5.1-BWNV_O8fCQAeUeVrESVc-2BdXloEXkFqReDJL7Q6XTSZ", + .url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", .lazy = true, }, .z2d = .{ // vancluever/z2d - .url = "https://deps.files.ghostty.org/z2d-a1237f6881d99b75abd8a20a934e62e34b44a005.tar.gz", - .hash = "z2d-0.8.2-pre-j5P_HlVRFgCsBTQ3EgUoKbYHx5JMnyH1mHsOSPiafnef", + .url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", + .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", .lazy = true, }, .zig_objc = .{ @@ -38,9 +38,9 @@ .lazy = true, }, .uucode = .{ - // TODO: currently the use-llvm branch because its broken on self-hosted - .url = "https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz", - .hash = "uucode-0.1.0-ZZjBPjQHQADuCy1VMWftjrMl3iWqgMpUugWVQJG6_7xT", + // jacobsandlund/uucode + .url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", }, .zig_wayland = .{ // codeberg ifreund/zig-wayland @@ -50,15 +50,15 @@ }, .zf = .{ // natecraddock/zf - .url = "https://deps.files.ghostty.org/zf-52ad2e5528ab754f77437edf08a07b5ec843661c.tar.gz", - .hash = "zf-0.10.3-OIRy8QGJAACJcu3tCGtfbJnnd3Y4QL7OW_X8PJ8u_ASR", + .url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", .lazy = true, }, .gobject = .{ - // https://github.com/jcollie/ghostty-gobject based on zig_gobject + // https://github.com/ghostty-org/zig-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst", - .hash = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV", + .url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", + .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", .lazy = true, }, @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/iterm2_themes-release-20250922-150534-d28055b.tgz", - .hash = "N-V-__8AACEnAwCkyrJ_XqdomYlR8bpuh0l8WDidEz-BAzIv", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", .lazy = true, }, }, diff --git a/build.zig.zon.bak b/build.zig.zon.bak new file mode 100644 index 000000000..191ae7fa9 --- /dev/null +++ b/build.zig.zon.bak @@ -0,0 +1,124 @@ +.{ + .name = .ghostty, + .version = "1.3.0-dev", + .paths = .{""}, + .fingerprint = 0x64407a2a0b4147e5, + .minimum_zig_version = "0.15.2", + .dependencies = .{ + // Zig libs + + .libxev = .{ + // mitchellh/libxev + .url = "https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz", + .hash = "libxev-0.0.0-86vtc4IcEwCqEYxEYoN_3KXmc6A9VLcm22aVImfvecYs", + .lazy = true, + }, + .vaxis = .{ + // rockorager/libvaxis + .url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", + .lazy = true, + }, + .z2d = .{ + // vancluever/z2d + .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", + .lazy = true, + }, + .zig_objc = .{ + // mitchellh/zig-objc + .url = "https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz", + .hash = "zig_objc-0.0.0-Ir_Sp5gTAQCvxxR7oVIrPXxXwsfKgVP7_wqoOQrZjFeK", + .lazy = true, + }, + .zig_js = .{ + // mitchellh/zig-js + .url = "https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz", + .hash = "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi", + .lazy = true, + }, + .uucode = .{ + // jacobsandlund/uucode + .url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", + }, + .zig_wayland = .{ + // codeberg ifreund/zig-wayland + .url = "https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz", + .hash = "wayland-0.5.0-dev-lQa1khrMAQDJDwYFKpdH3HizherB7sHo5dKMECfvxQHe", + .lazy = true, + }, + .zf = .{ + // natecraddock/zf + .url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", + .lazy = true, + }, + .gobject = .{ + // https://github.com/ghostty-org/zig-gobject based on zig_gobject + // Temporary until we generate them at build time automatically. + .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", + .lazy = true, + }, + + // C libs + .cimgui = .{ .path = "./pkg/cimgui", .lazy = true }, + .fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true }, + .freetype = .{ .path = "./pkg/freetype", .lazy = true }, + .gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true }, + .harfbuzz = .{ .path = "./pkg/harfbuzz", .lazy = true }, + .highway = .{ .path = "./pkg/highway", .lazy = true }, + .libintl = .{ .path = "./pkg/libintl", .lazy = true }, + .libpng = .{ .path = "./pkg/libpng", .lazy = true }, + .macos = .{ .path = "./pkg/macos", .lazy = true }, + .oniguruma = .{ .path = "./pkg/oniguruma", .lazy = true }, + .opengl = .{ .path = "./pkg/opengl", .lazy = true }, + .sentry = .{ .path = "./pkg/sentry", .lazy = true }, + .simdutf = .{ .path = "./pkg/simdutf", .lazy = true }, + .utfcpp = .{ .path = "./pkg/utfcpp", .lazy = true }, + .wuffs = .{ .path = "./pkg/wuffs", .lazy = true }, + .zlib = .{ .path = "./pkg/zlib", .lazy = true }, + + // Shader translation + .glslang = .{ .path = "./pkg/glslang", .lazy = true }, + .spirv_cross = .{ .path = "./pkg/spirv-cross", .lazy = true }, + + // Wayland + .wayland = .{ + .url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz", + .hash = "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t", + .lazy = true, + }, + .wayland_protocols = .{ + .url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz", + .hash = "N-V-__8AAKw-DAAaV8bOAAGqA0-oD7o-HNIlPFYKRXSPT03S", + .lazy = true, + }, + .plasma_wayland_protocols = .{ + .url = "https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566.tar.gz", + .hash = "N-V-__8AAKYZBAB-CFHBKs3u4JkeiT4BMvyHu3Y5aaWF3Bbs", + .lazy = true, + }, + + // Fonts + .jetbrains_mono = .{ + .url = "https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz", + .hash = "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x", + .lazy = true, + }, + .nerd_fonts_symbols_only = .{ + .url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz", + .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s", + .lazy = true, + }, + + // Other + .apple_sdk = .{ .path = "./pkg/apple-sdk" }, + .iterm2_themes = .{ + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", + .lazy = true, + }, + }, +} diff --git a/build.zig.zon.json b/build.zig.zon.json index ee374e695..c9a64ca5f 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -24,10 +24,10 @@ "url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz", "hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U=" }, - "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV": { + "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-": { "name": "gobject", - "url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst", - "hash": "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo=" + "url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", + "hash": "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { "name": "gtk4_layer_shell", @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AACEnAwCkyrJ_XqdomYlR8bpuh0l8WDidEz-BAzIv": { + "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/iterm2_themes-release-20250922-150534-d28055b.tgz", - "hash": "sha256-mdhUxAAqKxRRXwED2laabUo9ZZqZa/MZAsO0+Y9L7uQ=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", @@ -109,20 +109,20 @@ "url": "https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz", "hash": "sha256-/8ZooxDndgfTk/PBizJxXyI9oerExNbgV5oR345rWc8=" }, - "uucode-0.1.0-ZZjBPjQHQADuCy1VMWftjrMl3iWqgMpUugWVQJG6_7xT": { + "uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM": { "name": "uucode", - "url": "https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz", - "hash": "sha256-VomSYOF8fRJwb/8GtVG/QqR6c95zSkQt4649C/4KXAc=" + "url": "git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732", + "hash": "sha256-sHPh+TQSdUGus/QTbj7KSJJkTuNTrK4VNmQDjS30Lf8=" }, - "vaxis-0.5.1-BWNV_O8fCQAeUeVrESVc-2BdXloEXkFqReDJL7Q6XTSZ": { - "name": "vaxis", - "url": "https://deps.files.ghostty.org/vaxis-9fc9015d5f147568e18c5e7ca28f15bf8b293760.tar.gz", - "hash": "sha256-7H5a0J7uUsrzlO7JNAf/Ussi9WxvmsbyJSmhqvl+rqI=" + "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E": { + "name": "uucode", + "url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "hash": "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4=" }, - "vaxis-0.5.1-BWNV_H0PCQAeMusmtLzh9P9xO2IW242GZ2IRe9iKYhcA": { + "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { "name": "vaxis", - "url": "https://github.com/rockorager/libvaxis/archive/1bf887aa7e3736bad69fd4e277a378946edb0f2a.tar.gz", - "hash": "sha256-eq5YC26OY0i2cdQJ0ZXMZ+o2vHQLEFNNGzQt5Zuz4BM=" + "url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + "hash": "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY=" }, "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t": { "name": "wayland", @@ -139,25 +139,15 @@ "url": "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz", "hash": "sha256-nkzSCr6W5sTG7enDBXEIhgEm574uLD41UVR2wlC+HBM=" }, - "z2d-0.8.2-pre-j5P_HlVRFgCsBTQ3EgUoKbYHx5JMnyH1mHsOSPiafnef": { + "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T": { "name": "z2d", - "url": "https://deps.files.ghostty.org/z2d-a1237f6881d99b75abd8a20a934e62e34b44a005.tar.gz", - "hash": "sha256-5/qRZAIh1U42v7jql9W0jr2zzQZtu39DxJPLVrSybJg=" + "url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", + "hash": "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA=" }, - "zf-0.10.3-OIRy8QGJAACJcu3tCGtfbJnnd3Y4QL7OW_X8PJ8u_ASR": { + "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": { "name": "zf", - "url": "https://deps.files.ghostty.org/zf-52ad2e5528ab754f77437edf08a07b5ec843661c.tar.gz", - "hash": "sha256-8BinbanSfZeBA8SBAopVxwJObN36/BTpxVHABKicsMQ=" - }, - "zg-0.14.1-oGqU3J4_tAKBfyes3AWleKDjo-IcYvnEwaB8qxOqFMwM": { - "name": "zg", - "url": "git+https://codeberg.org/ivanstepanovftw/zg#4fe689e56ce2ed5a8f59308b471bccd7da89fac9", - "hash": "sha256-P0ieLuOQ05wKVaMmeNKJIxCWMIdyeKkmhsj8Ps80BGU=" - }, - "zg-0.15.1-oGqU3M0-tALZCy7boQS86znlBloyKx6--JriGlY0Paa9": { - "name": "zg", - "url": "https://codeberg.org/chaten/zg/archive/749197a3f9d25e211615960c02380a3d659b20f9.tar.gz", - "hash": "sha256-BZhz1nPqxK6hdsJQ66n7Jk4zMgFSGLXm8eU0CX/7mDI=" + "url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + "hash": "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg=" }, "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi": { "name": "zig_js", @@ -174,11 +164,6 @@ "url": "https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz", "hash": "sha256-TxRrc17Q1Sf1IOO/cdPpP3LD0PpYOujt06SFH3B5Ek4=" }, - "zigimg-0.1.0-8_eo2mWmEgBoqdr0sH9O5GTqDHthkoEPM5_tipcBRreL": { - "name": "zigimg", - "url": "git+https://github.com/ivanstepanovftw/zigimg#aa4c31db872612c39edbb79f753b3cd9a79fe726", - "hash": "sha256-Ko5RuxxTAvpUHCnWEdHqNl7b+PVUAxg1/OPmzGGjdt0=" - }, "zigimg-0.1.0-8_eo2vHnEwCIVW34Q14Ec-xUlzIoVg86-7FU2ypPtxms": { "name": "zigimg", "url": "https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index e9d2fc0bc..43a8efe46 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -5,7 +5,7 @@ fetchurl, fetchgit, runCommandLocal, - zig_0_14, + zig_0_15, name ? "zig-packages", }: let unpackZigArtifact = { @@ -14,7 +14,7 @@ }: runCommandLocal name { - nativeBuildInputs = [zig_0_14]; + nativeBuildInputs = [zig_0_15]; } '' hash="$(zig fetch --global-cache-dir "$TMPDIR" ${artifact})" @@ -123,11 +123,11 @@ in }; } { - name = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV"; + name = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-"; path = fetchZigArtifact { name = "gobject"; - url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst"; - hash = "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo="; + url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst"; + hash = "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg="; }; } { @@ -163,11 +163,11 @@ in }; } { - name = "N-V-__8AACEnAwCkyrJ_XqdomYlR8bpuh0l8WDidEz-BAzIv"; + name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/iterm2_themes-release-20250922-150534-d28055b.tgz"; - hash = "sha256-mdhUxAAqKxRRXwED2laabUo9ZZqZa/MZAsO0+Y9L7uQ="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz"; + hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; }; } { @@ -259,27 +259,27 @@ in }; } { - name = "uucode-0.1.0-ZZjBPjQHQADuCy1VMWftjrMl3iWqgMpUugWVQJG6_7xT"; + name = "uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM"; path = fetchZigArtifact { name = "uucode"; - url = "https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz"; - hash = "sha256-VomSYOF8fRJwb/8GtVG/QqR6c95zSkQt4649C/4KXAc="; + url = "git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732"; + hash = "sha256-sHPh+TQSdUGus/QTbj7KSJJkTuNTrK4VNmQDjS30Lf8="; }; } { - name = "vaxis-0.5.1-BWNV_O8fCQAeUeVrESVc-2BdXloEXkFqReDJL7Q6XTSZ"; + name = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E"; path = fetchZigArtifact { - name = "vaxis"; - url = "https://deps.files.ghostty.org/vaxis-9fc9015d5f147568e18c5e7ca28f15bf8b293760.tar.gz"; - hash = "sha256-7H5a0J7uUsrzlO7JNAf/Ussi9WxvmsbyJSmhqvl+rqI="; + name = "uucode"; + url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz"; + hash = "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4="; }; } { - name = "vaxis-0.5.1-BWNV_H0PCQAeMusmtLzh9P9xO2IW242GZ2IRe9iKYhcA"; + name = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS"; path = fetchZigArtifact { name = "vaxis"; - url = "https://github.com/rockorager/libvaxis/archive/1bf887aa7e3736bad69fd4e277a378946edb0f2a.tar.gz"; - hash = "sha256-eq5YC26OY0i2cdQJ0ZXMZ+o2vHQLEFNNGzQt5Zuz4BM="; + url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz"; + hash = "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY="; }; } { @@ -307,35 +307,19 @@ in }; } { - name = "z2d-0.8.2-pre-j5P_HlVRFgCsBTQ3EgUoKbYHx5JMnyH1mHsOSPiafnef"; + name = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T"; path = fetchZigArtifact { name = "z2d"; - url = "https://deps.files.ghostty.org/z2d-a1237f6881d99b75abd8a20a934e62e34b44a005.tar.gz"; - hash = "sha256-5/qRZAIh1U42v7jql9W0jr2zzQZtu39DxJPLVrSybJg="; + url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz"; + hash = "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA="; }; } { - name = "zf-0.10.3-OIRy8QGJAACJcu3tCGtfbJnnd3Y4QL7OW_X8PJ8u_ASR"; + name = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh"; path = fetchZigArtifact { name = "zf"; - url = "https://deps.files.ghostty.org/zf-52ad2e5528ab754f77437edf08a07b5ec843661c.tar.gz"; - hash = "sha256-8BinbanSfZeBA8SBAopVxwJObN36/BTpxVHABKicsMQ="; - }; - } - { - name = "zg-0.14.1-oGqU3J4_tAKBfyes3AWleKDjo-IcYvnEwaB8qxOqFMwM"; - path = fetchZigArtifact { - name = "zg"; - url = "git+https://codeberg.org/ivanstepanovftw/zg#4fe689e56ce2ed5a8f59308b471bccd7da89fac9"; - hash = "sha256-P0ieLuOQ05wKVaMmeNKJIxCWMIdyeKkmhsj8Ps80BGU="; - }; - } - { - name = "zg-0.15.1-oGqU3M0-tALZCy7boQS86znlBloyKx6--JriGlY0Paa9"; - path = fetchZigArtifact { - name = "zg"; - url = "https://codeberg.org/chaten/zg/archive/749197a3f9d25e211615960c02380a3d659b20f9.tar.gz"; - hash = "sha256-BZhz1nPqxK6hdsJQ66n7Jk4zMgFSGLXm8eU0CX/7mDI="; + url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz"; + hash = "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg="; }; } { @@ -362,14 +346,6 @@ in hash = "sha256-TxRrc17Q1Sf1IOO/cdPpP3LD0PpYOujt06SFH3B5Ek4="; }; } - { - name = "zigimg-0.1.0-8_eo2mWmEgBoqdr0sH9O5GTqDHthkoEPM5_tipcBRreL"; - path = fetchZigArtifact { - name = "zigimg"; - url = "git+https://github.com/ivanstepanovftw/zigimg#aa4c31db872612c39edbb79f753b3cd9a79fe726"; - hash = "sha256-Ko5RuxxTAvpUHCnWEdHqNl7b+PVUAxg1/OPmzGGjdt0="; - }; - } { name = "zigimg-0.1.0-8_eo2vHnEwCIVW34Q14Ec-xUlzIoVg86-7FU2ypPtxms"; path = fetchZigArtifact { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 8ebecee9b..24a2978d6 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -1,6 +1,4 @@ -git+https://codeberg.org/ivanstepanovftw/zg#4fe689e56ce2ed5a8f59308b471bccd7da89fac9 -git+https://github.com/ivanstepanovftw/zigimg#aa4c31db872612c39edbb79f753b3cd9a79fe726 -https://codeberg.org/chaten/zg/archive/749197a3f9d25e211615960c02380a3d659b20f9.tar.gz +git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732 https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz @@ -8,12 +6,11 @@ https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz -https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst +https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz -https://deps.files.ghostty.org/iterm2_themes-release-20250922-150534-d28055b.tgz https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz https://deps.files.ghostty.org/libxml2-2.11.5.tar.gz @@ -23,16 +20,16 @@ https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e.tar.gz https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz -https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz -https://deps.files.ghostty.org/vaxis-9fc9015d5f147568e18c5e7ca28f15bf8b293760.tar.gz +https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz +https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz -https://deps.files.ghostty.org/z2d-a1237f6881d99b75abd8a20a934e62e34b44a005.tar.gz -https://deps.files.ghostty.org/zf-52ad2e5528ab754f77437edf08a07b5ec843661c.tar.gz +https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz +https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz -https://github.com/rockorager/libvaxis/archive/1bf887aa7e3736bad69fd4e277a378946edb0f2a.tar.gz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz diff --git a/dist/doxygen/favicon.png b/dist/doxygen/favicon.png new file mode 100644 index 000000000..b647bcf35 Binary files /dev/null and b/dist/doxygen/favicon.png differ diff --git a/dist/doxygen/footer.html b/dist/doxygen/footer.html new file mode 100644 index 000000000..fca4b87d9 --- /dev/null +++ b/dist/doxygen/footer.html @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/dist/doxygen/ghostty.css b/dist/doxygen/ghostty.css new file mode 100644 index 000000000..678414b70 --- /dev/null +++ b/dist/doxygen/ghostty.css @@ -0,0 +1,390 @@ +/** + * Ghostty Doxygen Custom Stylesheet + * Minimal branding customizations for Ghostty colors + */ + +/* Ghostty brand color for links and accents - high contrast for dark bg */ +a, +a:link { + color: #99b3ff; +} + +a:visited { + color: #99b3ff; +} + +a:hover { + color: #c2d4ff; +} + +/* High contrast text colors */ +body, +div.contents, +div.header, +.title, +.summary, +td, +th, +p, +li { + color: #e8e8e8 !important; +} + +h1, +h2, +h3, +h4, +h5, +h6, +.groupheader { + color: #ffffff !important; +} + +.memtitle, +.memname { + color: #ffffff !important; +} + +.memdoc { + color: #e8e8e8 !important; +} + +/* Selection color */ +::selection { + background: rgba(53, 81, 243, 0.6); +} + +/* Modern scrollbar styling for WebKit browsers (Safari, Chrome) */ +::-webkit-scrollbar { + width: 14px; + height: 14px; + -webkit-appearance: none; +} + +::-webkit-scrollbar-track { + background: #1a1f2e; + border-radius: 8px; +} + +::-webkit-scrollbar-thumb { + background: #4a5260; + border-radius: 8px; + border: 3px solid #1a1f2e; + min-height: 40px; +} + +::-webkit-scrollbar-thumb:hover { + background: #5a6270; +} + +::-webkit-scrollbar-thumb:active { + background: #6a7280; +} + +::-webkit-scrollbar-corner { + background: #1a1f2e; +} + +/* Firefox scrollbar styling */ +* { + scrollbar-width: thin; + scrollbar-color: #404754 #1a1f2e; +} + +/* Tree view selected item */ +#nav-tree .selected { + background-color: #3551f3 !important; +} + +/* Custom syntax highlighting optimized for dark backgrounds with high contrast */ +.fragment, +div.line { + color: #f0f0f0 !important; +} + +/* Keywords (int, void, const, static, etc.) */ +.keyword, +.keywordtype { + color: #ff8be6 !important; + font-weight: 500; +} + +/* Control flow (if, else, return, for, while, etc.) */ +.keywordflow { + color: #ff8be6 !important; + font-weight: 500; +} + +/* Comments */ +.comment { + color: #8bc34a !important; + font-style: italic; +} + +/* Preprocessor directives (#include, #define, etc.) */ +.preprocessor { + color: #ffcc66 !important; +} + +/* String and character literals */ +.stringliteral, +.charliteral { + color: #b8e986 !important; +} + +/* Numbers */ +span.charliteral { + color: #d4a5ff !important; +} + +/* Function names */ +.functionname { + color: #6fe87c !important; + font-weight: 500; +} + +/* Line numbers */ +span.lineno { + color: #8a8a8a !important; + background-color: transparent !important; +} + +span.lineno a { + color: #8a8a8a !important; + background-color: transparent !important; +} + +/* Desktop: ensure page-nav maintains default width */ +@media screen and (min-width: 768px) { + #page-nav-toggle { + display: none !important; + } + + #page-nav { + position: relative !important; + width: 250px !important; + height: auto !important; + right: auto !important; + top: auto !important; + box-shadow: none !important; + } +} + +/* Mobile-friendly responsive styles */ +@media screen and (max-width: 767px) { + body { + font-size: 14px !important; + } + + /* Make navigation tree collapsible on mobile */ + #side-nav { + display: none; + } + + #doc-content { + margin-left: 0 !important; + margin-right: 0 !important; + } + + /* Make right sidebar (page-nav) overlay on mobile */ + #page-nav { + position: fixed !important; + top: 0 !important; + right: -280px !important; + width: 280px !important; + height: 100vh !important; + z-index: 10000 !important; + background: #101826 !important; + box-shadow: -2px 0 10px rgba(0, 0, 0, 0.5) !important; + transition: right 0.3s ease !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch !important; + } + + #page-nav.mobile-open { + right: 0 !important; + } + + /* Hamburger menu button for page nav */ + #page-nav-toggle { + display: block !important; + position: fixed !important; + top: 10px !important; + right: 15px !important; + z-index: 10001 !important; + width: 40px !important; + height: 40px !important; + background: rgba(53, 81, 243, 0.9) !important; + border: none !important; + border-radius: 5px !important; + cursor: pointer !important; + padding: 8px !important; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3) !important; + } + + #page-nav-toggle span { + display: block !important; + width: 24px !important; + height: 3px !important; + background: #fff !important; + margin: 4px 0 !important; + border-radius: 2px !important; + transition: 0.3s !important; + } + + /* Mobile overlay backdrop */ + #page-nav-backdrop { + display: none !important; + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + background: rgba(0, 0, 0, 0.5) !important; + z-index: 9999 !important; + } + + #page-nav-backdrop.active { + display: block !important; + } + + /* Improve header and navigation */ + #top { + height: auto !important; + } + + #titlearea { + padding: 10px !important; + } + + #projectname { + font-size: 18px !important; + } + + #projectbrief, + #projectnumber { + font-size: 12px !important; + } + + /* Make tabs stack better on mobile */ + #navrow1, + #navrow2, + #navrow3, + #navrow4 { + overflow-x: auto !important; + white-space: nowrap !important; + -webkit-overflow-scrolling: touch !important; + } + + .tablist li { + display: inline-block !important; + } + + /* Content adjustments */ + .contents { + padding: 10px !important; + width: 100% !important; + box-sizing: border-box !important; + } + + .header { + padding: 5px !important; + } + + /* Code blocks */ + .fragment { + font-size: 12px !important; + overflow-x: auto !important; + -webkit-overflow-scrolling: touch !important; + } + + div.line { + font-size: 12px !important; + } + + /* Tables */ + table { + display: block !important; + overflow-x: auto !important; + -webkit-overflow-scrolling: touch !important; + width: 100% !important; + } + + .memberdecls table, + .fieldtable { + font-size: 12px !important; + } + + .memtitle { + font-size: 14px !important; + padding: 8px !important; + } + + .memname { + font-size: 13px !important; + word-break: break-word !important; + } + + .memitem { + margin: 5px 0 !important; + } + + /* Search box */ + #MSearchBox { + width: 100% !important; + right: 0 !important; + } + + /* Reduce padding and margins */ + h1, + h2, + h3, + h4, + h5, + h6 { + margin-top: 10px !important; + margin-bottom: 8px !important; + } + + h1 { + font-size: 22px !important; + } + h2 { + font-size: 18px !important; + } + h3 { + font-size: 16px !important; + } + h4 { + font-size: 14px !important; + } + + /* Directory/file listings */ + .directory .levels span { + display: none !important; + } + + .directory .arrow { + margin-right: 5px !important; + } + + /* Treeview adjustments */ + #nav-tree { + width: 100% !important; + } +} + +/* Tablet adjustments */ +@media screen and (min-width: 768px) and (max-width: 1024px) { + .contents { + padding: 15px !important; + } + + #side-nav { + width: 200px !important; + } + + #doc-content { + margin-left: 200px !important; + } +} diff --git a/dist/doxygen/header.html b/dist/doxygen/header.html new file mode 100644 index 000000000..223ec4953 --- /dev/null +++ b/dist/doxygen/header.html @@ -0,0 +1,77 @@ + + + + + + + + +$projectname: $title +$title + + + + + + + + + + + + +$treeview +$search +$mathjax +$darkmode + +$extrastylesheet + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
$projectname $projectnumber +
+
$projectbrief
+
+
$projectbrief
+
$searchbox
$searchbox
+
+ + diff --git a/dist/doxygen/mobile-nav.js b/dist/doxygen/mobile-nav.js new file mode 100644 index 000000000..c6c4e2214 --- /dev/null +++ b/dist/doxygen/mobile-nav.js @@ -0,0 +1,65 @@ +/** + * Mobile navigation toggle for Doxygen documentation + */ + +(function () { + // Only run on mobile devices + function isMobile() { + return window.innerWidth <= 767; + } + + function initMobileNav() { + if (!isMobile()) return; + + const pageNav = document.getElementById("page-nav"); + if (!pageNav) return; + + // Create toggle button + const toggleBtn = document.createElement("button"); + toggleBtn.id = "page-nav-toggle"; + toggleBtn.setAttribute("aria-label", "Toggle page navigation"); + toggleBtn.innerHTML = ""; + document.body.appendChild(toggleBtn); + + // Create backdrop + const backdrop = document.createElement("div"); + backdrop.id = "page-nav-backdrop"; + document.body.appendChild(backdrop); + + // Toggle function + function toggleNav() { + const isOpen = pageNav.classList.toggle("mobile-open"); + backdrop.classList.toggle("active", isOpen); + document.body.style.overflow = isOpen ? "hidden" : ""; + } + + // Event listeners + toggleBtn.addEventListener("click", toggleNav); + backdrop.addEventListener("click", toggleNav); + + // Close on escape key + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && pageNav.classList.contains("mobile-open")) { + toggleNav(); + } + }); + } + + // Initialize on load and resize + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initMobileNav); + } else { + initMobileNav(); + } + + window.addEventListener("resize", function () { + const pageNav = document.getElementById("page-nav"); + const backdrop = document.getElementById("page-nav-backdrop"); + + if (!isMobile() && pageNav) { + pageNav.classList.remove("mobile-open"); + if (backdrop) backdrop.classList.remove("active"); + document.body.style.overflow = ""; + } + }); +})(); diff --git a/dist/doxygen/stylesheet.css b/dist/doxygen/stylesheet.css new file mode 100644 index 000000000..31ebcc685 --- /dev/null +++ b/dist/doxygen/stylesheet.css @@ -0,0 +1,2659 @@ +/* The standard CSS for doxygen 1.14.0*/ + +html { + /* page base colors */ + --page-background-color: white; + --page-foreground-color: black; + --page-link-color: #3d578c; + --page-visited-link-color: #3d578c; + --page-external-link-color: #334975; + + /* index */ + --index-odd-item-bg-color: #f8f9fc; + --index-even-item-bg-color: white; + --index-header-color: black; + --index-separator-color: #a0a0a0; + + /* header */ + --header-background-color: #f9fafc; + --header-separator-color: #c4cfe5; + --group-header-separator-color: #d9e0ee; + --group-header-color: #354c7b; + --inherit-header-color: gray; + + --footer-foreground-color: #2a3d61; + --footer-logo-width: 75px; + --citation-label-color: #334975; + --glow-color: cyan; + + --title-background-color: white; + --title-separator-color: #c4cfe5; + --directory-separator-color: #9cafd4; + --separator-color: #4a6aaa; + + --blockquote-background-color: #f7f8fb; + --blockquote-border-color: #9cafd4; + + --scrollbar-thumb-color: #c4cfe5; + --scrollbar-background-color: #f9fafc; + + --icon-background-color: #728dc1; + --icon-foreground-color: white; + /* +--icon-doc-image: url('doc.svg'); +--icon-folder-open-image: url('folderopen.svg'); +--icon-folder-closed-image: url('folderclosed.svg');*/ + --icon-folder-open-fill-color: #c4cfe5; + --icon-folder-fill-color: #d8dfee; + --icon-folder-border-color: #4665a2; + --icon-doc-fill-color: #d8dfee; + --icon-doc-border-color: #4665a2; + + /* brief member declaration list */ + --memdecl-background-color: #f9fafc; + --memdecl-separator-color: #dee4f0; + --memdecl-foreground-color: #555; + --memdecl-template-color: #4665a2; + --memdecl-border-color: #d5ddec; + + /* detailed member list */ + --memdef-border-color: #a8b8d9; + --memdef-title-background-color: #e2e8f2; + --memdef-proto-background-color: #eef1f7; + --memdef-proto-text-color: #253555; + --memdef-doc-background-color: white; + --memdef-param-name-color: #602020; + --memdef-template-color: #4665a2; + + /* tables */ + --table-cell-border-color: #2d4068; + --table-header-background-color: #374f7f; + --table-header-foreground-color: #ffffff; + + /* labels */ + --label-background-color: #728dc1; + --label-left-top-border-color: #5373b4; + --label-right-bottom-border-color: #c4cfe5; + --label-foreground-color: white; + + /** navigation bar/tree/menu */ + --nav-background-color: #f9fafc; + --nav-foreground-color: #364d7c; + --nav-border-color: #c4cfe5; + --nav-breadcrumb-separator-color: #c4cfe5; + --nav-breadcrumb-active-bg: #eef1f7; + --nav-breadcrumb-color: #354c7b; + --nav-breadcrumb-border-color: #e1e7f2; + --nav-splitbar-bg-color: #dce2ef; + --nav-splitbar-handle-color: #9cafd4; + --nav-font-size-level1: 13px; + --nav-font-size-level2: 10px; + --nav-font-size-level3: 9px; + --nav-text-normal-color: #283a5d; + --nav-text-hover-color: white; + --nav-text-active-color: white; + --nav-menu-button-color: #364d7c; + --nav-menu-background-color: white; + --nav-menu-foreground-color: #555555; + --nav-menu-active-bg: #dce2ef; + --nav-menu-active-color: #9cafd4; + --nav-menu-toggle-color: rgba(255, 255, 255, 0.5); + --nav-arrow-color: #b6c4df; + --nav-arrow-selected-color: #90a5ce; + + /* sync icon */ + --sync-icon-border-color: #c4cfe5; + --sync-icon-background-color: #f9fafc; + --sync-icon-selected-background-color: #eef1f7; + --sync-icon-color: #c4cfe5; + --sync-icon-selected-color: #6884bd; + + /* table of contents */ + --toc-background-color: #f4f6fa; + --toc-border-color: #d8dfee; + --toc-header-color: #4665a2; + --toc-down-arrow-image: url("data:image/svg+xml;utf8,&%238595;"); + + /** search field */ + --search-background-color: white; + --search-foreground-color: #909090; + --search-active-color: black; + --search-filter-background-color: rgba(255, 255, 255, 0.7); + --search-filter-backdrop-filter: blur(4px); + --search-filter-foreground-color: black; + --search-filter-border-color: rgba(150, 150, 150, 0.4); + --search-filter-highlight-text-color: white; + --search-filter-highlight-bg-color: #3d578c; + --search-results-foreground-color: #425e97; + --search-results-background-color: rgba(255, 255, 255, 0.8); + --search-results-backdrop-filter: blur(4px); + --search-results-border-color: rgba(150, 150, 150, 0.4); + --search-box-border-color: #b6c4df; + --search-close-icon-bg-color: #a0a0a0; + --search-close-icon-fg-color: white; + + /** code fragments */ + --code-keyword-color: #008000; + --code-type-keyword-color: #604020; + --code-flow-keyword-color: #e08000; + --code-comment-color: #800000; + --code-preprocessor-color: #806020; + --code-string-literal-color: #002080; + --code-char-literal-color: #008080; + --code-xml-cdata-color: black; + --code-vhdl-digit-color: #ff00ff; + --code-vhdl-char-color: #000000; + --code-vhdl-keyword-color: #700070; + --code-vhdl-logic-color: #ff0000; + --code-link-color: #4665a2; + --code-external-link-color: #4665a2; + --fragment-foreground-color: black; + --fragment-background-color: #fbfcfd; + --fragment-border-color: #c4cfe5; + --fragment-lineno-border-color: #00ff00; + --fragment-lineno-background-color: #e8e8e8; + --fragment-lineno-foreground-color: black; + --fragment-lineno-link-fg-color: #4665a2; + --fragment-lineno-link-bg-color: #d8d8d8; + --fragment-lineno-link-hover-fg-color: #4665a2; + --fragment-lineno-link-hover-bg-color: #c8c8c8; + --fragment-copy-ok-color: #2ec82e; + --tooltip-foreground-color: black; + --tooltip-background-color: rgba(255, 255, 255, 0.8); + --tooltip-arrow-background-color: white; + --tooltip-border-color: rgba(150, 150, 150, 0.7); + --tooltip-backdrop-filter: blur(3px); + --tooltip-doc-color: gray; + --tooltip-declaration-color: #006318; + --tooltip-link-color: #4665a2; + --tooltip-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.25); + --fold-line-color: #808080; + + /** font-family */ + --font-family-normal: + system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, + sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + --font-family-monospace: + "JetBrains Mono", Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace, + fixed; + --font-family-nav: "Lucida Grande", Geneva, Helvetica, Arial, sans-serif; + --font-family-title: + system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, + sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + --font-family-toc: Verdana, "DejaVu Sans", Geneva, sans-serif; + --font-family-search: Arial, Verdana, sans-serif; + --font-family-icon: Arial, Helvetica; + --font-family-tooltip: Roboto, sans-serif; + + /** special sections */ + --warning-color-bg: #f8d1cc; + --warning-color-hl: #b61825; + --warning-color-text: #75070f; + --note-color-bg: #faf3d8; + --note-color-hl: #f3a600; + --note-color-text: #5f4204; + --todo-color-bg: #e4f3ff; + --todo-color-hl: #1879c4; + --todo-color-text: #274a5c; + --test-color-bg: #e8e8ff; + --test-color-hl: #3939c4; + --test-color-text: #1a1a5c; + --deprecated-color-bg: #ecf0f3; + --deprecated-color-hl: #5b6269; + --deprecated-color-text: #43454a; + --bug-color-bg: #e4dafd; + --bug-color-hl: #5b2bdd; + --bug-color-text: #2a0d72; + --invariant-color-bg: #d8f1e3; + --invariant-color-hl: #44b86f; + --invariant-color-text: #265532; +} + +@media (prefers-color-scheme: dark) { + html:not(.dark-mode) { + color-scheme: dark; + + /* page base colors */ + --page-background-color: black; + --page-foreground-color: #c9d1d9; + --page-link-color: #90a5ce; + --page-visited-link-color: #90a5ce; + --page-external-link-color: #a3b4d7; + + /* index */ + --index-odd-item-bg-color: #0b101a; + --index-even-item-bg-color: black; + --index-header-color: #c4cfe5; + --index-separator-color: #334975; + + /* header */ + --header-background-color: #070b11; + --header-separator-color: #141c2e; + --group-header-separator-color: #1d2a43; + --group-header-color: #90a5ce; + --inherit-header-color: #a0a0a0; + + --footer-foreground-color: #5b7ab7; + --footer-logo-width: 60px; + --citation-label-color: #90a5ce; + --glow-color: cyan; + + --title-background-color: #090d16; + --title-separator-color: #212f4b; + --directory-separator-color: #283a5d; + --separator-color: #283a5d; + + --blockquote-background-color: #101826; + --blockquote-border-color: #283a5d; + + --scrollbar-thumb-color: #2c3f65; + --scrollbar-background-color: #070b11; + + --icon-background-color: #334975; + --icon-foreground-color: #c4cfe5; + --icon-folder-open-fill-color: #4665a2; + --icon-folder-fill-color: #5373b4; + --icon-folder-border-color: #c4cfe5; + --icon-doc-fill-color: #6884bd; + --icon-doc-border-color: #c4cfe5; + + /* brief member declaration list */ + --memdecl-background-color: #0b101a; + --memdecl-separator-color: #2c3f65; + --memdecl-foreground-color: #bbb; + --memdecl-template-color: #7c95c6; + --memdecl-border-color: #233250; + + /* detailed member list */ + --memdef-border-color: #233250; + --memdef-title-background-color: #1b2840; + --memdef-proto-background-color: #19243a; + --memdef-proto-text-color: #9db0d4; + --memdef-doc-background-color: black; + --memdef-param-name-color: #d28757; + --memdef-template-color: #7c95c6; + + /* tables */ + --table-cell-border-color: #283a5d; + --table-header-background-color: #283a5d; + --table-header-foreground-color: #c4cfe5; + + /* labels */ + --label-background-color: #354c7b; + --label-left-top-border-color: #4665a2; + --label-right-bottom-border-color: #283a5d; + --label-foreground-color: #cccccc; + + /** navigation bar/tree/menu */ + --nav-background-color: #101826; + --nav-foreground-color: #364d7c; + --nav-border-color: #212f4b; + --nav-breadcrumb-separator-color: #212f4b; + --nav-breadcrumb-active-bg: #1d2a43; + --nav-breadcrumb-color: #90a5ce; + --nav-breadcrumb-border-color: #2a3d61; + --nav-splitbar-bg-color: #283a5d; + --nav-splitbar-handle-color: #4665a2; + --nav-font-size-level1: 13px; + --nav-font-size-level2: 10px; + --nav-font-size-level3: 9px; + --nav-text-normal-color: #b6c4df; + --nav-text-hover-color: #dce2ef; + --nav-text-active-color: #dce2ef; + --nav-menu-button-color: #b6c4df; + --nav-menu-background-color: #05070c; + --nav-menu-foreground-color: #bbbbbb; + --nav-menu-active-bg: #1d2a43; + --nav-menu-active-color: #c9d3e7; + --nav-menu-toggle-color: rgba(255, 255, 255, 0.2); + --nav-arrow-color: #4665a2; + --nav-arrow-selected-color: #6884bd; + + /* sync icon */ + --sync-icon-border-color: #212f4b; + --sync-icon-background-color: #101826; + --sync-icon-selected-background-color: #1d2a43; + --sync-icon-color: #4665a2; + --sync-icon-selected-color: #5373b4; + + /* table of contents */ + --toc-background-color: #151e30; + --toc-border-color: #202e4a; + --toc-header-color: #a3b4d7; + --toc-down-arrow-image: url("data:image/svg+xml;utf8,&%238595;"); + + /** search field */ + --search-background-color: black; + --search-foreground-color: #c5c5c5; + --search-active-color: #f5f5f5; + --search-filter-background-color: #101826; + --search-filter-foreground-color: #90a5ce; + --search-filter-backdrop-filter: none; + --search-filter-border-color: #7c95c6; + --search-filter-highlight-text-color: #bcc9e2; + --search-filter-highlight-bg-color: #283a5d; + --search-results-background-color: black; + --search-results-foreground-color: #90a5ce; + --search-results-backdrop-filter: none; + --search-results-border-color: #334975; + --search-box-border-color: #334975; + --search-close-icon-bg-color: #909090; + --search-close-icon-fg-color: black; + + /** code fragments */ + --code-keyword-color: #cc99cd; + --code-type-keyword-color: #ab99cd; + --code-flow-keyword-color: #e08000; + --code-comment-color: #717790; + --code-preprocessor-color: #65cabe; + --code-string-literal-color: #7ec699; + --code-char-literal-color: #00e0f0; + --code-xml-cdata-color: #c9d1d9; + --code-vhdl-digit-color: #ff00ff; + --code-vhdl-char-color: #c0c0c0; + --code-vhdl-keyword-color: #cf53c9; + --code-vhdl-logic-color: #ff0000; + --code-link-color: #79c0ff; + --code-external-link-color: #79c0ff; + --fragment-foreground-color: #c9d1d9; + --fragment-background-color: #090d16; + --fragment-border-color: #30363d; + --fragment-lineno-border-color: #30363d; + --fragment-lineno-background-color: black; + --fragment-lineno-foreground-color: #6e7681; + --fragment-lineno-link-fg-color: #6e7681; + --fragment-lineno-link-bg-color: #303030; + --fragment-lineno-link-hover-fg-color: #8e96a1; + --fragment-lineno-link-hover-bg-color: #505050; + --fragment-copy-ok-color: #0ea80e; + --tooltip-foreground-color: #c9d1d9; + --tooltip-background-color: #202020; + --tooltip-arrow-background-color: #202020; + --tooltip-backdrop-filter: none; + --tooltip-border-color: #c9d1d9; + --tooltip-doc-color: #d9e1e9; + --tooltip-declaration-color: #20c348; + --tooltip-link-color: #79c0ff; + --tooltip-shadow: none; + --fold-line-color: #808080; + + /** font-family */ + --font-family-normal: + system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, + sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + --font-family-monospace: + "JetBrains Mono", Consolas, Monaco, "Andale Mono", "Ubuntu Mono", + monospace, fixed; + --font-family-nav: "Lucida Grande", Geneva, Helvetica, Arial, sans-serif; + --font-family-title: + system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, + sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + --font-family-toc: Verdana, "DejaVu Sans", Geneva, sans-serif; + --font-family-search: Arial, Verdana, sans-serif; + --font-family-icon: Arial, Helvetica; + --font-family-tooltip: Roboto, sans-serif; + + /** special sections */ + --warning-color-bg: #2e1917; + --warning-color-hl: #ad2617; + --warning-color-text: #f5b1aa; + --note-color-bg: #3b2e04; + --note-color-hl: #f1b602; + --note-color-text: #ceb670; + --todo-color-bg: #163750; + --todo-color-hl: #1982d2; + --todo-color-text: #dcf0fa; + --test-color-bg: #121258; + --test-color-hl: #4242cf; + --test-color-text: #c0c0da; + --deprecated-color-bg: #2e323b; + --deprecated-color-hl: #738396; + --deprecated-color-text: #abb0bd; + --bug-color-bg: #2a2536; + --bug-color-hl: #7661b3; + --bug-color-text: #ae9ed6; + --invariant-color-bg: #303a35; + --invariant-color-hl: #76ce96; + --invariant-color-text: #cceed5; + } +} +body { + background-color: var(--page-background-color); + color: var(--page-foreground-color); +} + +body, +table, +div, +p, +dl { + font-weight: 400; + font-size: 14px; + font-family: var(--font-family-normal); + line-height: 22px; +} + +body.resizing { + user-select: none; + -webkit-user-select: none; +} + +#doc-content { + scrollbar-width: thin; +} + +/* @group Heading Levels */ + +.title { + font-family: var(--font-family-normal); + line-height: 28px; + font-size: 160%; + font-weight: 400; + margin: 10px 2px; +} + +h1.groupheader { + font-size: 150%; +} + +h2.groupheader { + box-shadow: + 12px 0 var(--page-background-color), + -12px 0 var(--page-background-color), + 12px 1px var(--group-header-separator-color), + -12px 1px var(--group-header-separator-color); + color: var(--group-header-color); + font-size: 150%; + font-weight: normal; + margin-top: 1.75em; + padding-top: 8px; + padding-bottom: 4px; + width: 100%; +} + +td h2.groupheader { + box-shadow: + 13px 0 var(--page-background-color), + -13px 0 var(--page-background-color), + 13px 1px var(--group-header-separator-color), + -13px 1px var(--group-header-separator-color); +} + +h3.groupheader { + font-size: 100%; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + -webkit-transition: text-shadow 0.5s linear; + -moz-transition: text-shadow 0.5s linear; + -ms-transition: text-shadow 0.5s linear; + -o-transition: text-shadow 0.5s linear; + transition: text-shadow 0.5s linear; + margin-right: 15px; +} + +h1.glow, +h2.glow, +h3.glow, +h4.glow, +h5.glow, +h6.glow { + text-shadow: 0 0 15px var(--glow-color); +} + +dt { + font-weight: bold; +} + +p.startli, +p.startdd { + margin-top: 2px; +} + +th p.starttd, +th p.intertd, +th p.endtd { + font-size: 100%; + font-weight: 700; +} + +p.starttd { + margin-top: 0px; +} + +p.endli { + margin-bottom: 0px; +} + +p.enddd { + margin-bottom: 4px; +} + +p.endtd { + margin-bottom: 2px; +} + +p.interli { +} + +p.interdd { +} + +p.intertd { +} + +/* @end */ + +caption { + font-weight: bold; +} + +span.legend { + font-size: 70%; + text-align: center; +} + +h3.version { + font-size: 90%; + text-align: center; +} + +div.navtab { + margin-right: 6px; + padding-right: 6px; + text-align: right; + line-height: 110%; + background-color: var(--nav-background-color); +} + +div.navtab table { + border-spacing: 0; +} + +td.navtab { + padding-right: 6px; + padding-left: 6px; +} + +td.navtabHL { + padding-right: 6px; + padding-left: 6px; + border-radius: 0 6px 6px 0; + background-color: var(--nav-menu-active-bg); +} + +div.qindex { + text-align: center; + width: 100%; + line-height: 140%; + font-size: 130%; + color: var(--index-separator-color); +} + +#main-menu a:focus { + outline: auto; + z-index: 10; + position: relative; +} + +dt.alphachar { + font-size: 180%; + font-weight: bold; +} + +.alphachar a { + color: var(--index-header-color); +} + +.alphachar a:hover, +.alphachar a:visited { + text-decoration: none; +} + +.classindex dl { + padding: 25px; + column-count: 1; +} + +.classindex dd { + display: inline-block; + margin-left: 50px; + width: 90%; + line-height: 1.15em; +} + +.classindex dl.even { + background-color: var(--index-even-item-bg-color); +} + +.classindex dl.odd { + background-color: var(--index-odd-item-bg-color); +} + +@media (min-width: 1120px) { + .classindex dl { + column-count: 2; + } +} + +@media (min-width: 1320px) { + .classindex dl { + column-count: 3; + } +} + +/* @group Link Styling */ + +a { + color: var(--page-link-color); + font-weight: normal; + text-decoration: none; +} + +.contents a:visited { + color: var(--page-visited-link-color); +} + +span.label a:hover { + text-decoration: none; + background: linear-gradient( + to bottom, + transparent 0, + transparent calc(100% - 1px), + currentColor 100% + ); +} + +a.el { + font-weight: bold; +} + +a.elRef { +} + +a.el, +a.el:visited, +a.code, +a.code:visited, +a.line, +a.line:visited { + color: var(--page-link-color); +} + +a.codeRef, +a.codeRef:visited, +a.lineRef, +a.lineRef:visited { + color: var(--page-external-link-color); +} + +a.code.hl_class { + /* style for links to class names in code snippets */ +} +a.code.hl_struct { + /* style for links to struct names in code snippets */ +} +a.code.hl_union { + /* style for links to union names in code snippets */ +} +a.code.hl_interface { + /* style for links to interface names in code snippets */ +} +a.code.hl_protocol { + /* style for links to protocol names in code snippets */ +} +a.code.hl_category { + /* style for links to category names in code snippets */ +} +a.code.hl_exception { + /* style for links to exception names in code snippets */ +} +a.code.hl_service { + /* style for links to service names in code snippets */ +} +a.code.hl_singleton { + /* style for links to singleton names in code snippets */ +} +a.code.hl_concept { + /* style for links to concept names in code snippets */ +} +a.code.hl_namespace { + /* style for links to namespace names in code snippets */ +} +a.code.hl_package { + /* style for links to package names in code snippets */ +} +a.code.hl_define { + /* style for links to macro names in code snippets */ +} +a.code.hl_function { + /* style for links to function names in code snippets */ +} +a.code.hl_variable { + /* style for links to variable names in code snippets */ +} +a.code.hl_typedef { + /* style for links to typedef names in code snippets */ +} +a.code.hl_enumvalue { + /* style for links to enum value names in code snippets */ +} +a.code.hl_enumeration { + /* style for links to enumeration names in code snippets */ +} +a.code.hl_signal { + /* style for links to Qt signal names in code snippets */ +} +a.code.hl_slot { + /* style for links to Qt slot names in code snippets */ +} +a.code.hl_friend { + /* style for links to friend names in code snippets */ +} +a.code.hl_dcop { + /* style for links to KDE3 DCOP names in code snippets */ +} +a.code.hl_property { + /* style for links to property names in code snippets */ +} +a.code.hl_event { + /* style for links to event names in code snippets */ +} +a.code.hl_sequence { + /* style for links to sequence names in code snippets */ +} +a.code.hl_dictionary { + /* style for links to dictionary names in code snippets */ +} + +/* @end */ + +dl.el { + margin-left: -1cm; +} + +ul.check { + list-style: none; + text-indent: -16px; + padding-left: 38px; +} +li.unchecked:before { + content: "\2610\A0"; +} +li.checked:before { + content: "\2611\A0"; +} + +ol { + text-indent: 0px; +} + +ul { + text-indent: 0px; + overflow: visible; +} + +ul.multicol { + -moz-column-gap: 1em; + -webkit-column-gap: 1em; + column-gap: 1em; + -moz-column-count: 3; + -webkit-column-count: 3; + column-count: 3; + list-style-type: none; +} + +#side-nav ul { + overflow: visible; /* reset ul rule for scroll bar in GENERATE_TREEVIEW window */ +} + +#main-nav ul { + overflow: visible; /* reset ul rule for the navigation bar drop down lists */ +} + +.fragment { + text-align: left; + direction: ltr; + overflow-x: auto; + overflow-y: hidden; + position: relative; + min-height: 12px; + margin: 10px 0px; + padding: 10px 10px; + border: 1px solid var(--fragment-border-color); + border-radius: 4px; + background-color: var(--fragment-background-color); + color: var(--fragment-foreground-color); +} + +pre.fragment { + word-wrap: break-word; + font-size: 10pt; + line-height: 125%; + font-family: var(--font-family-monospace); +} + +span.tt { + white-space: pre; + font-family: var(--font-family-monospace); +} + +.clipboard { + width: 24px; + height: 24px; + right: 5px; + top: 5px; + opacity: 0; + position: absolute; + display: inline; + overflow: hidden; + justify-content: center; + align-items: center; + cursor: pointer; +} + +.clipboard.success { + border: 1px solid var(--fragment-foreground-color); + border-radius: 4px; +} + +.fragment:hover .clipboard, +.clipboard.success { + opacity: 0.4; +} + +.clipboard:hover, +.clipboard.success { + opacity: 1 !important; +} + +.clipboard:active:not([class~="success"]) svg { + transform: scale(0.91); +} + +.clipboard.success svg { + fill: var(--fragment-copy-ok-color); +} + +.clipboard.success { + border-color: var(--fragment-copy-ok-color); +} + +div.line { + font-family: var(--font-family-monospace); + font-size: 13px; + min-height: 13px; + line-height: 1.2; + text-wrap: wrap; + word-break: break-all; + white-space: -moz-pre-wrap; /* Moz */ + white-space: -pre-wrap; /* Opera 4-6 */ + white-space: -o-pre-wrap; /* Opera 7 */ + white-space: pre-wrap; /* CSS3 */ + word-wrap: break-word; /* IE 5.5+ */ + text-indent: -62px; + padding-left: 62px; + padding-bottom: 0px; + margin: 0px; + -webkit-transition-property: background-color, box-shadow; + -webkit-transition-duration: 0.5s; + -moz-transition-property: background-color, box-shadow; + -moz-transition-duration: 0.5s; + -ms-transition-property: background-color, box-shadow; + -ms-transition-duration: 0.5s; + -o-transition-property: background-color, box-shadow; + -o-transition-duration: 0.5s; + transition-property: background-color, box-shadow; + transition-duration: 0.5s; +} + +div.line:after { + content: "\000A"; + white-space: pre; +} + +div.line.glow { + background-color: var(--glow-color); + box-shadow: 0 0 10px var(--glow-color); +} + +span.fold { + display: inline-block; + width: 12px; + height: 12px; + margin-left: 4px; + margin-right: 1px; +} + +span.foldnone { + display: inline-block; + position: relative; + cursor: pointer; + user-select: none; +} + +span.fold.plus, +span.fold.minus { + width: 10px; + height: 10px; + background-color: var(--fragment-background-color); + position: relative; + border: 1px solid var(--fold-line-color); + margin-right: 1px; +} + +span.fold.plus::before, +span.fold.minus::before { + content: ""; + position: absolute; + background-color: var(--fold-line-color); +} + +span.fold.plus::before { + width: 2px; + height: 6px; + top: 2px; + left: 4px; +} + +span.fold.plus::after { + content: ""; + position: absolute; + width: 6px; + height: 2px; + top: 4px; + left: 2px; + background-color: var(--fold-line-color); +} + +span.fold.minus::before { + width: 6px; + height: 2px; + top: 4px; + left: 2px; +} + +span.lineno { + padding-right: 4px; + margin-right: 9px; + text-align: right; + border-right: 2px solid var(--fragment-lineno-border-color); + color: var(--fragment-lineno-foreground-color); + background-color: var(--fragment-lineno-background-color); + white-space: pre; +} +span.lineno a, +span.lineno a:visited { + color: var(--fragment-lineno-link-fg-color); + background-color: var(--fragment-lineno-link-bg-color); +} + +span.lineno a:hover { + color: var(--fragment-lineno-link-hover-fg-color); + background-color: var(--fragment-lineno-link-hover-bg-color); +} + +.lineno { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +div.classindex ul { + list-style: none; + padding-left: 0; +} + +div.classindex span.ai { + display: inline-block; +} + +div.groupHeader { + box-shadow: + 13px 0 var(--page-background-color), + -13px 0 var(--page-background-color), + 13px 1px var(--group-header-separator-color), + -13px 1px var(--group-header-separator-color); + color: var(--group-header-color); + font-size: 110%; + font-weight: 500; + margin-left: 0px; + margin-top: 0em; + margin-bottom: 6px; + padding-top: 8px; + padding-bottom: 4px; +} + +div.groupText { + margin-left: 16px; + font-style: italic; +} + +body { + color: var(--page-foreground-color); + margin: 0; +} + +div.contents { + margin-top: 10px; + margin-left: 12px; + margin-right: 12px; +} + +p.formulaDsp { + text-align: center; +} + +img.dark-mode-visible { + display: none; +} +img.light-mode-visible { + display: none; +} + +img.formulaInl, +img.inline { + vertical-align: middle; +} + +div.center { + text-align: center; + margin-top: 0px; + margin-bottom: 0px; + padding: 0px; +} + +div.center img { + border: 0px; +} + +address.footer { + text-align: right; + padding-right: 12px; +} + +img.footer { + border: 0px; + vertical-align: middle; + width: var(--footer-logo-width); +} + +.compoundTemplParams { + color: var(--memdecl-template-color); + font-size: 80%; + line-height: 120%; +} + +/* @group Code Colorization */ + +span.keyword { + color: var(--code-keyword-color); +} + +span.keywordtype { + color: var(--code-type-keyword-color); +} + +span.keywordflow { + color: var(--code-flow-keyword-color); +} + +span.comment { + color: var(--code-comment-color); +} + +span.preprocessor { + color: var(--code-preprocessor-color); +} + +span.stringliteral { + color: var(--code-string-literal-color); +} + +span.charliteral { + color: var(--code-char-literal-color); +} + +span.xmlcdata { + color: var(--code-xml-cdata-color); +} + +span.vhdldigit { + color: var(--code-vhdl-digit-color); +} + +span.vhdlchar { + color: var(--code-vhdl-char-color); +} + +span.vhdlkeyword { + color: var(--code-vhdl-keyword-color); +} + +span.vhdllogic { + color: var(--code-vhdl-logic-color); +} + +blockquote { + background-color: var(--blockquote-background-color); + border-left: 2px solid var(--blockquote-border-color); + margin: 0 24px 0 4px; + padding: 0 12px 0 16px; +} + +/* @end */ + +td.tiny { + font-size: 75%; +} + +.dirtab { + padding: 4px; + border-collapse: collapse; + border: 1px solid var(--table-cell-border-color); +} + +th.dirtab { + background-color: var(--table-header-background-color); + color: var(--table-header-foreground-color); + font-weight: bold; +} + +hr { + border: none; + margin-top: 16px; + margin-bottom: 16px; + height: 1px; + box-shadow: + 13px 0 var(--page-background-color), + -13px 0 var(--page-background-color), + 13px 1px var(--group-header-separator-color), + -13px 1px var(--group-header-separator-color); +} + +hr.footer { + height: 1px; +} + +/* @group Member Descriptions */ + +table.memberdecls { + border-spacing: 0px; + padding: 0px; +} + +.memberdecls td, +.fieldtable tr { + transition-property: background-color, box-shadow; + transition-duration: 0.5s; +} + +.memberdecls td.glow, +.fieldtable tr.glow { + background-color: var(--glow-color); + box-shadow: 0 0 15px var(--glow-color); +} + +.memberdecls tr[class^="memitem"] { + font-family: var(--font-family-monospace); +} + +.mdescLeft, +.mdescRight, +.memItemLeft, +.memItemRight { + padding-top: 2px; + padding-bottom: 2px; +} + +.memTemplParams { + padding-left: 10px; + padding-top: 5px; +} + +.memItemLeft, +.memItemRight, +.memTemplParams { + background-color: var(--memdecl-background-color); +} + +.mdescLeft, +.mdescRight { + padding: 0px 8px 4px 8px; + color: var(--memdecl-foreground-color); +} + +tr[class^="memdesc"] { + box-shadow: inset 0px 1px 3px 0px rgba(0, 0, 0, 0.075); +} + +.mdescLeft { + border-left: 1px solid var(--memdecl-border-color); + border-bottom: 1px solid var(--memdecl-border-color); +} + +.mdescRight { + border-right: 1px solid var(--memdecl-border-color); + border-bottom: 1px solid var(--memdecl-border-color); +} + +.memTemplParams { + color: var(--memdecl-template-color); + white-space: nowrap; + font-size: 80%; + border-left: 1px solid var(--memdecl-border-color); + border-right: 1px solid var(--memdecl-border-color); +} + +td.ititle { + border: 1px solid var(--memdecl-border-color); + border-top-left-radius: 4px; + border-top-right-radius: 4px; + padding-left: 10px; +} + +tr:not(:first-child) > td.ititle { + border-top: 0; + border-radius: 0; +} + +.memItemLeft { + white-space: nowrap; + border-left: 1px solid var(--memdecl-border-color); + border-bottom: 1px solid var(--memdecl-border-color); + padding-left: 10px; + transition: none; +} + +.memItemRight { + width: 100%; + border-right: 1px solid var(--memdecl-border-color); + border-bottom: 1px solid var(--memdecl-border-color); + padding-right: 10px; + transition: none; +} + +tr.heading + tr[class^="memitem"] td.memItemLeft, +tr.groupHeader + tr[class^="memitem"] td.memItemLeft, +tr.inherit_header + tr[class^="memitem"] td.memItemLeft { + border-top: 1px solid var(--memdecl-border-color); + border-top-left-radius: 4px; +} + +tr.heading + tr[class^="memitem"] td.memItemRight, +tr.groupHeader + tr[class^="memitem"] td.memItemRight, +tr.inherit_header + tr[class^="memitem"] td.memItemRight { + border-top: 1px solid var(--memdecl-border-color); + border-top-right-radius: 4px; +} + +tr.heading + tr[class^="memitem"] td.memTemplParams, +tr.heading + tr td.ititle, +tr.groupHeader + tr[class^="memitem"] td.memTemplParams, +tr.groupHeader + tr td.ititle, +tr.inherit_header + tr[class^="memitem"] td.memTemplParams { + border-top: 1px solid var(--memdecl-border-color); + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +table.memberdecls tr:last-child td.memItemLeft, +table.memberdecls tr:last-child td.mdescLeft, +table.memberdecls tr[class^="memitem"]:has(+ tr.groupHeader) td.memItemLeft, +table.memberdecls tr[class^="memitem"]:has(+ tr.inherit_header) td.memItemLeft, +table.memberdecls tr[class^="memdesc"]:has(+ tr.groupHeader) td.mdescLeft, +table.memberdecls tr[class^="memdesc"]:has(+ tr.inherit_header) td.mdescLeft { + border-bottom-left-radius: 4px; +} + +table.memberdecls tr:last-child td.memItemRight, +table.memberdecls tr:last-child td.mdescRight, +table.memberdecls tr[class^="memitem"]:has(+ tr.groupHeader) td.memItemRight, +table.memberdecls tr[class^="memitem"]:has(+ tr.inherit_header) td.memItemRight, +table.memberdecls tr[class^="memdesc"]:has(+ tr.groupHeader) td.mdescRight, +table.memberdecls tr[class^="memdesc"]:has(+ tr.inherit_header) td.mdescRight { + border-bottom-right-radius: 4px; +} + +tr.template .memItemLeft, +tr.template .memItemRight { + border-top: none; + padding-top: 0; +} + +/* @end */ + +/* @group Member Details */ + +/* Styles for detailed member documentation */ + +.memtitle { + padding: 8px; + border-top: 1px solid var(--memdef-border-color); + border-left: 1px solid var(--memdef-border-color); + border-right: 1px solid var(--memdef-border-color); + border-top-right-radius: 4px; + border-top-left-radius: 4px; + margin-bottom: -1px; + background-color: var(--memdef-proto-background-color); + line-height: 1.25; + font-family: var(--font-family-monospace); + font-weight: 500; + font-size: 16px; + float: left; + box-shadow: + 0 10px 0 -1px var(--memdef-proto-background-color), + 0 2px 8px 0 rgba(0, 0, 0, 0.075); + position: relative; +} + +.memtitle:after { + content: ""; + display: block; + background: var(--memdef-proto-background-color); + height: 10px; + bottom: -10px; + left: 0px; + right: -14px; + position: absolute; + border-top-right-radius: 6px; +} + +.permalink { + font-family: var(--font-family-monospace); + font-weight: 500; + line-height: 1.25; + font-size: 16px; + display: inline-block; + vertical-align: middle; +} + +.memtemplate { + font-size: 80%; + color: var(--memdef-template-color); + font-family: var(--font-family-monospace); + font-weight: normal; + margin-left: 9px; +} + +.mempage { + width: 100%; +} + +.memitem { + padding: 0; + margin-bottom: 10px; + margin-right: 5px; + display: table !important; + width: 100%; + box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.075); + border-radius: 4px; +} + +.memitem.glow { + box-shadow: 0 0 15px var(--glow-color); +} + +.memname { + font-family: var(--font-family-monospace); + font-size: 13px; + font-weight: 400; + margin-left: 6px; +} + +.memname td { + vertical-align: bottom; +} + +.memproto, +dl.reflist dt { + border-top: 1px solid var(--memdef-border-color); + border-left: 1px solid var(--memdef-border-color); + border-right: 1px solid var(--memdef-border-color); + padding: 6px 0px 6px 0px; + color: var(--memdef-proto-text-color); + font-weight: bold; + background-color: var(--memdef-proto-background-color); + border-top-right-radius: 4px; + border-bottom: 1px solid var(--memdef-border-color); +} + +.overload { + font-family: var(--font-family-monospace); + font-size: 65%; +} + +.memdoc, +dl.reflist dd { + border-bottom: 1px solid var(--memdef-border-color); + border-left: 1px solid var(--memdef-border-color); + border-right: 1px solid var(--memdef-border-color); + padding: 6px 10px 2px 10px; + border-top-width: 0; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} + +dl.reflist dt { + padding: 5px; +} + +dl.reflist dd { + margin: 0px 0px 10px 0px; + padding: 5px; +} + +.paramkey { + text-align: right; +} + +.paramtype { + white-space: nowrap; + padding: 0px; + padding-bottom: 1px; +} + +.paramname { + white-space: nowrap; + padding: 0px; + padding-bottom: 1px; + margin-left: 2px; +} + +.paramname em { + color: var(--memdef-param-name-color); + font-style: normal; + margin-right: 1px; +} + +.paramname .paramdefval { + font-family: var(--font-family-monospace); +} + +.params, +.retval, +.exception, +.tparams { + margin-left: 0px; + padding-left: 0px; +} + +.params .paramname, +.retval .paramname, +.tparams .paramname, +.exception .paramname { + font-weight: bold; + vertical-align: top; +} + +.params .paramtype, +.tparams .paramtype { + font-style: italic; + vertical-align: top; +} + +.params .paramdir, +.tparams .paramdir { + font-family: var(--font-family-monospace); + vertical-align: top; +} + +table.mlabels { + border-spacing: 0px; +} + +td.mlabels-left { + width: 100%; + padding: 0px; +} + +td.mlabels-right { + vertical-align: bottom; + padding: 0px; + white-space: nowrap; +} + +span.mlabels { + margin-left: 8px; +} + +span.mlabel { + background-color: var(--label-background-color); + border-top: 1px solid var(--label-left-top-border-color); + border-left: 1px solid var(--label-left-top-border-color); + border-right: 1px solid var(--label-right-bottom-border-color); + border-bottom: 1px solid var(--label-right-bottom-border-color); + text-shadow: none; + color: var(--label-foreground-color); + margin-right: 4px; + padding: 2px 3px; + border-radius: 3px; + font-size: 7pt; + white-space: nowrap; + vertical-align: middle; +} + +/* @end */ + +/* these are for tree view inside a (index) page */ + +div.directory { + margin: 10px 0px; + width: 100%; +} + +.directory table { + border-collapse: collapse; +} + +.directory td { + margin: 0px; + padding: 0px; + vertical-align: top; +} + +.directory td.entry { + white-space: nowrap; + padding-right: 6px; + padding-top: 3px; +} + +.directory td.entry a { + outline: none; +} + +.directory td.entry a img { + border: none; +} + +.directory td.desc { + width: 100%; + padding-left: 6px; + padding-right: 6px; + padding-top: 3px; + border-left: 1px solid rgba(0, 0, 0, 0.05); +} + +.directory tr.odd { + padding-left: 6px; + background-color: var(--index-odd-item-bg-color); +} + +.directory tr.even { + padding-left: 6px; + background-color: var(--index-even-item-bg-color); +} + +.directory img { + vertical-align: -30%; +} + +.directory .levels { + white-space: nowrap; + width: 100%; + text-align: right; + font-size: 9pt; +} + +.directory .levels span { + cursor: pointer; + padding-left: 2px; + padding-right: 2px; + color: var(--page-link-color); +} + +.arrow { + color: var(--nav-background-color); + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: pointer; + font-size: 80%; + display: inline-block; + width: 16px; + height: 14px; + transition: opacity 0.3s ease; +} + +span.arrowhead { + position: relative; + padding: 0; + margin: 0 0 0 2px; + display: inline-block; + width: 5px; + height: 5px; + border-right: 2px solid var(--nav-arrow-color); + border-bottom: 2px solid var(--nav-arrow-color); + transform: rotate(-45deg); + transition: transform 0.3s ease; +} + +span.arrowhead.opened { + transform: rotate(45deg); +} + +.selected span.arrowhead { + border-right: 2px solid var(--nav-arrow-selected-color); + border-bottom: 2px solid var(--nav-arrow-selected-color); +} + +.icon { + font-family: var(--font-family-icon); + line-height: normal; + font-weight: bold; + font-size: 12px; + height: 14px; + width: 16px; + display: inline-block; + background-color: var(--icon-background-color); + color: var(--icon-foreground-color); + text-align: center; + border-radius: 4px; + margin-left: 2px; + margin-right: 2px; +} + +.icona { + width: 24px; + height: 22px; + display: inline-block; +} + +.iconfolder { + width: 24px; + height: 18px; + margin-top: 6px; + vertical-align: top; + display: inline-block; + position: relative; +} + +.icondoc { + width: 24px; + height: 18px; + margin-top: 3px; + vertical-align: top; + display: inline-block; + position: relative; +} + +.folder-icon { + width: 16px; + height: 11px; + background-color: var(--icon-folder-fill-color); + border: 1px solid var(--icon-folder-border-color); + border-radius: 0 2px 2px 2px; + position: relative; + box-sizing: content-box; +} + +.folder-icon::after { + content: ""; + position: absolute; + top: 2px; + left: -1px; + width: 16px; + height: 7px; + background-color: var(--icon-folder-open-fill-color); + border: 1px solid var(--icon-folder-border-color); + border-radius: 7px 7px 2px 2px; + transform-origin: top left; + opacity: 0; + transition: all 0.3s linear; +} + +.folder-icon::before { + content: ""; + position: absolute; + top: -3px; + left: -1px; + width: 6px; + height: 2px; + background-color: var(--icon-folder-fill-color); + border-top: 1px solid var(--icon-folder-border-color); + border-left: 1px solid var(--icon-folder-border-color); + border-right: 1px solid var(--icon-folder-border-color); + border-radius: 2px 2px 0 0; +} + +.folder-icon.open::after { + top: 3px; + opacity: 1; +} + +.doc-icon { + left: 6px; + width: 12px; + height: 16px; + background-color: var(--icon-doc-border-color); + clip-path: polygon(0 0, 66% 0, 100% 25%, 100% 100%, 0 100%); + position: relative; + display: inline-block; +} +.doc-icon::before { + content: ""; + left: 1px; + top: 1px; + width: 10px; + height: 14px; + background-color: var(--icon-doc-fill-color); + clip-path: polygon(0 0, 66% 0, 100% 25%, 100% 100%, 0 100%); + position: absolute; + box-sizing: border-box; +} +.doc-icon::after { + content: ""; + left: 7px; + top: 0px; + width: 3px; + height: 3px; + background-color: transparent; + position: absolute; + border: 1px solid var(--icon-doc-border-color); +} + +/* @end */ + +div.dynheader { + margin-top: 8px; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +span.dynarrow { + position: relative; + display: inline-block; + width: 12px; + bottom: 1px; +} + +address { + font-style: normal; + color: var(--footer-foreground-color); +} + +table.doxtable caption { + caption-side: top; +} + +table.doxtable { + border-collapse: collapse; + margin-top: 4px; + margin-bottom: 4px; +} + +table.doxtable td, +table.doxtable th { + border: 1px solid var(--table-cell-border-color); + padding: 3px 7px 2px; +} + +table.doxtable th { + background-color: var(--table-header-background-color); + color: var(--table-header-foreground-color); + font-size: 110%; + padding-bottom: 4px; + padding-top: 5px; +} + +table.fieldtable { + margin-bottom: 10px; + border: 1px solid var(--memdef-border-color); + border-spacing: 0px; + border-radius: 4px; + box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.15); +} + +.fieldtable td, +.fieldtable th { + padding: 3px 7px 2px; +} + +.fieldtable td.fieldtype, +.fieldtable td.fieldname, +.fieldtable td.fieldinit { + white-space: nowrap; + border-right: 1px solid var(--memdef-border-color); + border-bottom: 1px solid var(--memdef-border-color); + vertical-align: top; +} + +.fieldtable td.fieldname { + padding-top: 3px; +} + +.fieldtable td.fieldinit { + padding-top: 3px; + text-align: right; +} + +.fieldtable td.fielddoc { + border-bottom: 1px solid var(--memdef-border-color); +} + +.fieldtable td.fielddoc p:first-child { + margin-top: 0px; +} + +.fieldtable td.fielddoc p:last-child { + margin-bottom: 2px; +} + +.fieldtable tr:last-child td { + border-bottom: none; +} + +.fieldtable th { + background-color: var(--memdef-title-background-color); + font-size: 90%; + color: var(--memdef-proto-text-color); + padding-bottom: 4px; + padding-top: 5px; + text-align: left; + font-weight: 400; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom: 1px solid var(--memdef-border-color); +} + +/* ----------- navigation breadcrumb styling ----------- */ + +#nav-path ul { + height: 30px; + line-height: 30px; + color: var(--nav-text-normal-color); + overflow: hidden; + margin: 0px; + padding-left: 4px; + background-image: none; + background: var(--page-background-color); + border-bottom: 1px solid var(--nav-breadcrumb-separator-color); + font-size: var(--nav-font-size-level1); + font-family: var(--font-family-nav); + position: relative; + z-index: 100; +} + +#main-nav { + border-bottom: 1px solid var(--nav-border-color); +} + +.navpath li { + list-style-type: none; + float: left; + color: var(--nav-foreground-color); +} + +.navpath li.footer { + list-style-type: none; + float: right; + padding-left: 10px; + padding-right: 15px; + background-image: none; + background-repeat: no-repeat; + background-position: right; + font-size: 8pt; + color: var(--footer-foreground-color); +} + +#nav-path li.navelem { + background-image: none; + display: flex; + align-items: center; + padding-left: 15px; +} + +.navpath li.navelem a { + text-shadow: none; + display: inline-block; + color: var(--nav-breadcrumb-color); + position: relative; + top: 0px; + height: 30px; + margin-right: -20px; +} + +#nav-path li.navelem:after { + content: ""; + display: inline-block; + position: relative; + top: 0; + right: -15px; + width: 30px; + height: 30px; + transform: scaleX(0.5) scale(0.707) rotate(45deg); + z-index: 10; + background: var(--page-background-color); + box-shadow: 2px -2px 0 2px var(--nav-breadcrumb-separator-color); + border-radius: 0 5px 0 50px; +} + +#nav-path li.navelem:first-child { + margin-left: -6px; +} + +#nav-path li.navelem:hover, +#nav-path li.navelem:hover:after { + background-color: var(--nav-breadcrumb-active-bg); +} + +/* ---------------------- */ + +div.summary { + float: right; + font-size: 8pt; + padding-right: 5px; + width: 50%; + text-align: right; +} + +div.summary a { + white-space: nowrap; +} + +table.classindex { + margin: 10px; + white-space: nowrap; + margin-left: 3%; + margin-right: 3%; + width: 94%; + border: 0; + border-spacing: 0; + padding: 0; +} + +div.ingroups { + font-size: 8pt; + width: 50%; + text-align: left; +} + +div.ingroups a { + white-space: nowrap; +} + +div.header { + margin: 0px; + background-color: var(--header-background-color); + border-bottom: 1px solid var(--header-separator-color); +} + +div.headertitle { + padding: 5px 5px 5px 10px; +} + +dl { + padding: 0 0 0 0; +} + +dl.bug dt a, +dl.deprecated dt a, +dl.todo dt a, +dl.test a { + font-weight: bold !important; +} + +dl.warning, +dl.attention, +dl.important, +dl.note, +dl.deprecated, +dl.bug, +dl.invariant, +dl.pre, +dl.post, +dl.todo, +dl.test, +dl.remark { + padding: 10px; + margin: 10px 0px; + overflow: hidden; + margin-left: 0; + border-radius: 4px; +} + +dl.section dd { + margin-bottom: 2px; +} + +dl.warning, +dl.attention, +dl.important { + background: var(--warning-color-bg); + border-left: 8px solid var(--warning-color-hl); + color: var(--warning-color-text); +} + +dl.warning dt, +dl.attention dt, +dl.important dt { + color: var(--warning-color-hl); +} + +dl.note, +dl.remark { + background: var(--note-color-bg); + border-left: 8px solid var(--note-color-hl); + color: var(--note-color-text); +} + +dl.note dt, +dl.remark dt { + color: var(--note-color-hl); +} + +dl.todo { + background: var(--todo-color-bg); + border-left: 8px solid var(--todo-color-hl); + color: var(--todo-color-text); +} + +dl.todo dt { + color: var(--todo-color-hl); +} + +dl.test { + background: var(--test-color-bg); + border-left: 8px solid var(--test-color-hl); + color: var(--test-color-text); +} + +dl.test dt { + color: var(--test-color-hl); +} + +dl.bug dt a { + color: var(--bug-color-hl) !important; +} + +dl.bug { + background: var(--bug-color-bg); + border-left: 8px solid var(--bug-color-hl); + color: var(--bug-color-text); +} + +dl.bug dt a { + color: var(--bug-color-hl) !important; +} + +dl.deprecated { + background: var(--deprecated-color-bg); + border-left: 8px solid var(--deprecated-color-hl); + color: var(--deprecated-color-text); +} + +dl.deprecated dt a { + color: var(--deprecated-color-hl) !important; +} + +dl.note dd, +dl.warning dd, +dl.pre dd, +dl.post dd, +dl.remark dd, +dl.attention dd, +dl.important dd, +dl.invariant dd, +dl.bug dd, +dl.deprecated dd, +dl.todo dd, +dl.test dd { + margin-inline-start: 0px; +} + +dl.invariant, +dl.pre, +dl.post { + background: var(--invariant-color-bg); + border-left: 8px solid var(--invariant-color-hl); + color: var(--invariant-color-text); +} + +dl.invariant dt, +dl.pre dt, +dl.post dt { + color: var(--invariant-color-hl); +} + +#projectrow { + height: 56px; +} + +#projectlogo { + text-align: center; + vertical-align: bottom; + border-collapse: separate; +} + +#projectlogo img { + border: 0px none; +} + +#projectalign { + vertical-align: middle; + padding-left: 0.5em; +} + +#projectname { + font-size: 200%; + font-family: var(--font-family-title); + margin: 0; + padding: 0; +} + +#side-nav #projectname { + font-size: 130%; +} + +#projectbrief { + font-size: 90%; + font-family: var(--font-family-title); + margin: 0px; + padding: 0px; +} + +#projectnumber { + font-size: 50%; + font-family: var(--font-family-title); + margin: 0px; + padding: 0px; +} + +#titlearea { + padding: 0 0 0 5px; + margin: 0px; + border-bottom: 1px solid var(--title-separator-color); + background-color: var(--title-background-color); +} + +.image { + text-align: center; +} + +.dotgraph { + text-align: center; +} + +.mscgraph { + text-align: center; +} + +.plantumlgraph { + text-align: center; +} + +.diagraph { + text-align: center; +} + +.caption { + font-weight: bold; +} + +dl.citelist { + margin-bottom: 50px; +} + +dl.citelist dt { + color: var(--citation-label-color); + float: left; + font-weight: bold; + margin-right: 10px; + padding: 5px; + text-align: right; + width: 52px; +} + +dl.citelist dd { + margin: 2px 0 2px 72px; + padding: 5px 0; +} + +div.toc { + padding: 14px 25px; + background-color: var(--toc-background-color); + border: 1px solid var(--toc-border-color); + border-radius: 7px 7px 7px 7px; + float: right; + height: auto; + margin: 0 8px 10px 10px; + width: 200px; +} + +div.toc li { + background: var(--toc-down-arrow-image) no-repeat scroll 0 5px transparent; + font: 10px/1.2 var(--font-family-toc); + margin-top: 5px; + padding-left: 10px; + padding-top: 2px; +} + +div.toc h3 { + font: bold 12px/1.2 var(--font-family-toc); + color: var(--toc-header-color); + border-bottom: 0 none; + margin: 0; +} + +div.toc ul { + list-style: none outside none; + border: medium none; + padding: 0px; +} + +div.toc li[class^="level"] { + margin-left: 15px; +} + +div.toc li.level1 { + margin-left: 0px; +} + +div.toc li.empty { + background-image: none; + margin-top: 0px; +} + +span.emoji { + /* font family used at the site: https://unicode.org/emoji/charts/full-emoji-list.html + * font-family: "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", Times, Symbola, Aegyptus, Code2000, Code2001, Code2002, Musica, serif, LastResort; + */ +} + +span.obfuscator { + display: none; +} + +.inherit_header { + font-weight: 400; + cursor: pointer; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.inherit_header td { + padding: 6px 0 2px 0; +} + +.inherit { + display: none; +} + +tr.heading h2 { + margin-top: 12px; + margin-bottom: 12px; +} + +/* tooltip related style info */ + +.ttc { + position: absolute; + display: none; +} + +#powerTip { + cursor: default; + color: var(--tooltip-foreground-color); + background-color: var(--tooltip-background-color); + backdrop-filter: var(--tooltip-backdrop-filter); + -webkit-backdrop-filter: var(--tooltip-backdrop-filter); + border: 1px solid var(--tooltip-border-color); + border-radius: 4px; + box-shadow: var(--tooltip-shadow); + display: none; + font-size: smaller; + max-width: 80%; + padding: 1ex 1em 1em; + position: absolute; + z-index: 2147483647; +} + +#powerTip div.ttdoc { + color: var(--tooltip-doc-color); + font-style: italic; +} + +#powerTip div.ttname a { + font-weight: bold; +} + +#powerTip a { + color: var(--tooltip-link-color); +} + +#powerTip div.ttname { + font-weight: bold; +} + +#powerTip div.ttdeci { + color: var(--tooltip-declaration-color); +} + +#powerTip div { + margin: 0px; + padding: 0px; + font-size: 12px; + font-family: var(--font-family-tooltip); + line-height: 16px; +} + +#powerTip:before, +#powerTip:after { + content: ""; + position: absolute; + margin: 0px; +} + +#powerTip.n:after, +#powerTip.n:before, +#powerTip.s:after, +#powerTip.s:before, +#powerTip.w:after, +#powerTip.w:before, +#powerTip.e:after, +#powerTip.e:before, +#powerTip.ne:after, +#powerTip.ne:before, +#powerTip.se:after, +#powerTip.se:before, +#powerTip.nw:after, +#powerTip.nw:before, +#powerTip.sw:after, +#powerTip.sw:before { + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; +} + +#powerTip.n:after, +#powerTip.s:after, +#powerTip.w:after, +#powerTip.e:after, +#powerTip.nw:after, +#powerTip.ne:after, +#powerTip.sw:after, +#powerTip.se:after { + border-color: rgba(255, 255, 255, 0); +} + +#powerTip.n:before, +#powerTip.s:before, +#powerTip.w:before, +#powerTip.e:before, +#powerTip.nw:before, +#powerTip.ne:before, +#powerTip.sw:before, +#powerTip.se:before { + border-color: rgba(128, 128, 128, 0); +} + +#powerTip.n:after, +#powerTip.n:before, +#powerTip.ne:after, +#powerTip.ne:before, +#powerTip.nw:after, +#powerTip.nw:before { + top: 100%; +} + +#powerTip.n:after, +#powerTip.ne:after, +#powerTip.nw:after { + border-top-color: var(--tooltip-arrow-background-color); + border-width: 10px; + margin: 0px -10px; +} +#powerTip.n:before, +#powerTip.ne:before, +#powerTip.nw:before { + border-top-color: var(--tooltip-border-color); + border-width: 11px; + margin: 0px -11px; +} +#powerTip.n:after, +#powerTip.n:before { + left: 50%; +} + +#powerTip.nw:after, +#powerTip.nw:before { + right: 14px; +} + +#powerTip.ne:after, +#powerTip.ne:before { + left: 14px; +} + +#powerTip.s:after, +#powerTip.s:before, +#powerTip.se:after, +#powerTip.se:before, +#powerTip.sw:after, +#powerTip.sw:before { + bottom: 100%; +} + +#powerTip.s:after, +#powerTip.se:after, +#powerTip.sw:after { + border-bottom-color: var(--tooltip-arrow-background-color); + border-width: 10px; + margin: 0px -10px; +} + +#powerTip.s:before, +#powerTip.se:before, +#powerTip.sw:before { + border-bottom-color: var(--tooltip-border-color); + border-width: 11px; + margin: 0px -11px; +} + +#powerTip.s:after, +#powerTip.s:before { + left: 50%; +} + +#powerTip.sw:after, +#powerTip.sw:before { + right: 14px; +} + +#powerTip.se:after, +#powerTip.se:before { + left: 14px; +} + +#powerTip.e:after, +#powerTip.e:before { + left: 100%; +} +#powerTip.e:after { + border-left-color: var(--tooltip-border-color); + border-width: 10px; + top: 50%; + margin-top: -10px; +} +#powerTip.e:before { + border-left-color: var(--tooltip-border-color); + border-width: 11px; + top: 50%; + margin-top: -11px; +} + +#powerTip.w:after, +#powerTip.w:before { + right: 100%; +} +#powerTip.w:after { + border-right-color: var(--tooltip-border-color); + border-width: 10px; + top: 50%; + margin-top: -10px; +} +#powerTip.w:before { + border-right-color: var(--tooltip-border-color); + border-width: 11px; + top: 50%; + margin-top: -11px; +} + +@media print { + #top { + display: none; + } + #side-nav { + display: none; + } + #nav-path { + display: none; + } + body { + overflow: visible; + } + h1, + h2, + h3, + h4, + h5, + h6 { + page-break-after: avoid; + } + .summary { + display: none; + } + .memitem { + page-break-inside: avoid; + } + #doc-content { + margin-left: 0 !important; + height: auto !important; + width: auto !important; + overflow: inherit; + display: inline; + } +} + +/* @group Markdown */ + +table.markdownTable { + border-collapse: collapse; + margin-top: 4px; + margin-bottom: 4px; +} + +table.markdownTable td, +table.markdownTable th { + border: 1px solid var(--table-cell-border-color); + padding: 3px 7px 2px; +} + +table.markdownTable tr { +} + +th.markdownTableHeadLeft, +th.markdownTableHeadRight, +th.markdownTableHeadCenter, +th.markdownTableHeadNone { + background-color: var(--table-header-background-color); + color: var(--table-header-foreground-color); + font-size: 110%; + padding-bottom: 4px; + padding-top: 5px; +} + +th.markdownTableHeadLeft, +td.markdownTableBodyLeft { + text-align: left; +} + +th.markdownTableHeadRight, +td.markdownTableBodyRight { + text-align: right; +} + +th.markdownTableHeadCenter, +td.markdownTableBodyCenter { + text-align: center; +} + +tt, +code, +kbd { + display: inline-block; +} +tt, +code, +kbd { + vertical-align: top; +} +/* @end */ + +u { + text-decoration: underline; +} + +details > summary { + list-style-type: none; +} + +details > summary::-webkit-details-marker { + display: none; +} + +details > summary::before { + content: "\25ba"; + padding-right: 4px; + font-size: 80%; +} + +details[open] > summary::before { + content: "\25bc"; + padding-right: 4px; + font-size: 80%; +} + +:root { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb-color) + var(--scrollbar-background-color); +} + +::-webkit-scrollbar { + background-color: var(--scrollbar-background-color); + height: 12px; + width: 12px; +} +::-webkit-scrollbar-thumb { + border-radius: 6px; + box-shadow: inset 0 0 12px 12px var(--scrollbar-thumb-color); + border: solid 2px transparent; +} +::-webkit-scrollbar-corner { + background-color: var(--scrollbar-background-color); +} diff --git a/example/c-vt-key-encode/README.md b/example/c-vt-key-encode/README.md new file mode 100644 index 000000000..05ee3fc31 --- /dev/null +++ b/example/c-vt-key-encode/README.md @@ -0,0 +1,22 @@ +# Example: `ghostty-vt` C Key Encoding + +This example demonstrates how to use the `ghostty-vt` C library to encode key +events into terminal escape sequences. + +This example specifically shows how to: + +1. Create a key encoder with the C API +2. Configure Kitty keyboard protocol flags (this example uses KKP) +3. Create and configure a key event +4. Encode the key event into a terminal escape sequence + +The example encodes a Ctrl key release event with the Ctrl modifier set, +producing the escape sequence `\x1b[57442;5:3u`. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-key-encode/build.zig b/example/c-vt-key-encode/build.zig new file mode 100644 index 000000000..b4b759744 --- /dev/null +++ b/example/c-vt-key-encode/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_key_encode", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-key-encode/build.zig.zon b/example/c-vt-key-encode/build.zig.zon new file mode 100644 index 000000000..5da1a9168 --- /dev/null +++ b/example/c-vt-key-encode/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt, + .version = "0.0.0", + .fingerprint = 0x413a8529b1255f9a, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-key-encode/src/main.c b/example/c-vt-key-encode/src/main.c new file mode 100644 index 000000000..82444f99d --- /dev/null +++ b/example/c-vt-key-encode/src/main.c @@ -0,0 +1,59 @@ +#include +#include +#include +#include +#include + +int main() { + GhosttyKeyEncoder encoder; + GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); + assert(result == GHOSTTY_SUCCESS); + + // Set kitty flags with all features enabled + ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); + + // Create key event + GhosttyKeyEvent event; + result = ghostty_key_event_new(NULL, &event); + assert(result == GHOSTTY_SUCCESS); + ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_RELEASE); + ghostty_key_event_set_key(event, GHOSTTY_KEY_CONTROL_LEFT); + ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); + printf("Encoding event: left ctrl release with all Kitty flags enabled\n"); + + // Optionally, encode with null buffer to get required size. You can + // skip this step and provide a sufficiently large buffer directly. + // If there isn't enoug hspace, the function will return an out of memory + // error. + size_t required = 0; + result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); + assert(result == GHOSTTY_OUT_OF_MEMORY); + printf("Required buffer size: %zu bytes\n", required); + + // Encode the key event. We don't use our required size above because + // that was just an example; we know 128 bytes is enough. + char buf[128]; + size_t written = 0; + result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); + assert(result == GHOSTTY_SUCCESS); + printf("Encoded %zu bytes\n", written); + + // Print the encoded sequence (hex and string) + printf("Hex: "); + for (size_t i = 0; i < written; i++) printf("%02x ", (unsigned char)buf[i]); + printf("\n"); + + printf("String: "); + for (size_t i = 0; i < written; i++) { + if (buf[i] == 0x1b) { + printf("\\x1b"); + } else { + printf("%c", buf[i]); + } + } + printf("\n"); + + ghostty_key_event_free(event); + ghostty_key_encoder_free(encoder); + return 0; +} diff --git a/example/c-vt-paste/README.md b/example/c-vt-paste/README.md new file mode 100644 index 000000000..0f911771f --- /dev/null +++ b/example/c-vt-paste/README.md @@ -0,0 +1,17 @@ +# Example: `ghostty-vt` Paste Safety Check + +This contains a simple example of how to use the `ghostty-vt` paste +utilities to check if paste data is safe. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-paste/build.zig b/example/c-vt-paste/build.zig new file mode 100644 index 000000000..99b7ba771 --- /dev/null +++ b/example/c-vt-paste/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_paste", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-paste/build.zig.zon b/example/c-vt-paste/build.zig.zon new file mode 100644 index 000000000..fb78db9bc --- /dev/null +++ b/example/c-vt-paste/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_paste, + .version = "0.0.0", + .fingerprint = 0xa105002abbc8cf74, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-paste/src/main.c b/example/c-vt-paste/src/main.c new file mode 100644 index 000000000..153861ca9 --- /dev/null +++ b/example/c-vt-paste/src/main.c @@ -0,0 +1,31 @@ +#include +#include +#include + +int main() { + // Test safe paste data + const char *safe_data = "hello world"; + if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) { + printf("'%s' is safe to paste\n", safe_data); + } + + // Test unsafe paste data with newline + const char *unsafe_newline = "rm -rf /\n"; + if (!ghostty_paste_is_safe(unsafe_newline, strlen(unsafe_newline))) { + printf("'%s' is UNSAFE - contains newline\n", unsafe_newline); + } + + // Test unsafe paste data with bracketed paste end sequence + const char *unsafe_escape = "evil\x1b[201~code"; + if (!ghostty_paste_is_safe(unsafe_escape, strlen(unsafe_escape))) { + printf("Data with escape sequence is UNSAFE\n"); + } + + // Test empty data + const char *empty_data = ""; + if (ghostty_paste_is_safe(empty_data, 0)) { + printf("Empty data is safe\n"); + } + + return 0; +} diff --git a/example/c-vt-sgr/README.md b/example/c-vt-sgr/README.md new file mode 100644 index 000000000..c89e1aec9 --- /dev/null +++ b/example/c-vt-sgr/README.md @@ -0,0 +1,21 @@ +# Example: `ghostty-vt` SGR Parser + +This contains a simple example of how to use the `ghostty-vt` SGR parser +to parse terminal styling sequences and extract text attributes. + +This example demonstrates parsing a complex SGR sequence from Kakoune that +includes curly underline, RGB foreground/background colors, and RGB underline +color with mixed semicolon and colon separators. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-sgr/build.zig b/example/c-vt-sgr/build.zig new file mode 100644 index 000000000..ea6ea6e1e --- /dev/null +++ b/example/c-vt-sgr/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_sgr", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-sgr/build.zig.zon b/example/c-vt-sgr/build.zig.zon new file mode 100644 index 000000000..0d33b0897 --- /dev/null +++ b/example/c-vt-sgr/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_sgr, + .version = "0.0.0", + .fingerprint = 0x6e9c6d318e59c268, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-sgr/src/main.c b/example/c-vt-sgr/src/main.c new file mode 100644 index 000000000..21a529726 --- /dev/null +++ b/example/c-vt-sgr/src/main.c @@ -0,0 +1,131 @@ +#include +#include +#include + +int main() { + // Create parser + GhosttySgrParser parser; + GhosttyResult result = ghostty_sgr_new(NULL, &parser); + assert(result == GHOSTTY_SUCCESS); + + // Parse a complex SGR sequence from Kakoune + // This corresponds to the escape sequence: + // ESC[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136m + // + // Breaking down the sequence: + // - 4:3 = curly underline (colon-separated sub-parameters) + // - 38;2;51;51;51 = foreground RGB color (51, 51, 51) - dark gray + // - 48;2;170;170;170 = background RGB color (170, 170, 170) - light gray + // - 58;2;255;97;136 = underline RGB color (255, 97, 136) - pink + uint16_t params[] = {4, 3, 38, 2, 51, 51, 51, 48, 2, 170, 170, 170, 58, 2, 255, 97, 136}; + + // Separator array: ':' at position 0 (between 4 and 3), ';' elsewhere + char separators[] = ";;;;;;;;;;;;;;;;"; + separators[0] = ':'; + + result = ghostty_sgr_set_params(parser, params, separators, sizeof(params) / sizeof(params[0])); + assert(result == GHOSTTY_SUCCESS); + + printf("Parsing Kakoune SGR sequence:\n"); + printf("ESC[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136m\n\n"); + + // Iterate through attributes + GhosttySgrAttribute attr; + int count = 0; + while (ghostty_sgr_next(parser, &attr)) { + count++; + printf("Attribute %d: ", count); + + switch (attr.tag) { + case GHOSTTY_SGR_ATTR_UNDERLINE: + printf("Underline style = "); + switch (attr.value.underline) { + case GHOSTTY_SGR_UNDERLINE_NONE: + printf("none\n"); + break; + case GHOSTTY_SGR_UNDERLINE_SINGLE: + printf("single\n"); + break; + case GHOSTTY_SGR_UNDERLINE_DOUBLE: + printf("double\n"); + break; + case GHOSTTY_SGR_UNDERLINE_CURLY: + printf("curly\n"); + break; + case GHOSTTY_SGR_UNDERLINE_DOTTED: + printf("dotted\n"); + break; + case GHOSTTY_SGR_UNDERLINE_DASHED: + printf("dashed\n"); + break; + default: + printf("unknown (%d)\n", attr.value.underline); + break; + } + break; + + case GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG: + printf("Foreground RGB = (%d, %d, %d)\n", + attr.value.direct_color_fg.r, + attr.value.direct_color_fg.g, + attr.value.direct_color_fg.b); + break; + + case GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG: + printf("Background RGB = (%d, %d, %d)\n", + attr.value.direct_color_bg.r, + attr.value.direct_color_bg.g, + attr.value.direct_color_bg.b); + break; + + case GHOSTTY_SGR_ATTR_UNDERLINE_COLOR: + printf("Underline color RGB = (%d, %d, %d)\n", + attr.value.underline_color.r, + attr.value.underline_color.g, + attr.value.underline_color.b); + break; + + case GHOSTTY_SGR_ATTR_FG_8: + printf("Foreground 8-color = %d\n", attr.value.fg_8); + break; + + case GHOSTTY_SGR_ATTR_BG_8: + printf("Background 8-color = %d\n", attr.value.bg_8); + break; + + case GHOSTTY_SGR_ATTR_FG_256: + printf("Foreground 256-color = %d\n", attr.value.fg_256); + break; + + case GHOSTTY_SGR_ATTR_BG_256: + printf("Background 256-color = %d\n", attr.value.bg_256); + break; + + case GHOSTTY_SGR_ATTR_BOLD: + printf("Bold\n"); + break; + + case GHOSTTY_SGR_ATTR_ITALIC: + printf("Italic\n"); + break; + + case GHOSTTY_SGR_ATTR_UNSET: + printf("Reset all attributes\n"); + break; + + case GHOSTTY_SGR_ATTR_UNKNOWN: + printf("Unknown attribute\n"); + break; + + default: + printf("Other attribute (tag=%d)\n", attr.tag); + break; + } + } + + printf("\nTotal attributes parsed: %d\n", count); + + // Cleanup + ghostty_sgr_free(parser); + return 0; +} diff --git a/example/wasm-key-encode/README.md b/example/wasm-key-encode/README.md new file mode 100644 index 000000000..ccd906cf7 --- /dev/null +++ b/example/wasm-key-encode/README.md @@ -0,0 +1,40 @@ +# WebAssembly Key Encoder Example + +This example demonstrates how to use the Ghostty VT library from WebAssembly +to encode key events into terminal escape sequences. + +## Building + +First, build the WebAssembly module: + +```bash +zig build lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall +``` + +This will create `zig-out/bin/ghostty-vt.wasm`. + +## Running + +**Important:** You must serve this via HTTP, not open it as a file directly. +Browsers block loading WASM files from `file://` URLs. + +From the **root of the ghostty repository**, serve with a local HTTP server: + +```bash +# Using Python (recommended) +python3 -m http.server 8000 + +# Or using Node.js +npx serve . + +# Or using PHP +php -S localhost:8000 +``` + +Then open your browser to: + +``` +http://localhost:8000/example/wasm-key-encode/ +``` + +Focus the text input field and press any key combination to see the encoded output. diff --git a/example/wasm-key-encode/index.html b/example/wasm-key-encode/index.html new file mode 100644 index 000000000..9f4d8bebb --- /dev/null +++ b/example/wasm-key-encode/index.html @@ -0,0 +1,687 @@ + + + + + + Ghostty VT Key Encoder - WebAssembly Example + + + +

Ghostty VT Key Encoder - WebAssembly Example

+

This example demonstrates encoding key events into terminal escape sequences using the Ghostty VT WebAssembly module.

+ +
+ ⚠️ Warning: + This is an example of the libghostty-vt WebAssembly API. The JavaScript + keyboard event mapping to the libghostty-vt API may not be perfect + and may result in encoding inaccuracies for certain keys or layouts. + Do not use this as a key encoding reference. +
+ +
Loading WebAssembly module...
+ +
+

Key Action

+
+ + + +
+
+ +
+

Kitty Keyboard Protocol Flags

+
+ + + + + +
+
+ + + +
Waiting for key events...
+ +

Note: This example must be served via HTTP (not opened directly as a file). See the README for instructions.

+ + + + diff --git a/example/wasm-sgr/README.md b/example/wasm-sgr/README.md new file mode 100644 index 000000000..a107c910d --- /dev/null +++ b/example/wasm-sgr/README.md @@ -0,0 +1,39 @@ +# WebAssembly SGR Parser Example + +This example demonstrates how to use the Ghostty VT library from WebAssembly +to parse terminal SGR (Select Graphic Rendition) sequences and extract text +styling attributes. + +## Building + +First, build the WebAssembly module: + +```bash +zig build lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall +``` + +This will create `zig-out/bin/ghostty-vt.wasm`. + +## Running + +**Important:** You must serve this via HTTP, not open it as a file directly. +Browsers block loading WASM files from `file://` URLs. + +From the **root of the ghostty repository**, serve with a local HTTP server: + +```bash +# Using Python (recommended) +python3 -m http.server 8000 + +# Or using Node.js +npx serve . + +# Or using PHP +php -S localhost:8000 +``` + +Then open your browser to: + +``` +http://localhost:8000/example/wasm-sgr/ +``` diff --git a/example/wasm-sgr/index.html b/example/wasm-sgr/index.html new file mode 100644 index 000000000..e62b26c7e --- /dev/null +++ b/example/wasm-sgr/index.html @@ -0,0 +1,457 @@ + + + + + + Ghostty VT SGR Parser - WebAssembly Example + + + +

Ghostty VT SGR Parser - WebAssembly Example

+

This example demonstrates parsing terminal SGR (Select Graphic Rendition) sequences using the Ghostty VT WebAssembly module.

+ +
Loading WebAssembly module...
+ +
+

SGR Sequence

+ + +

The parser runs live as you type.

+
+ +
Waiting for input...
+ +

Note: This example must be served via HTTP (not opened directly as a file). See the README for instructions.

+ + + + diff --git a/example/zig-formatter/README.md b/example/zig-formatter/README.md new file mode 100644 index 000000000..777fa5d7f --- /dev/null +++ b/example/zig-formatter/README.md @@ -0,0 +1,24 @@ +# Example: stdin to HTML using `vtStream` and `TerminalFormatter` + +This example demonstrates how to read VT sequences from stdin, parse them +using `vtStream`, and output styled HTML using `TerminalFormatter`. The +purpose of this example is primarily to show how to use formatters with +terminals. + +Requires the Zig version stated in the `build.zig.zon` file. + +## Usage + +Basic usage: + +```shell-session +echo -e "Hello \033[1;32mGreen\033[0m World" | zig build run +``` + +This will output HTML with inline styles and CSS palette variables. + +You can also pipe complex terminal output: + +```shell-session +ls --color=always | zig build run > output.html +``` diff --git a/example/zig-formatter/build.zig b/example/zig-formatter/build.zig new file mode 100644 index 000000000..54cdb3ee0 --- /dev/null +++ b/example/zig-formatter/build.zig @@ -0,0 +1,39 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + const test_step = b.step("test", "Run unit tests"); + + const exe_mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + if (b.lazyDependency("ghostty", .{})) |dep| { + exe_mod.addImport( + "ghostty-vt", + dep.module("ghostty-vt"), + ); + } + + const exe = b.addExecutable(.{ + .name = "zig_formatter", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_module = exe_mod, + }); + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/example/zig-formatter/build.zig.zon b/example/zig-formatter/build.zig.zon new file mode 100644 index 000000000..9388a248f --- /dev/null +++ b/example/zig-formatter/build.zig.zon @@ -0,0 +1,13 @@ +.{ + .name = .zig_formatter, + .version = "0.0.0", + .fingerprint = 0x578de530797eafe6, + .dependencies = .{ + .ghostty = .{ .path = "../../" }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/zig-formatter/src/main.zig b/example/zig-formatter/src/main.zig new file mode 100644 index 000000000..ad101dbf1 --- /dev/null +++ b/example/zig-formatter/src/main.zig @@ -0,0 +1,42 @@ +const std = @import("std"); +const ghostty_vt = @import("ghostty-vt"); + +pub fn main() !void { + var gpa: std.heap.DebugAllocator(.{}) = .init; + defer _ = gpa.deinit(); + const alloc = gpa.allocator(); + + // Create a terminal + var t: ghostty_vt.Terminal = try .init(alloc, .{ .cols = 150, .rows = 80 }); + defer t.deinit(alloc); + + // Create a read-only VT stream for parsing terminal sequences + var stream = t.vtStream(); + defer stream.deinit(); + + // Read from stdin + const stdin = std.fs.File.stdin(); + var buf: [4096]u8 = undefined; + while (true) { + const n = try stdin.readAll(&buf); + if (n == 0) break; + + // Replace \n with \r\n + for (buf[0..n]) |byte| { + if (byte == '\n') try stream.next('\r'); + try stream.next(byte); + } + } + + // Use TerminalFormatter to emit HTML + const formatter: ghostty_vt.formatter.TerminalFormatter = .init(&t, .{ + .emit = .html, + .palette = &t.colors.palette.current, + }); + + // Write to stdout + var stdout_writer = std.fs.File.stdout().writer(&buf); + const stdout = &stdout_writer.interface; + try stdout.print("{f}", .{formatter}); + try stdout.flush(); +} diff --git a/example/zig-vt-stream/README.md b/example/zig-vt-stream/README.md new file mode 100644 index 000000000..d285009da --- /dev/null +++ b/example/zig-vt-stream/README.md @@ -0,0 +1,33 @@ +# Example: `vtStream` API for Parsing Terminal Streams + +This example demonstrates how to use the `vtStream` API to parse and process +VT sequences. The `vtStream` API is ideal for read-only terminal applications +that need to parse terminal output without responding to queries, such as: + +- Replay tooling +- CI log viewers +- PaaS builder output +- etc. + +The stream processes VT escape sequences and updates terminal state, while +ignoring sequences that require responses (like device status queries). + +Requires the Zig version stated in the `build.zig.zon` file. + +## Usage + +Run the program: + +```shell-session +zig build run +``` + +The example will process various VT sequences including: + +- Plain text output +- ANSI color codes +- Cursor positioning +- Line clearing +- Multiple line handling + +And display the final terminal state after processing all sequences. diff --git a/example/zig-vt-stream/build.zig b/example/zig-vt-stream/build.zig new file mode 100644 index 000000000..feee8f27d --- /dev/null +++ b/example/zig-vt-stream/build.zig @@ -0,0 +1,39 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + const test_step = b.step("test", "Run unit tests"); + + const exe_mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + if (b.lazyDependency("ghostty", .{})) |dep| { + exe_mod.addImport( + "ghostty-vt", + dep.module("ghostty-vt"), + ); + } + + const exe = b.addExecutable(.{ + .name = "zig_vt_stream", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_module = exe_mod, + }); + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/example/zig-vt-stream/build.zig.zon b/example/zig-vt-stream/build.zig.zon new file mode 100644 index 000000000..036c79592 --- /dev/null +++ b/example/zig-vt-stream/build.zig.zon @@ -0,0 +1,14 @@ +.{ + .name = .zig_vt_stream, + .version = "0.0.0", + .fingerprint = 0x34c1f71303690b3f, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + .ghostty = .{ .path = "../../" }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/zig-vt-stream/src/main.zig b/example/zig-vt-stream/src/main.zig new file mode 100644 index 000000000..8fd438b70 --- /dev/null +++ b/example/zig-vt-stream/src/main.zig @@ -0,0 +1,40 @@ +const std = @import("std"); +const ghostty_vt = @import("ghostty-vt"); + +pub fn main() !void { + var gpa: std.heap.DebugAllocator(.{}) = .init; + defer _ = gpa.deinit(); + const alloc = gpa.allocator(); + + var t: ghostty_vt.Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 }); + defer t.deinit(alloc); + + // Create a read-only VT stream for parsing terminal sequences + var stream = t.vtStream(); + defer stream.deinit(); + + // Basic text with newline + try stream.nextSlice("Hello, World!\r\n"); + + // ANSI color codes: ESC[1;32m = bold green, ESC[0m = reset + try stream.nextSlice("\x1b[1;32mGreen Text\x1b[0m\r\n"); + + // Cursor positioning: ESC[1;1H = move to row 1, column 1 + try stream.nextSlice("\x1b[1;1HTop-left corner\r\n"); + + // Cursor movement: ESC[5B = move down 5 lines + try stream.nextSlice("\x1b[5B"); + try stream.nextSlice("Moved down!\r\n"); + + // Erase line: ESC[2K = clear entire line + try stream.nextSlice("\x1b[2K"); + try stream.nextSlice("New content\r\n"); + + // Multiple lines + try stream.nextSlice("Line A\r\nLine B\r\nLine C\r\n"); + + // Get the final terminal state as a plain string + const str = try t.plainString(alloc); + defer alloc.free(str); + std.debug.print("{s}\n", .{str}); +} diff --git a/flake.lock b/flake.lock index 349248668..a80c2f8ae 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1747046372, - "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "lastModified": 1761588595, + "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=", "owner": "edolstra", "repo": "flake-compat", - "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", "type": "github" }, "original": { @@ -34,36 +34,45 @@ "type": "github" } }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1755776884, + "narHash": "sha256-CPM7zm6csUx7vSfKvzMDIjepEJv1u/usmaT7zydzbuI=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "4fb695d10890e9fc6a19deadf85ff79ffb78da86", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "release-25.05", + "repo": "home-manager", + "type": "github" + } + }, "nixpkgs": { "locked": { - "lastModified": 315532800, - "narHash": "sha256-YwoXN6fthkakCFD7nXPcUK+rkNr6ZTNTuF8zdGaxZo0=", - "rev": "dc704e6102e76aad573f63b74c742cd96f8f1e6c", + "lastModified": 1763191728, + "narHash": "sha256-gI9PpaoX4/f28HkjcTbFVpFhtOxSDtOEdFaHZrdETe0=", + "rev": "1d4c88323ac36805d09657d13a5273aea1b34f0c", "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre870318.dc704e6102e7/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre896415.1d4c88323ac3/nixexprs.tar.xz" }, "original": { "type": "tarball", "url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz" } }, - "nixpkgs_2": { - "locked": { - "lastModified": 1758360447, - "narHash": "sha256-XDY3A83bclygHDtesRoaRTafUd80Q30D/Daf9KSG6bs=", - "rev": "8eaee110344796db060382e15d3af0a9fc396e0e", - "type": "tarball", - "url": "https://releases.nixos.org/nixos/unstable/nixos-25.11pre864002.8eaee1103447/nixexprs.tar.xz" - }, - "original": { - "type": "tarball", - "url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz" - } - }, "root": { "inputs": { "flake-compat": "flake-compat", "flake-utils": "flake-utils", + "home-manager": "home-manager", "nixpkgs": "nixpkgs", "zig": "zig", "zon2nix": "zon2nix" @@ -97,11 +106,11 @@ ] }, "locked": { - "lastModified": 1759192380, - "narHash": "sha256-0BWJgt4OSzxCESij5oo8WLWrPZ+1qLp8KUQe32QeV4Q=", + "lastModified": 1763295135, + "narHash": "sha256-sGv/NHCmEnJivguGwB5w8LRmVqr1P72OjS+NzcJsssE=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "0bcd1401ed43d10f10cbded49624206553e92f57", + "rev": "64f8b42cfc615b2cf99144adf2b7728c7847c72a", "type": "github" }, "original": { @@ -112,7 +121,9 @@ }, "zon2nix": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": [ + "nixpkgs" + ] }, "locked": { "lastModified": 1758405547, diff --git a/flake.nix b/flake.nix index 18241a447..d70f23513 100644 --- a/flake.nix +++ b/flake.nix @@ -6,7 +6,9 @@ # glibc versions used by our dependencies from Nix are compatible with the # system glibc that the user is building for. # - # We are currently on unstable to get Zig 0.15 for our package.nix + # We are currently on nixpkgs-unstable to get Zig 0.15 for our package.nix and + # Gnome 49/Gtk 4.20. + # nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"; flake-utils.url = "github:numtide/flake-utils"; @@ -28,10 +30,14 @@ zon2nix = { url = "github:jcollie/zon2nix?rev=bf983aa90ff169372b9fa8c02e57ea75e0b42245"; inputs = { - # Don't override nixpkgs until Zig 0.15 is available in the Nix branch - # we are using for "normal" builds. - # - # nixpkgs.follows = "nixpkgs"; + nixpkgs.follows = "nixpkgs"; + }; + }; + + home-manager = { + url = "github:nix-community/home-manager?ref=release-25.05"; + inputs = { + nixpkgs.follows = "nixpkgs"; }; }; }; @@ -41,6 +47,7 @@ nixpkgs, zig, zon2nix, + home-manager, ... }: builtins.foldl' nixpkgs.lib.recursiveUpdate {} ( @@ -48,10 +55,18 @@ system: let pkgs = nixpkgs.legacyPackages.${system}; in { - devShell.${system} = pkgs.callPackage ./nix/devShell.nix { - zig = zig.packages.${system}."0.15.1"; - wraptest = pkgs.callPackage ./nix/wraptest.nix {}; + devShells.${system}.default = pkgs.callPackage ./nix/devShell.nix { + zig = zig.packages.${system}."0.15.2"; + wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {}; zon2nix = zon2nix; + + python3 = pkgs.python3.override { + self = pkgs.python3; + packageOverrides = pyfinal: pyprev: { + blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {}; + ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {}; + }; + }; }; packages.${system} = let @@ -72,6 +87,10 @@ formatter.${system} = pkgs.alejandra; + checks.${system} = import ./nix/tests.nix { + inherit home-manager nixpkgs self system; + }; + apps.${system} = let runVM = ( module: let @@ -88,6 +107,9 @@ in { type = "app"; program = "${program}"; + meta = { + description = "start a vm from ${toString module}"; + }; } ); in { @@ -107,17 +129,12 @@ overlays = { default = self.overlays.releasefast; releasefast = final: prev: { - ghostty = self.packages.${prev.system}.ghostty-releasefast; + ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-releasefast; }; debug = final: prev: { - ghostty = self.packages.${prev.system}.ghostty-debug; + ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-debug; }; }; - create-vm = import ./nix/vm/create.nix; - create-cinnamon-vm = import ./nix/vm/create-cinnamon.nix; - create-gnome-vm = import ./nix/vm/create-gnome.nix; - create-plasma6-vm = import ./nix/vm/create-plasma6.nix; - create-xfce-vm = import ./nix/vm/create-xfce.nix; }; nixConfig = { diff --git a/flatpak/dependencies.yml b/flatpak/dependencies.yml index 667e4662c..87512a547 100644 --- a/flatpak/dependencies.yml +++ b/flatpak/dependencies.yml @@ -13,12 +13,12 @@ modules: - chmod a+x /app/zig/zig sources: - type: archive - sha256: c61c5da6edeea14ca51ecd5e4520c6f4189ef5250383db33d01848293bfafe05 - url: https://ziglang.org/download/0.15.1/zig-x86_64-linux-0.15.1.tar.xz + sha256: 02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239 + url: https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz only-arches: [x86_64] - type: archive - sha256: bb4a8d2ad735e7fba764c497ddf4243cb129fece4148da3222a7046d3f1f19fe - url: https://ziglang.org/download/0.15.1/zig-aarch64-linux-0.15.1.tar.xz + sha256: 958ed7d1e00d0ea76590d27666efbf7a932281b3d7ba0c6b01b0ff26498f667f + url: https://ziglang.org/download/0.15.2/zig-aarch64-linux-0.15.2.tar.xz only-arches: [aarch64] - name: bzip2-redirect diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 58ef3c97a..21f79ec04 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,9 +31,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst", - "dest": "vendor/p/gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV", - "sha256": "4978aa1a6f356949facaad7015780d4c050b74a3874bf473929e4e80d924717a" + "url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", + "dest": "vendor/p/gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", + "sha256": "d9bd4306f0081d8e4b848b6adfeabd2fab49822ee2b679eb4801dcedf5d60c48" }, { "type": "archive", @@ -61,9 +61,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/iterm2_themes-release-20250922-150534-d28055b.tgz", - "dest": "vendor/p/N-V-__8AACEnAwCkyrJ_XqdomYlR8bpuh0l8WDidEz-BAzIv", - "sha256": "99d854c4002a2b14515f0103da569a6d4a3d659a996bf31902c3b4f98f4beee4" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", + "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" }, { "type": "archive", @@ -132,22 +132,22 @@ "sha256": "ffc668a310e77607d393f3c18b32715f223da1eac4c4d6e0579a11df8e6b59cf" }, { - "type": "archive", - "url": "https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz", - "dest": "vendor/p/uucode-0.1.0-ZZjBPjQHQADuCy1VMWftjrMl3iWqgMpUugWVQJG6_7xT", - "sha256": "56899260e17c7d12706fff06b551bf42a47a73de734a442de3ae3d0bfe0a5c07" + "type": "git", + "url": "https://github.com/jacobsandlund/uucode", + "commit": "5f05f8f83a75caea201f12cc8ea32a2d82ea9732", + "dest": "vendor/p/uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM" }, { "type": "archive", - "url": "https://deps.files.ghostty.org/vaxis-9fc9015d5f147568e18c5e7ca28f15bf8b293760.tar.gz", - "dest": "vendor/p/vaxis-0.5.1-BWNV_O8fCQAeUeVrESVc-2BdXloEXkFqReDJL7Q6XTSZ", - "sha256": "ec7e5ad09eee52caf394eec93407ff52cb22f56c6f9ac6f22529a1aaf97eaea2" + "url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "dest": "vendor/p/uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", + "sha256": "4b3a581a1806e01e8bb9ff1e4f7e6c28ba1b0b18e6c04ba8d53c24d237aef60e" }, { "type": "archive", - "url": "https://github.com/rockorager/libvaxis/archive/1bf887aa7e3736bad69fd4e277a378946edb0f2a.tar.gz", - "dest": "vendor/p/vaxis-0.5.1-BWNV_H0PCQAeMusmtLzh9P9xO2IW242GZ2IRe9iKYhcA", - "sha256": "7aae580b6e8e6348b671d409d195cc67ea36bc740b10534d1b342de59bb3e013" + "url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + "dest": "vendor/p/vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", + "sha256": "2e72332bc89c5b541ec6e6bd48769e1f3fb757c4006f3d1af940b54f9b088ef6" }, { "type": "archive", @@ -169,27 +169,15 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/z2d-a1237f6881d99b75abd8a20a934e62e34b44a005.tar.gz", - "dest": "vendor/p/z2d-0.8.2-pre-j5P_HlVRFgCsBTQ3EgUoKbYHx5JMnyH1mHsOSPiafnef", - "sha256": "e7fa91640221d54e36bfb8ea97d5b48ebdb3cd066dbb7f43c493cb56b4b26c98" + "url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", + "dest": "vendor/p/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", + "sha256": "f90a824685f0ac5035fe5fa85af615c8055e6c6422b401503618bd537115af10" }, { "type": "archive", - "url": "https://deps.files.ghostty.org/zf-52ad2e5528ab754f77437edf08a07b5ec843661c.tar.gz", - "dest": "vendor/p/zf-0.10.3-OIRy8QGJAACJcu3tCGtfbJnnd3Y4QL7OW_X8PJ8u_ASR", - "sha256": "f018a76da9d27d978103c481028a55c7024e6cddfafc14e9c551c004a89cb0c4" - }, - { - "type": "git", - "url": "https://codeberg.org/ivanstepanovftw/zg", - "commit": "4fe689e56ce2ed5a8f59308b471bccd7da89fac9", - "dest": "vendor/p/zg-0.14.1-oGqU3J4_tAKBfyes3AWleKDjo-IcYvnEwaB8qxOqFMwM" - }, - { - "type": "archive", - "url": "https://codeberg.org/chaten/zg/archive/749197a3f9d25e211615960c02380a3d659b20f9.tar.gz", - "dest": "vendor/p/zg-0.15.1-oGqU3M0-tALZCy7boQS86znlBloyKx6--JriGlY0Paa9", - "sha256": "059873d673eac4aea176c250eba9fb264e3332015218b5e6f1e534097ffb9832" + "url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + "dest": "vendor/p/zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", + "sha256": "3b015d928af04e9e26272bc15eb4dbb4d9a9d469eb6d290a0ddae673b77c4568" }, { "type": "archive", @@ -209,12 +197,6 @@ "dest": "vendor/p/wayland-0.5.0-dev-lQa1khrMAQDJDwYFKpdH3HizherB7sHo5dKMECfvxQHe", "sha256": "4f146b735ed0d527f520e3bf71d3e93f72c3d0fa583ae8edd3a4851f7079124e" }, - { - "type": "git", - "url": "https://github.com/ivanstepanovftw/zigimg", - "commit": "aa4c31db872612c39edbb79f753b3cd9a79fe726", - "dest": "vendor/p/zigimg-0.1.0-8_eo2mWmEgBoqdr0sH9O5GTqDHthkoEPM5_tipcBRreL" - }, { "type": "archive", "url": "https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz", diff --git a/include/ghostty.h b/include/ghostty.h index 48836ee96..b0395b89e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -45,6 +45,11 @@ typedef enum { GHOSTTY_CLIPBOARD_SELECTION, } ghostty_clipboard_e; +typedef struct { + const char *mime; + const char *data; +} ghostty_clipboard_content_s; + typedef enum { GHOSTTY_CLIPBOARD_REQUEST_PASTE, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, @@ -507,6 +512,12 @@ typedef enum { GHOSTTY_GOTO_SPLIT_RIGHT, } ghostty_action_goto_split_e; +// apprt.action.GotoWindow +typedef enum { + GHOSTTY_GOTO_WINDOW_PREVIOUS, + GHOSTTY_GOTO_WINDOW_NEXT, +} ghostty_action_goto_window_e; + // apprt.action.ResizeSplit.Direction typedef enum { GHOSTTY_RESIZE_SPLIT_UP, @@ -568,6 +579,12 @@ typedef enum { GHOSTTY_QUIT_TIMER_STOP, } ghostty_action_quit_timer_e; +// apprt.action.Readonly +typedef enum { + GHOSTTY_READONLY_OFF, + GHOSTTY_READONLY_ON, +} ghostty_action_readonly_e; + // apprt.action.DesktopNotification.C typedef struct { const char* title; @@ -579,6 +596,12 @@ typedef struct { const char* title; } ghostty_action_set_title_s; +// apprt.action.PromptTitle +typedef enum { + GHOSTTY_PROMPT_TITLE_SURFACE, + GHOSTTY_PROMPT_TITLE_TAB, +} ghostty_action_prompt_title_e; + // apprt.action.Pwd.C typedef struct { const char* pwd; @@ -695,6 +718,7 @@ typedef struct { typedef enum { GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, + GHOSTTY_ACTION_OPEN_URL_KIND_HTML, } ghostty_action_open_url_kind_e; // apprt.action.OpenUrl.C @@ -708,6 +732,7 @@ typedef struct { typedef enum { GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS, GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER, + GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT, } ghostty_action_close_tab_mode_e; // apprt.surface.Message.ChildExited @@ -741,6 +766,28 @@ 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; + uint64_t offset; + uint64_t len; +} ghostty_action_scrollbar_s; + // apprt.Action.Key typedef enum { GHOSTTY_ACTION_QUIT, @@ -759,6 +806,7 @@ typedef enum { GHOSTTY_ACTION_MOVE_TAB, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, + GHOSTTY_ACTION_GOTO_WINDOW, GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, @@ -767,6 +815,7 @@ typedef enum { GHOSTTY_ACTION_RESET_WINDOW_SIZE, GHOSTTY_ACTION_INITIAL_SIZE, GHOSTTY_ACTION_CELL_SIZE, + GHOSTTY_ACTION_SCROLLBAR, GHOSTTY_ACTION_RENDER, GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, @@ -797,7 +846,12 @@ typedef enum { GHOSTTY_ACTION_PROGRESS_REPORT, GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, GHOSTTY_ACTION_COMMAND_FINISHED, -} ghostty_action_tag_e; + GHOSTTY_ACTION_START_SEARCH, + GHOSTTY_ACTION_END_SEARCH, + GHOSTTY_ACTION_SEARCH_TOTAL, + GHOSTTY_ACTION_SEARCH_SELECTED, + GHOSTTY_ACTION_READONLY, + } ghostty_action_tag_e; typedef union { ghostty_action_split_direction_e new_split; @@ -805,13 +859,16 @@ typedef union { ghostty_action_move_tab_s move_tab; ghostty_action_goto_tab_e goto_tab; ghostty_action_goto_split_e goto_split; + ghostty_action_goto_window_e goto_window; ghostty_action_resize_split_s resize_split; ghostty_action_size_limit_s size_limit; ghostty_action_initial_size_s initial_size; ghostty_action_cell_size_s cell_size; + ghostty_action_scrollbar_s scrollbar; ghostty_action_inspector_e inspector; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; + ghostty_action_prompt_title_e prompt_title; ghostty_action_pwd_s pwd; ghostty_action_mouse_shape_e mouse_shape; ghostty_action_mouse_visibility_e mouse_visibility; @@ -829,6 +886,10 @@ 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_readonly_e readonly; } ghostty_action_u; typedef struct { @@ -846,8 +907,9 @@ typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( void*, ghostty_clipboard_request_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void*, - const char*, ghostty_clipboard_e, + const ghostty_clipboard_content_s*, + size_t, bool); typedef void (*ghostty_runtime_close_surface_cb)(void*, bool); typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t, diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 4b930a96f..4f8fef88e 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -1,7 +1,7 @@ /** * @file vt.h * - * libghostty-vt - Virtual terminal sequence parsing library + * libghostty-vt - Virtual terminal emulator library * * This library provides functionality for parsing and handling terminal * escape sequences as well as maintaining terminal state such as styles, @@ -12,14 +12,15 @@ */ /** - * @mainpage libghostty-vt - Virtual Terminal Sequence Parser + * @mainpage libghostty-vt - Virtual Terminal Emulator Library * * libghostty-vt is a C library which implements a modern terminal emulator, * extracted from the [Ghostty](https://ghostty.org) terminal emulator. * * libghostty-vt contains the logic for handling the core parts of a terminal - * emulator: parsing terminal escape sequences and maintaining terminal state. - * It can handle scrollback, line wrapping, reflow on resize, and more. + * emulator: parsing terminal escape sequences, maintaining terminal state, + * encoding input events, etc. It can handle scrollback, line wrapping, + * reflow on resize, and more. * * @warning This library is currently in development and the API is not yet stable. * Breaking changes are expected in future versions. Use with caution in production code. @@ -27,9 +28,41 @@ * @section groups_sec API Reference * * The API is organized into the following groups: + * - @ref key "Key Encoding" - Encode key events into terminal sequences * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences + * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences + * - @ref paste "Paste Utilities" - Validate paste data safety * - @ref allocator "Memory Management" - Memory management and custom allocators + * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions * + * @section examples_sec Examples + * + * Complete working examples: + * - @ref c-vt/src/main.c - OSC parser example + * - @ref c-vt-key-encode/src/main.c - Key encoding example + * - @ref c-vt-paste/src/main.c - Paste safety check example + * - @ref c-vt-sgr/src/main.c - SGR parser example + * + */ + +/** @example c-vt/src/main.c + * This example demonstrates how to use the OSC parser to parse an OSC sequence, + * extract command information, and retrieve command-specific data like window titles. + */ + +/** @example c-vt-key-encode/src/main.c + * This example demonstrates how to use the key encoder to convert key events + * into terminal escape sequences using the Kitty keyboard protocol. + */ + +/** @example c-vt-paste/src/main.c + * This example demonstrates how to use the paste utilities to check if + * paste data is safe before sending it to the terminal. + */ + +/** @example c-vt-sgr/src/main.c + * This example demonstrates how to use the SGR parser to parse terminal + * styling sequences and extract text attributes like colors and underline styles. */ #ifndef GHOSTTY_VT_H @@ -39,414 +72,13 @@ extern "C" { #endif -#include -#include -#include - -//------------------------------------------------------------------- -// Types - -/** - * Opaque handle to an OSC parser instance. - * - * This handle represents an OSC (Operating System Command) parser that can - * be used to parse the contents of OSC sequences. This isn't a full VT - * parser; it is only the OSC parser component. This is useful if you have - * a parser already and want to only extract and handle OSC sequences. - * - * @ingroup osc - */ -typedef struct GhosttyOscParser *GhosttyOscParser; - -/** - * Opaque handle to a single OSC command. - * - * This handle represents a parsed OSC (Operating System Command) command. - * The command can be queried for its type and associated data using - * `ghostty_osc_command_type` and `ghostty_osc_command_data`. - * - * @ingroup osc - */ -typedef struct GhosttyOscCommand *GhosttyOscCommand; - -/** - * Result codes for libghostty-vt operations. - */ -typedef enum { - /** Operation completed successfully */ - GHOSTTY_SUCCESS = 0, - /** Operation failed due to failed allocation */ - GHOSTTY_OUT_OF_MEMORY = -1, -} GhosttyResult; - -/** - * OSC command types. - * - * @ingroup osc - */ -typedef enum { - GHOSTTY_OSC_COMMAND_INVALID = 0, - GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, - GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, - GHOSTTY_OSC_COMMAND_PROMPT_START = 3, - GHOSTTY_OSC_COMMAND_PROMPT_END = 4, - GHOSTTY_OSC_COMMAND_END_OF_INPUT = 5, - GHOSTTY_OSC_COMMAND_END_OF_COMMAND = 6, - GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 7, - GHOSTTY_OSC_COMMAND_REPORT_PWD = 8, - GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 9, - GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 10, - GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 11, - GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 12, - GHOSTTY_OSC_COMMAND_HYPERLINK_START = 13, - GHOSTTY_OSC_COMMAND_HYPERLINK_END = 14, - GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 15, - GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 16, - GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 17, - GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 18, - GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 19, - GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20, -} GhosttyOscCommandType; - -/** - * OSC command data types. - * - * These values specify what type of data to extract from an OSC command - * using `ghostty_osc_command_data`. - * - * @ingroup osc - */ -typedef enum { - /** Invalid data type. Never results in any data extraction. */ - GHOSTTY_OSC_DATA_INVALID = 0, - - /** - * Window title string data. - * - * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE - * - * Output type: const char ** (pointer to null-terminated string) - * - * Lifetime: Valid until the next call to any ghostty_osc_* function with - * the same parser instance. Memory is owned by the parser. - */ - GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, -} GhosttyOscCommandData; - -//------------------------------------------------------------------- -// Allocator Interface - -/** @defgroup allocator Memory Management - * - * libghostty-vt does require memory allocation for various operations, - * but is resilient to allocation failures and will gracefully handle - * out-of-memory situations by returning error codes. - * - * The exact memory management semantics are documented in the relevant - * functions and data structures. - * - * libghostty-vt uses explicit memory allocation via an allocator - * interface provided by GhosttyAllocator. The interface is based on the - * [Zig](https://ziglang.org) allocator interface, since this has been - * shown to be a flexible and powerful interface in practice and enables - * a wide variety of allocation strategies. - * - * **For the common case, you can pass NULL as the allocator for any - * function that accepts one,** and libghostty will use a default allocator. - * The default allocator will be libc malloc/free if libc is linked. - * Otherwise, a custom allocator is used (currently Zig's SMP allocator) - * that doesn't require any external dependencies. - * - * ## Basic Usage - * - * For simple use cases, you can ignore this interface entirely by passing NULL - * as the allocator parameter to functions that accept one. This will use the - * default allocator (typically libc malloc/free, if libc is linked, but - * we provide our own default allocator if libc isn't linked). - * - * To use a custom allocator: - * 1. Implement the GhosttyAllocatorVtable function pointers - * 2. Create a GhosttyAllocator struct with your vtable and context - * 3. Pass the allocator to functions that accept one - * - * @{ - */ - -/** - * Function table for custom memory allocator operations. - * - * This vtable defines the interface for a custom memory allocator. All - * function pointers must be valid and non-NULL. - * - * @ingroup allocator - * - * If you're not going to use a custom allocator, you can ignore all of - * this. All functions that take an allocator pointer allow NULL to use a - * default allocator. - * - * The interface is based on the Zig allocator interface. I'll say up front - * that it is easy to look at this interface and think "wow, this is really - * overcomplicated". The reason for this complexity is well thought out by - * the Zig folks, and it enables a diverse set of allocation strategies - * as shown by the Zig ecosystem. As a consolation, please note that many - * of the arguments are only needed for advanced use cases and can be - * safely ignored in simple implementations. For example, if you look at - * the Zig implementation of the libc allocator in `lib/std/heap.zig` - * (search for CAllocator), you'll see it is very simple. - * - * We chose to align with the Zig allocator interface because: - * - * 1. It is a proven interface that serves a wide variety of use cases - * in the real world via the Zig ecosystem. It's shown to work. - * - * 2. Our core implementation itself is Zig, and this lets us very - * cheaply and easily convert between C and Zig allocators. - * - * NOTE(mitchellh): In the future, we can have default implementations of - * resize/remap and allow those to be null. - */ -typedef struct { - /** - * Return a pointer to `len` bytes with specified `alignment`, or return - * `NULL` indicating the allocation failed. - * - * @param ctx The allocator context - * @param len Number of bytes to allocate - * @param alignment Required alignment for the allocation. Guaranteed to - * be a power of two between 1 and 16 inclusive. - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return Pointer to allocated memory, or NULL if allocation failed - */ - void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr); - - /** - * Attempt to expand or shrink memory in place. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * `new_len` must be greater than zero. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to resize - * @param memory_len Current size of the memory block - * @param alignment Alignment (must match original allocation) - * @param new_len New requested size - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return true if resize was successful in-place, false if relocation would be required - */ - bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); - - /** - * Attempt to expand or shrink memory, allowing relocation. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * A non-`NULL` return value indicates the resize was successful. The - * allocation may have same address, or may have been relocated. In either - * case, the allocation now has size of `new_len`. A `NULL` return value - * indicates that the resize would be equivalent to allocating new memory, - * copying the bytes from the old memory, and then freeing the old memory. - * In such case, it is more efficient for the caller to perform the copy. - * - * `new_len` must be greater than zero. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to remap - * @param memory_len Current size of the memory block - * @param alignment Alignment (must match original allocation) - * @param new_len New requested size - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed - */ - void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); - - /** - * Free and invalidate a region of memory. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to free - * @param memory_len Size of the memory block - * @param alignment Alignment (must match original allocation) - * @param ret_addr First return address of the allocation call stack (0 if not provided) - */ - void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr); -} GhosttyAllocatorVtable; - -/** - * Custom memory allocator. - * - * For functions that take an allocator pointer, a NULL pointer indicates - * that the default allocator should be used. The default allocator will - * be libc malloc/free if we're linking to libc. If libc isn't linked, - * a custom allocator is used (currently Zig's SMP allocator). - * - * @ingroup allocator - * - * Usage example: - * @code - * GhosttyAllocator allocator = { - * .vtable = &my_allocator_vtable, - * .ctx = my_allocator_state - * }; - * @endcode - */ -typedef struct { - /** - * Opaque context pointer passed to all vtable functions. - * This allows the allocator implementation to maintain state - * or reference external resources needed for memory management. - */ - void *ctx; - - /** - * Pointer to the allocator's vtable containing function pointers - * for memory operations (alloc, resize, remap, free). - */ - const GhosttyAllocatorVtable *vtable; -} GhosttyAllocator; - -/** @} */ // end of allocator group - -//------------------------------------------------------------------- -// Functions - -/** @defgroup osc OSC Parser - * - * OSC (Operating System Command) sequence parser and command handling. - * - * The parser operates in a streaming fashion, processing input byte-by-byte - * to handle OSC sequences that may arrive in fragments across multiple reads. - * This interface makes it easy to integrate into most environments and avoids - * over-allocating buffers. - * - * ## Basic Usage - * - * 1. Create a parser instance with ghostty_osc_new() - * 2. Feed bytes to the parser using ghostty_osc_next() - * 3. Finalize parsing with ghostty_osc_end() to get the command - * 4. Query command type and extract data using ghostty_osc_command_type() - * and ghostty_osc_command_data() - * 5. Free the parser with ghostty_osc_free() when done - * - * @{ - */ - -/** - * Create a new OSC parser instance. - * - * Creates a new OSC (Operating System Command) parser using the provided - * allocator. The parser must be freed using ghostty_vt_osc_free() when - * no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param parser Pointer to store the created parser handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - */ -GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); - -/** - * Free an OSC parser instance. - * - * Releases all resources associated with the OSC parser. After this call, - * the parser handle becomes invalid and must not be used. - * - * @param parser The parser handle to free (may be NULL) - */ -void ghostty_osc_free(GhosttyOscParser parser); - -/** - * Reset an OSC parser instance to its initial state. - * - * Resets the parser state, clearing any partially parsed OSC sequences - * and returning the parser to its initial state. This is useful for - * reusing a parser instance or recovering from parse errors. - * - * @param parser The parser handle to reset, must not be null. - */ -void ghostty_osc_reset(GhosttyOscParser parser); - -/** - * Parse the next byte in an OSC sequence. - * - * Processes a single byte as part of an OSC sequence. The parser maintains - * internal state to track the progress through the sequence. Call this - * function for each byte in the sequence data. - * - * When finished pumping the parser with bytes, call ghostty_osc_end - * to get the final result. - * - * @param parser The parser handle, must not be null. - * @param byte The next byte to parse - */ -void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); - -/** - * Finalize OSC parsing and retrieve the parsed command. - * - * Call this function after feeding all bytes of an OSC sequence to the parser - * using ghostty_osc_next() with the exception of the terminating character - * (ESC or ST). This function finalizes the parsing process and returns the - * parsed OSC command. - * - * The return value is never NULL. Invalid commands will return a command - * with type GHOSTTY_OSC_COMMAND_INVALID. - * - * The terminator parameter specifies the byte that terminated the OSC sequence - * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is - * preserved in the parsed command so that responses can use the same terminator - * format for better compatibility with the calling program. For commands that - * do not require a response, this parameter is ignored and the resulting - * command will not retain the terminator information. - * - * The returned command handle is valid until the next call to any - * `ghostty_osc_*` function with the same parser instance with the exception - * of command introspection functions such as `ghostty_osc_command_type`. - * - * @param parser The parser handle, must not be null. - * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST) - * @return Handle to the parsed OSC command - */ -GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); - -/** - * Get the type of an OSC command. - * - * Returns the type identifier for the given OSC command. This can be used - * to determine what kind of command was parsed and what data might be - * available from it. - * - * @param command The OSC command handle to query (may be NULL) - * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL - */ -GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); - -/** - * Extract data from an OSC command. - * - * Extracts typed data from the given OSC command based on the specified - * data type. The output pointer must be of the appropriate type for the - * requested data kind. Valid command types, output types, and memory - * safety information are documented in the `GhosttyOscCommandData` enum. - * - * @param command The OSC command handle to query (may be NULL) - * @param data The type of data to extract - * @param out Pointer to store the extracted data (type depends on data parameter) - * @return true if data extraction was successful, false otherwise - */ -bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); - -/** @} */ // end of osc group +#include +#include +#include +#include +#include +#include +#include #ifdef __cplusplus } diff --git a/include/ghostty/vt/allocator.h b/include/ghostty/vt/allocator.h new file mode 100644 index 000000000..4cebe91bb --- /dev/null +++ b/include/ghostty/vt/allocator.h @@ -0,0 +1,196 @@ +/** + * @file allocator.h + * + * Memory management interface for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_ALLOCATOR_H +#define GHOSTTY_VT_ALLOCATOR_H + +#include +#include +#include + +/** @defgroup allocator Memory Management + * + * libghostty-vt does require memory allocation for various operations, + * but is resilient to allocation failures and will gracefully handle + * out-of-memory situations by returning error codes. + * + * The exact memory management semantics are documented in the relevant + * functions and data structures. + * + * libghostty-vt uses explicit memory allocation via an allocator + * interface provided by GhosttyAllocator. The interface is based on the + * [Zig](https://ziglang.org) allocator interface, since this has been + * shown to be a flexible and powerful interface in practice and enables + * a wide variety of allocation strategies. + * + * **For the common case, you can pass NULL as the allocator for any + * function that accepts one,** and libghostty will use a default allocator. + * The default allocator will be libc malloc/free if libc is linked. + * Otherwise, a custom allocator is used (currently Zig's SMP allocator) + * that doesn't require any external dependencies. + * + * ## Basic Usage + * + * For simple use cases, you can ignore this interface entirely by passing NULL + * as the allocator parameter to functions that accept one. This will use the + * default allocator (typically libc malloc/free, if libc is linked, but + * we provide our own default allocator if libc isn't linked). + * + * To use a custom allocator: + * 1. Implement the GhosttyAllocatorVtable function pointers + * 2. Create a GhosttyAllocator struct with your vtable and context + * 3. Pass the allocator to functions that accept one + * + * @{ + */ + +/** + * Function table for custom memory allocator operations. + * + * This vtable defines the interface for a custom memory allocator. All + * function pointers must be valid and non-NULL. + * + * @ingroup allocator + * + * If you're not going to use a custom allocator, you can ignore all of + * this. All functions that take an allocator pointer allow NULL to use a + * default allocator. + * + * The interface is based on the Zig allocator interface. I'll say up front + * that it is easy to look at this interface and think "wow, this is really + * overcomplicated". The reason for this complexity is well thought out by + * the Zig folks, and it enables a diverse set of allocation strategies + * as shown by the Zig ecosystem. As a consolation, please note that many + * of the arguments are only needed for advanced use cases and can be + * safely ignored in simple implementations. For example, if you look at + * the Zig implementation of the libc allocator in `lib/std/heap.zig` + * (search for CAllocator), you'll see it is very simple. + * + * We chose to align with the Zig allocator interface because: + * + * 1. It is a proven interface that serves a wide variety of use cases + * in the real world via the Zig ecosystem. It's shown to work. + * + * 2. Our core implementation itself is Zig, and this lets us very + * cheaply and easily convert between C and Zig allocators. + * + * NOTE(mitchellh): In the future, we can have default implementations of + * resize/remap and allow those to be null. + */ +typedef struct { + /** + * Return a pointer to `len` bytes with specified `alignment`, or return + * `NULL` indicating the allocation failed. + * + * @param ctx The allocator context + * @param len Number of bytes to allocate + * @param alignment Required alignment for the allocation. Guaranteed to + * be a power of two between 1 and 16 inclusive. + * @param ret_addr First return address of the allocation call stack (0 if not provided) + * @return Pointer to allocated memory, or NULL if allocation failed + */ + void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr); + + /** + * Attempt to expand or shrink memory in place. + * + * `memory_len` must equal the length requested from the most recent + * successful call to `alloc`, `resize`, or `remap`. `alignment` must + * equal the same value that was passed as the `alignment` parameter to + * the original `alloc` call. + * + * `new_len` must be greater than zero. + * + * @param ctx The allocator context + * @param memory Pointer to the memory block to resize + * @param memory_len Current size of the memory block + * @param alignment Alignment (must match original allocation) + * @param new_len New requested size + * @param ret_addr First return address of the allocation call stack (0 if not provided) + * @return true if resize was successful in-place, false if relocation would be required + */ + bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); + + /** + * Attempt to expand or shrink memory, allowing relocation. + * + * `memory_len` must equal the length requested from the most recent + * successful call to `alloc`, `resize`, or `remap`. `alignment` must + * equal the same value that was passed as the `alignment` parameter to + * the original `alloc` call. + * + * A non-`NULL` return value indicates the resize was successful. The + * allocation may have same address, or may have been relocated. In either + * case, the allocation now has size of `new_len`. A `NULL` return value + * indicates that the resize would be equivalent to allocating new memory, + * copying the bytes from the old memory, and then freeing the old memory. + * In such case, it is more efficient for the caller to perform the copy. + * + * `new_len` must be greater than zero. + * + * @param ctx The allocator context + * @param memory Pointer to the memory block to remap + * @param memory_len Current size of the memory block + * @param alignment Alignment (must match original allocation) + * @param new_len New requested size + * @param ret_addr First return address of the allocation call stack (0 if not provided) + * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed + */ + void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); + + /** + * Free and invalidate a region of memory. + * + * `memory_len` must equal the length requested from the most recent + * successful call to `alloc`, `resize`, or `remap`. `alignment` must + * equal the same value that was passed as the `alignment` parameter to + * the original `alloc` call. + * + * @param ctx The allocator context + * @param memory Pointer to the memory block to free + * @param memory_len Size of the memory block + * @param alignment Alignment (must match original allocation) + * @param ret_addr First return address of the allocation call stack (0 if not provided) + */ + void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr); +} GhosttyAllocatorVtable; + +/** + * Custom memory allocator. + * + * For functions that take an allocator pointer, a NULL pointer indicates + * that the default allocator should be used. The default allocator will + * be libc malloc/free if we're linking to libc. If libc isn't linked, + * a custom allocator is used (currently Zig's SMP allocator). + * + * @ingroup allocator + * + * Usage example: + * @code + * GhosttyAllocator allocator = { + * .vtable = &my_allocator_vtable, + * .ctx = my_allocator_state + * }; + * @endcode + */ +typedef struct GhosttyAllocator { + /** + * Opaque context pointer passed to all vtable functions. + * This allows the allocator implementation to maintain state + * or reference external resources needed for memory management. + */ + void *ctx; + + /** + * Pointer to the allocator's vtable containing function pointers + * for memory operations (alloc, resize, remap, free). + */ + const GhosttyAllocatorVtable *vtable; +} GhosttyAllocator; + +/** @} */ + +#endif /* GHOSTTY_VT_ALLOCATOR_H */ diff --git a/include/ghostty/vt/color.h b/include/ghostty/vt/color.h new file mode 100644 index 000000000..0d57b8db4 --- /dev/null +++ b/include/ghostty/vt/color.h @@ -0,0 +1,96 @@ +/** + * @file color.h + * + * Color types and utilities. + */ + +#ifndef GHOSTTY_VT_COLOR_H +#define GHOSTTY_VT_COLOR_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * RGB color value. + * + * @ingroup sgr + */ +typedef struct { + uint8_t r; /**< Red component (0-255) */ + uint8_t g; /**< Green component (0-255) */ + uint8_t b; /**< Blue component (0-255) */ +} GhosttyColorRgb; + +/** + * Palette color index (0-255). + * + * @ingroup sgr + */ +typedef uint8_t GhosttyColorPaletteIndex; + +/** @addtogroup sgr + * @{ + */ + +/** Black color (0) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BLACK 0 +/** Red color (1) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_RED 1 +/** Green color (2) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_GREEN 2 +/** Yellow color (3) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_YELLOW 3 +/** Blue color (4) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BLUE 4 +/** Magenta color (5) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_MAGENTA 5 +/** Cyan color (6) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_CYAN 6 +/** White color (7) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_WHITE 7 +/** Bright black color (8) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8 +/** Bright red color (9) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9 +/** Bright green color (10) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10 +/** Bright yellow color (11) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11 +/** Bright blue color (12) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12 +/** Bright magenta color (13) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13 +/** Bright cyan color (14) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14 +/** Bright white color (15) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15 + +/** @} */ + +/** + * Get the RGB color components. + * + * This function extracts the individual red, green, and blue components + * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments + * where accessing struct fields directly is difficult. + * + * @param color The RGB color value + * @param r Pointer to store the red component (0-255) + * @param g Pointer to store the green component (0-255) + * @param b Pointer to store the blue component (0-255) + * + * @ingroup sgr + */ +void ghostty_color_rgb_get(GhosttyColorRgb color, + uint8_t* r, + uint8_t* g, + uint8_t* b); + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_COLOR_H */ diff --git a/include/ghostty/vt/key.h b/include/ghostty/vt/key.h new file mode 100644 index 000000000..772b5d43b --- /dev/null +++ b/include/ghostty/vt/key.h @@ -0,0 +1,80 @@ +/** + * @file key.h + * + * Key encoding module - encode key events into terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_KEY_H +#define GHOSTTY_VT_KEY_H + +/** @defgroup key Key Encoding + * + * Utilities for encoding key events into terminal escape sequences, + * supporting both legacy encoding as well as Kitty Keyboard Protocol. + * + * ## Basic Usage + * + * 1. Create an encoder instance with ghostty_key_encoder_new() + * 2. Configure encoder options with ghostty_key_encoder_setopt(). + * 3. For each key event: + * - Create a key event with ghostty_key_event_new() + * - Set event properties (action, key, modifiers, etc.) + * - Encode with ghostty_key_encoder_encode() + * - Free the event with ghostty_key_event_free() + * - Note: You can also reuse the same key event multiple times by + * changing its properties. + * 4. Free the encoder with ghostty_key_encoder_free() when done + * + * ## Example + * + * @code{.c} + * #include + * #include + * #include + * + * int main() { + * // Create encoder + * GhosttyKeyEncoder encoder; + * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); + * assert(result == GHOSTTY_SUCCESS); + * + * // Enable Kitty keyboard protocol with all features + * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, + * &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); + * + * // Create and configure key event for Ctrl+C press + * GhosttyKeyEvent event; + * result = ghostty_key_event_new(NULL, &event); + * assert(result == GHOSTTY_SUCCESS); + * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); + * ghostty_key_event_set_key(event, GHOSTTY_KEY_C); + * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); + * + * // Encode the key event + * char buf[128]; + * size_t written = 0; + * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * assert(result == GHOSTTY_SUCCESS); + * + * // Use the encoded sequence (e.g., write to terminal) + * fwrite(buf, 1, written, stdout); + * + * // Cleanup + * ghostty_key_event_free(event); + * ghostty_key_encoder_free(encoder); + * return 0; + * } + * @endcode + * + * For a complete working example, see example/c-vt-key-encode in the + * repository. + * + * @{ + */ + +#include +#include + +/** @} */ + +#endif /* GHOSTTY_VT_KEY_H */ diff --git a/include/ghostty/vt/key/encoder.h b/include/ghostty/vt/key/encoder.h new file mode 100644 index 000000000..766a29427 --- /dev/null +++ b/include/ghostty/vt/key/encoder.h @@ -0,0 +1,221 @@ +/** + * @file encoder.h + * + * Key event encoding to terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_KEY_ENCODER_H +#define GHOSTTY_VT_KEY_ENCODER_H + +#include +#include +#include +#include +#include + +/** + * Opaque handle to a key encoder instance. + * + * This handle represents a key encoder that converts key events into terminal + * escape sequences. + * + * @ingroup key + */ +typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder; + +/** + * Kitty keyboard protocol flags. + * + * Bitflags representing the various modes of the Kitty keyboard protocol. + * These can be combined using bitwise OR operations. Valid values all + * start with `GHOSTTY_KITTY_KEY_`. + * + * @ingroup key + */ +typedef uint8_t GhosttyKittyKeyFlags; + +/** Kitty keyboard protocol disabled (all flags off) */ +#define GHOSTTY_KITTY_KEY_DISABLED 0 + +/** Disambiguate escape codes */ +#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0) + +/** Report key press and release events */ +#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1) + +/** Report alternate key codes */ +#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2) + +/** Report all key events including those normally handled by the terminal */ +#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3) + +/** Report associated text with key events */ +#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4) + +/** All Kitty keyboard protocol flags enabled */ +#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED) + +/** + * macOS option key behavior. + * + * Determines whether the "option" key on macOS is treated as "alt" or not. + * See the Ghostty `macos-option-as-alt` configuration option for more details. + * + * @ingroup key + */ +typedef enum { + /** Option key is not treated as alt */ + GHOSTTY_OPTION_AS_ALT_FALSE = 0, + /** Option key is treated as alt */ + GHOSTTY_OPTION_AS_ALT_TRUE = 1, + /** Only left option key is treated as alt */ + GHOSTTY_OPTION_AS_ALT_LEFT = 2, + /** Only right option key is treated as alt */ + GHOSTTY_OPTION_AS_ALT_RIGHT = 3, +} GhosttyOptionAsAlt; + +/** + * Key encoder option identifiers. + * + * These values are used with ghostty_key_encoder_setopt() to configure + * the behavior of the key encoder. + * + * @ingroup key + */ +typedef enum { + /** Terminal DEC mode 1: cursor key application mode (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, + + /** Terminal DEC mode 66: keypad key application mode (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1, + + /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2, + + /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3, + + /** xterm modifyOtherKeys mode 2 (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4, + + /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */ + GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5, + + /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ + GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, +} GhosttyKeyEncoderOption; + +/** + * Create a new key encoder instance. + * + * Creates a new key encoder with default options. The encoder can be configured + * using ghostty_key_encoder_setopt() and must be freed using + * ghostty_key_encoder_free() when no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator + * @param encoder Pointer to store the created encoder handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup key + */ +GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); + +/** + * Free a key encoder instance. + * + * Releases all resources associated with the key encoder. After this call, + * the encoder handle becomes invalid and must not be used. + * + * @param encoder The encoder handle to free (may be NULL) + * + * @ingroup key + */ +void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); + +/** + * Set an option on the key encoder. + * + * Configures the behavior of the key encoder. Options control various aspects + * of encoding such as terminal modes (cursor key application mode, keypad mode), + * protocol selection (Kitty keyboard protocol flags), and platform-specific + * behaviors (macOS option-as-alt). + * + * A null pointer value does nothing. It does not reset the value to the + * default. The setopt call will do nothing. + * + * @param encoder The encoder handle, must not be NULL + * @param option The option to set + * @param value Pointer to the value to set (type depends on the option) + * + * @ingroup key + */ +void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); + +/** + * Encode a key event into a terminal escape sequence. + * + * Converts a key event into the appropriate terminal escape sequence based on + * the encoder's current options. The sequence is written to the provided buffer. + * + * Not all key events produce output. For example, unmodified modifier keys + * typically don't generate escape sequences. Check the out_len parameter to + * determine if any data was written. + * + * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY + * and out_len will contain the required buffer size. The caller can then + * allocate a larger buffer and call the function again. + * + * @param encoder The encoder handle, must not be NULL + * @param event The key event to encode, must not be NULL + * @param out_buf Buffer to write the encoded sequence to + * @param out_buf_size Size of the output buffer in bytes + * @param out_len Pointer to store the number of bytes written (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code + * + * ## Example: Calculate required buffer size + * + * @code{.c} + * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY) + * size_t required = 0; + * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); + * assert(result == GHOSTTY_OUT_OF_MEMORY); + * + * // Allocate buffer of required size + * char *buf = malloc(required); + * + * // Encode with properly sized buffer + * size_t written = 0; + * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written); + * assert(result == GHOSTTY_SUCCESS); + * + * // Use the encoded sequence... + * + * free(buf); + * @endcode + * + * ## Example: Direct encoding with static buffer + * + * @code{.c} + * // Most escape sequences are short, so a static buffer often suffices + * char buf[128]; + * size_t written = 0; + * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * + * if (result == GHOSTTY_SUCCESS) { + * // Write the encoded sequence to the terminal + * write(pty_fd, buf, written); + * } else if (result == GHOSTTY_OUT_OF_MEMORY) { + * // Buffer too small, written contains required size + * char *dynamic_buf = malloc(written); + * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written); + * assert(result == GHOSTTY_SUCCESS); + * write(pty_fd, dynamic_buf, written); + * free(dynamic_buf); + * } + * @endcode + * + * @ingroup key + */ +GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); + +#endif /* GHOSTTY_VT_KEY_ENCODER_H */ diff --git a/include/ghostty/vt/key/event.h b/include/ghostty/vt/key/event.h new file mode 100644 index 000000000..dbd2e9f84 --- /dev/null +++ b/include/ghostty/vt/key/event.h @@ -0,0 +1,474 @@ +/** + * @file event.h + * + * Key event representation and manipulation. + */ + +#ifndef GHOSTTY_VT_KEY_EVENT_H +#define GHOSTTY_VT_KEY_EVENT_H + +#include +#include +#include +#include +#include + +/** + * Opaque handle to a key event. + * + * This handle represents a keyboard input event containing information about + * the physical key pressed, modifiers, and generated text. + * + * @ingroup key + */ +typedef struct GhosttyKeyEvent *GhosttyKeyEvent; + +/** + * Keyboard input event types. + * + * @ingroup key + */ +typedef enum { + /** Key was released */ + GHOSTTY_KEY_ACTION_RELEASE = 0, + /** Key was pressed */ + GHOSTTY_KEY_ACTION_PRESS = 1, + /** Key is being repeated (held down) */ + GHOSTTY_KEY_ACTION_REPEAT = 2, +} GhosttyKeyAction; + +/** + * Keyboard modifier keys bitmask. + * + * A bitmask representing all keyboard modifiers. This tracks which modifier keys + * are pressed and, where supported by the platform, which side (left or right) + * of each modifier is active. + * + * Use the GHOSTTY_MODS_* constants to test and set individual modifiers. + * + * Modifier side bits are only meaningful when the corresponding modifier bit is set. + * Not all platforms support distinguishing between left and right modifier + * keys and Ghostty is built to expect that some platforms may not provide this + * information. + * + * @ingroup key + */ +typedef uint16_t GhosttyMods; + +/** Shift key is pressed */ +#define GHOSTTY_MODS_SHIFT (1 << 0) +/** Control key is pressed */ +#define GHOSTTY_MODS_CTRL (1 << 1) +/** Alt/Option key is pressed */ +#define GHOSTTY_MODS_ALT (1 << 2) +/** Super/Command/Windows key is pressed */ +#define GHOSTTY_MODS_SUPER (1 << 3) +/** Caps Lock is active */ +#define GHOSTTY_MODS_CAPS_LOCK (1 << 4) +/** Num Lock is active */ +#define GHOSTTY_MODS_NUM_LOCK (1 << 5) + +/** + * Right shift is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_SHIFT is set. + */ +#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6) +/** + * Right ctrl is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_CTRL is set. + */ +#define GHOSTTY_MODS_CTRL_SIDE (1 << 7) +/** + * Right alt is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_ALT is set. + */ +#define GHOSTTY_MODS_ALT_SIDE (1 << 8) +/** + * Right super is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_SUPER is set. + */ +#define GHOSTTY_MODS_SUPER_SIDE (1 << 9) + +/** + * Physical key codes. + * + * The set of key codes that Ghostty is aware of. These represent physical keys + * on the keyboard and are layout-independent. For example, the "a" key on a US + * keyboard is the same as the "ф" key on a Russian keyboard, but both will + * report the same key_a value. + * + * Layout-dependent strings are provided separately as UTF-8 text and are produced + * by the platform. These values are based on the W3C UI Events KeyboardEvent code + * standard. See: https://www.w3.org/TR/uievents-code + * + * @ingroup key + */ +typedef enum { + GHOSTTY_KEY_UNIDENTIFIED = 0, + + // Writing System Keys (W3C § 3.1.1) + GHOSTTY_KEY_BACKQUOTE, + GHOSTTY_KEY_BACKSLASH, + GHOSTTY_KEY_BRACKET_LEFT, + GHOSTTY_KEY_BRACKET_RIGHT, + GHOSTTY_KEY_COMMA, + GHOSTTY_KEY_DIGIT_0, + GHOSTTY_KEY_DIGIT_1, + GHOSTTY_KEY_DIGIT_2, + GHOSTTY_KEY_DIGIT_3, + GHOSTTY_KEY_DIGIT_4, + GHOSTTY_KEY_DIGIT_5, + GHOSTTY_KEY_DIGIT_6, + GHOSTTY_KEY_DIGIT_7, + GHOSTTY_KEY_DIGIT_8, + GHOSTTY_KEY_DIGIT_9, + GHOSTTY_KEY_EQUAL, + GHOSTTY_KEY_INTL_BACKSLASH, + GHOSTTY_KEY_INTL_RO, + GHOSTTY_KEY_INTL_YEN, + GHOSTTY_KEY_A, + GHOSTTY_KEY_B, + GHOSTTY_KEY_C, + GHOSTTY_KEY_D, + GHOSTTY_KEY_E, + GHOSTTY_KEY_F, + GHOSTTY_KEY_G, + GHOSTTY_KEY_H, + GHOSTTY_KEY_I, + GHOSTTY_KEY_J, + GHOSTTY_KEY_K, + GHOSTTY_KEY_L, + GHOSTTY_KEY_M, + GHOSTTY_KEY_N, + GHOSTTY_KEY_O, + GHOSTTY_KEY_P, + GHOSTTY_KEY_Q, + GHOSTTY_KEY_R, + GHOSTTY_KEY_S, + GHOSTTY_KEY_T, + GHOSTTY_KEY_U, + GHOSTTY_KEY_V, + GHOSTTY_KEY_W, + GHOSTTY_KEY_X, + GHOSTTY_KEY_Y, + GHOSTTY_KEY_Z, + GHOSTTY_KEY_MINUS, + GHOSTTY_KEY_PERIOD, + GHOSTTY_KEY_QUOTE, + GHOSTTY_KEY_SEMICOLON, + GHOSTTY_KEY_SLASH, + + // Functional Keys (W3C § 3.1.2) + GHOSTTY_KEY_ALT_LEFT, + GHOSTTY_KEY_ALT_RIGHT, + GHOSTTY_KEY_BACKSPACE, + GHOSTTY_KEY_CAPS_LOCK, + GHOSTTY_KEY_CONTEXT_MENU, + GHOSTTY_KEY_CONTROL_LEFT, + GHOSTTY_KEY_CONTROL_RIGHT, + GHOSTTY_KEY_ENTER, + GHOSTTY_KEY_META_LEFT, + GHOSTTY_KEY_META_RIGHT, + GHOSTTY_KEY_SHIFT_LEFT, + GHOSTTY_KEY_SHIFT_RIGHT, + GHOSTTY_KEY_SPACE, + GHOSTTY_KEY_TAB, + GHOSTTY_KEY_CONVERT, + GHOSTTY_KEY_KANA_MODE, + GHOSTTY_KEY_NON_CONVERT, + + // Control Pad Section (W3C § 3.2) + GHOSTTY_KEY_DELETE, + GHOSTTY_KEY_END, + GHOSTTY_KEY_HELP, + GHOSTTY_KEY_HOME, + GHOSTTY_KEY_INSERT, + GHOSTTY_KEY_PAGE_DOWN, + GHOSTTY_KEY_PAGE_UP, + + // Arrow Pad Section (W3C § 3.3) + GHOSTTY_KEY_ARROW_DOWN, + GHOSTTY_KEY_ARROW_LEFT, + GHOSTTY_KEY_ARROW_RIGHT, + GHOSTTY_KEY_ARROW_UP, + + // Numpad Section (W3C § 3.4) + GHOSTTY_KEY_NUM_LOCK, + GHOSTTY_KEY_NUMPAD_0, + GHOSTTY_KEY_NUMPAD_1, + GHOSTTY_KEY_NUMPAD_2, + GHOSTTY_KEY_NUMPAD_3, + GHOSTTY_KEY_NUMPAD_4, + GHOSTTY_KEY_NUMPAD_5, + GHOSTTY_KEY_NUMPAD_6, + GHOSTTY_KEY_NUMPAD_7, + GHOSTTY_KEY_NUMPAD_8, + GHOSTTY_KEY_NUMPAD_9, + GHOSTTY_KEY_NUMPAD_ADD, + GHOSTTY_KEY_NUMPAD_BACKSPACE, + GHOSTTY_KEY_NUMPAD_CLEAR, + GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, + GHOSTTY_KEY_NUMPAD_COMMA, + GHOSTTY_KEY_NUMPAD_DECIMAL, + GHOSTTY_KEY_NUMPAD_DIVIDE, + GHOSTTY_KEY_NUMPAD_ENTER, + GHOSTTY_KEY_NUMPAD_EQUAL, + GHOSTTY_KEY_NUMPAD_MEMORY_ADD, + GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, + GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, + GHOSTTY_KEY_NUMPAD_MEMORY_STORE, + GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, + GHOSTTY_KEY_NUMPAD_MULTIPLY, + GHOSTTY_KEY_NUMPAD_PAREN_LEFT, + GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, + GHOSTTY_KEY_NUMPAD_SUBTRACT, + GHOSTTY_KEY_NUMPAD_SEPARATOR, + GHOSTTY_KEY_NUMPAD_UP, + GHOSTTY_KEY_NUMPAD_DOWN, + GHOSTTY_KEY_NUMPAD_RIGHT, + GHOSTTY_KEY_NUMPAD_LEFT, + GHOSTTY_KEY_NUMPAD_BEGIN, + GHOSTTY_KEY_NUMPAD_HOME, + GHOSTTY_KEY_NUMPAD_END, + GHOSTTY_KEY_NUMPAD_INSERT, + GHOSTTY_KEY_NUMPAD_DELETE, + GHOSTTY_KEY_NUMPAD_PAGE_UP, + GHOSTTY_KEY_NUMPAD_PAGE_DOWN, + + // Function Section (W3C § 3.5) + GHOSTTY_KEY_ESCAPE, + GHOSTTY_KEY_F1, + GHOSTTY_KEY_F2, + GHOSTTY_KEY_F3, + GHOSTTY_KEY_F4, + GHOSTTY_KEY_F5, + GHOSTTY_KEY_F6, + GHOSTTY_KEY_F7, + GHOSTTY_KEY_F8, + GHOSTTY_KEY_F9, + GHOSTTY_KEY_F10, + GHOSTTY_KEY_F11, + GHOSTTY_KEY_F12, + GHOSTTY_KEY_F13, + GHOSTTY_KEY_F14, + GHOSTTY_KEY_F15, + GHOSTTY_KEY_F16, + GHOSTTY_KEY_F17, + GHOSTTY_KEY_F18, + GHOSTTY_KEY_F19, + GHOSTTY_KEY_F20, + GHOSTTY_KEY_F21, + GHOSTTY_KEY_F22, + GHOSTTY_KEY_F23, + GHOSTTY_KEY_F24, + GHOSTTY_KEY_F25, + GHOSTTY_KEY_FN, + GHOSTTY_KEY_FN_LOCK, + GHOSTTY_KEY_PRINT_SCREEN, + GHOSTTY_KEY_SCROLL_LOCK, + GHOSTTY_KEY_PAUSE, + + // Media Keys (W3C § 3.6) + GHOSTTY_KEY_BROWSER_BACK, + GHOSTTY_KEY_BROWSER_FAVORITES, + GHOSTTY_KEY_BROWSER_FORWARD, + GHOSTTY_KEY_BROWSER_HOME, + GHOSTTY_KEY_BROWSER_REFRESH, + GHOSTTY_KEY_BROWSER_SEARCH, + GHOSTTY_KEY_BROWSER_STOP, + GHOSTTY_KEY_EJECT, + GHOSTTY_KEY_LAUNCH_APP_1, + GHOSTTY_KEY_LAUNCH_APP_2, + GHOSTTY_KEY_LAUNCH_MAIL, + GHOSTTY_KEY_MEDIA_PLAY_PAUSE, + GHOSTTY_KEY_MEDIA_SELECT, + GHOSTTY_KEY_MEDIA_STOP, + GHOSTTY_KEY_MEDIA_TRACK_NEXT, + GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, + GHOSTTY_KEY_POWER, + GHOSTTY_KEY_SLEEP, + GHOSTTY_KEY_AUDIO_VOLUME_DOWN, + GHOSTTY_KEY_AUDIO_VOLUME_MUTE, + GHOSTTY_KEY_AUDIO_VOLUME_UP, + GHOSTTY_KEY_WAKE_UP, + + // Legacy, Non-standard, and Special Keys (W3C § 3.7) + GHOSTTY_KEY_COPY, + GHOSTTY_KEY_CUT, + GHOSTTY_KEY_PASTE, +} GhosttyKey; + +/** + * Create a new key event instance. + * + * Creates a new key event with default values. The event must be freed using + * ghostty_key_event_free() when no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator + * @param event Pointer to store the created key event handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup key + */ +GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); + +/** + * Free a key event instance. + * + * Releases all resources associated with the key event. After this call, + * the event handle becomes invalid and must not be used. + * + * @param event The key event handle to free (may be NULL) + * + * @ingroup key + */ +void ghostty_key_event_free(GhosttyKeyEvent event); + +/** + * Set the key action (press, release, repeat). + * + * @param event The key event handle, must not be NULL + * @param action The action to set + * + * @ingroup key + */ +void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); + +/** + * Get the key action (press, release, repeat). + * + * @param event The key event handle, must not be NULL + * @return The key action + * + * @ingroup key + */ +GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); + +/** + * Set the physical key code. + * + * @param event The key event handle, must not be NULL + * @param key The physical key code to set + * + * @ingroup key + */ +void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); + +/** + * Get the physical key code. + * + * @param event The key event handle, must not be NULL + * @return The physical key code + * + * @ingroup key + */ +GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); + +/** + * Set the modifier keys bitmask. + * + * @param event The key event handle, must not be NULL + * @param mods The modifier keys bitmask to set + * + * @ingroup key + */ +void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); + +/** + * Get the modifier keys bitmask. + * + * @param event The key event handle, must not be NULL + * @return The modifier keys bitmask + * + * @ingroup key + */ +GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); + +/** + * Set the consumed modifiers bitmask. + * + * @param event The key event handle, must not be NULL + * @param consumed_mods The consumed modifiers bitmask to set + * + * @ingroup key + */ +void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); + +/** + * Get the consumed modifiers bitmask. + * + * @param event The key event handle, must not be NULL + * @return The consumed modifiers bitmask + * + * @ingroup key + */ +GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); + +/** + * Set whether the key event is part of a composition sequence. + * + * @param event The key event handle, must not be NULL + * @param composing Whether the key event is part of a composition sequence + * + * @ingroup key + */ +void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); + +/** + * Get whether the key event is part of a composition sequence. + * + * @param event The key event handle, must not be NULL + * @return Whether the key event is part of a composition sequence + * + * @ingroup key + */ +bool ghostty_key_event_get_composing(GhosttyKeyEvent event); + +/** + * Set the UTF-8 text generated by the key event. + * + * The key event does NOT take ownership of the text pointer. The caller + * must ensure the string remains valid for the lifetime needed by the event. + * + * @param event The key event handle, must not be NULL + * @param utf8 The UTF-8 text to set (or NULL for empty) + * @param len Length of the UTF-8 text in bytes + * + * @ingroup key + */ +void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); + +/** + * Get the UTF-8 text generated by the key event. + * + * The returned pointer is valid until the event is freed or the UTF-8 text is modified. + * + * @param event The key event handle, must not be NULL + * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL) + * @return The UTF-8 text (or NULL for empty) + * + * @ingroup key + */ +const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); + +/** + * Set the unshifted Unicode codepoint. + * + * @param event The key event handle, must not be NULL + * @param codepoint The unshifted Unicode codepoint to set + * + * @ingroup key + */ +void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); + +/** + * Get the unshifted Unicode codepoint. + * + * @param event The key event handle, must not be NULL + * @return The unshifted Unicode codepoint + * + * @ingroup key + */ +uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); + +#endif /* GHOSTTY_VT_KEY_EVENT_H */ diff --git a/include/ghostty/vt/osc.h b/include/ghostty/vt/osc.h new file mode 100644 index 000000000..7e2c8f322 --- /dev/null +++ b/include/ghostty/vt/osc.h @@ -0,0 +1,231 @@ +/** + * @file osc.h + * + * OSC (Operating System Command) sequence parser and command handling. + */ + +#ifndef GHOSTTY_VT_OSC_H +#define GHOSTTY_VT_OSC_H + +#include +#include +#include +#include +#include + +/** + * Opaque handle to an OSC parser instance. + * + * This handle represents an OSC (Operating System Command) parser that can + * be used to parse the contents of OSC sequences. + * + * @ingroup osc + */ +typedef struct GhosttyOscParser *GhosttyOscParser; + +/** + * Opaque handle to a single OSC command. + * + * This handle represents a parsed OSC (Operating System Command) command. + * The command can be queried for its type and associated data. + * + * @ingroup osc + */ +typedef struct GhosttyOscCommand *GhosttyOscCommand; + +/** @defgroup osc OSC Parser + * + * OSC (Operating System Command) sequence parser and command handling. + * + * The parser operates in a streaming fashion, processing input byte-by-byte + * to handle OSC sequences that may arrive in fragments across multiple reads. + * This interface makes it easy to integrate into most environments and avoids + * over-allocating buffers. + * + * ## Basic Usage + * + * 1. Create a parser instance with ghostty_osc_new() + * 2. Feed bytes to the parser using ghostty_osc_next() + * 3. Finalize parsing with ghostty_osc_end() to get the command + * 4. Query command type and extract data using ghostty_osc_command_type() + * and ghostty_osc_command_data() + * 5. Free the parser with ghostty_osc_free() when done + * + * @{ + */ + +/** + * OSC command types. + * + * @ingroup osc + */ +typedef enum { + GHOSTTY_OSC_COMMAND_INVALID = 0, + GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, + GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, + GHOSTTY_OSC_COMMAND_PROMPT_START = 3, + GHOSTTY_OSC_COMMAND_PROMPT_END = 4, + GHOSTTY_OSC_COMMAND_END_OF_INPUT = 5, + GHOSTTY_OSC_COMMAND_END_OF_COMMAND = 6, + GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 7, + GHOSTTY_OSC_COMMAND_REPORT_PWD = 8, + GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 9, + GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 10, + GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 11, + GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 12, + GHOSTTY_OSC_COMMAND_HYPERLINK_START = 13, + GHOSTTY_OSC_COMMAND_HYPERLINK_END = 14, + GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 15, + GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 16, + GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 17, + GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 18, + GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 19, + GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20, +} GhosttyOscCommandType; + +/** + * OSC command data types. + * + * These values specify what type of data to extract from an OSC command + * using `ghostty_osc_command_data`. + * + * @ingroup osc + */ +typedef enum { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_OSC_DATA_INVALID = 0, + + /** + * Window title string data. + * + * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE + * + * Output type: const char ** (pointer to null-terminated string) + * + * Lifetime: Valid until the next call to any ghostty_osc_* function with + * the same parser instance. Memory is owned by the parser. + */ + GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, +} GhosttyOscCommandData; + +/** + * Create a new OSC parser instance. + * + * Creates a new OSC (Operating System Command) parser using the provided + * allocator. The parser must be freed using ghostty_vt_osc_free() when + * no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator + * @param parser Pointer to store the created parser handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup osc + */ +GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); + +/** + * Free an OSC parser instance. + * + * Releases all resources associated with the OSC parser. After this call, + * the parser handle becomes invalid and must not be used. + * + * @param parser The parser handle to free (may be NULL) + * + * @ingroup osc + */ +void ghostty_osc_free(GhosttyOscParser parser); + +/** + * Reset an OSC parser instance to its initial state. + * + * Resets the parser state, clearing any partially parsed OSC sequences + * and returning the parser to its initial state. This is useful for + * reusing a parser instance or recovering from parse errors. + * + * @param parser The parser handle to reset, must not be null. + * + * @ingroup osc + */ +void ghostty_osc_reset(GhosttyOscParser parser); + +/** + * Parse the next byte in an OSC sequence. + * + * Processes a single byte as part of an OSC sequence. The parser maintains + * internal state to track the progress through the sequence. Call this + * function for each byte in the sequence data. + * + * When finished pumping the parser with bytes, call ghostty_osc_end + * to get the final result. + * + * @param parser The parser handle, must not be null. + * @param byte The next byte to parse + * + * @ingroup osc + */ +void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); + +/** + * Finalize OSC parsing and retrieve the parsed command. + * + * Call this function after feeding all bytes of an OSC sequence to the parser + * using ghostty_osc_next() with the exception of the terminating character + * (ESC or ST). This function finalizes the parsing process and returns the + * parsed OSC command. + * + * The return value is never NULL. Invalid commands will return a command + * with type GHOSTTY_OSC_COMMAND_INVALID. + * + * The terminator parameter specifies the byte that terminated the OSC sequence + * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is + * preserved in the parsed command so that responses can use the same terminator + * format for better compatibility with the calling program. For commands that + * do not require a response, this parameter is ignored and the resulting + * command will not retain the terminator information. + * + * The returned command handle is valid until the next call to any + * `ghostty_osc_*` function with the same parser instance with the exception + * of command introspection functions such as `ghostty_osc_command_type`. + * + * @param parser The parser handle, must not be null. + * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST) + * @return Handle to the parsed OSC command + * + * @ingroup osc + */ +GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); + +/** + * Get the type of an OSC command. + * + * Returns the type identifier for the given OSC command. This can be used + * to determine what kind of command was parsed and what data might be + * available from it. + * + * @param command The OSC command handle to query (may be NULL) + * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL + * + * @ingroup osc + */ +GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); + +/** + * Extract data from an OSC command. + * + * Extracts typed data from the given OSC command based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid command types, output types, and memory + * safety information are documented in the `GhosttyOscCommandData` enum. + * + * @param command The OSC command handle to query (may be NULL) + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return true if data extraction was successful, false otherwise + * + * @ingroup osc + */ +bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); + +/** @} */ + +#endif /* GHOSTTY_VT_OSC_H */ diff --git a/include/ghostty/vt/paste.h b/include/ghostty/vt/paste.h new file mode 100644 index 000000000..d90f303d4 --- /dev/null +++ b/include/ghostty/vt/paste.h @@ -0,0 +1,75 @@ +/** + * @file paste.h + * + * Paste utilities - validate and encode paste data for terminal input. + */ + +#ifndef GHOSTTY_VT_PASTE_H +#define GHOSTTY_VT_PASTE_H + +/** @defgroup paste Paste Utilities + * + * Utilities for validating paste data safety. + * + * ## Basic Usage + * + * Use ghostty_paste_is_safe() to check if paste data contains potentially + * dangerous sequences before sending it to the terminal. + * + * ## Example + * + * @code{.c} + * #include + * #include + * #include + * + * int main() { + * const char* safe_data = "hello world"; + * const char* unsafe_data = "rm -rf /\n"; + * + * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) { + * printf("Safe to paste\n"); + * } + * + * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) { + * printf("Unsafe! Contains newline\n"); + * } + * + * return 0; + * } + * @endcode + * + * @{ + */ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Check if paste data is safe to paste into the terminal. + * + * Data is considered unsafe if it contains: + * - Newlines (`\n`) which can inject commands + * - The bracketed paste end sequence (`\x1b[201~`) which can be used + * to exit bracketed paste mode and inject commands + * + * This check is conservative and considers data unsafe regardless of + * current terminal state. + * + * @param data The paste data to check (must not be NULL) + * @param len The length of the data in bytes + * @return true if the data is safe to paste, false otherwise + */ +bool ghostty_paste_is_safe(const char* data, size_t len); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_PASTE_H */ diff --git a/include/ghostty/vt/result.h b/include/ghostty/vt/result.h new file mode 100644 index 000000000..65938ee76 --- /dev/null +++ b/include/ghostty/vt/result.h @@ -0,0 +1,22 @@ +/** + * @file result.h + * + * Result codes for libghostty-vt operations. + */ + +#ifndef GHOSTTY_VT_RESULT_H +#define GHOSTTY_VT_RESULT_H + +/** + * Result codes for libghostty-vt operations. + */ +typedef enum { + /** Operation completed successfully */ + GHOSTTY_SUCCESS = 0, + /** Operation failed due to failed allocation */ + GHOSTTY_OUT_OF_MEMORY = -1, + /** Operation failed due to invalid value */ + GHOSTTY_INVALID_VALUE = -2, +} GhosttyResult; + +#endif /* GHOSTTY_VT_RESULT_H */ diff --git a/include/ghostty/vt/sgr.h b/include/ghostty/vt/sgr.h new file mode 100644 index 000000000..0c1afc309 --- /dev/null +++ b/include/ghostty/vt/sgr.h @@ -0,0 +1,394 @@ +/** + * @file sgr.h + * + * SGR (Select Graphic Rendition) attribute parsing and handling. + */ + +#ifndef GHOSTTY_VT_SGR_H +#define GHOSTTY_VT_SGR_H + +/** @defgroup sgr SGR Parser + * + * SGR (Select Graphic Rendition) attribute parser. + * + * SGR sequences are the syntax used to set styling attributes such as + * bold, italic, underline, and colors for text in terminal emulators. + * For example, you may be familiar with sequences like `ESC[1;31m`. The + * `1;31` is the SGR attribute list. + * + * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`) + * and returns individual text attributes like bold, italic, colors, etc. + * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, + * and handles various color formats including 8-color, 16-color, 256-color, + * X11 named colors, and RGB in multiple formats. + * + * ## Basic Usage + * + * 1. Create a parser instance with ghostty_sgr_new() + * 2. Set SGR parameters with ghostty_sgr_set_params() + * 3. Iterate through attributes using ghostty_sgr_next() + * 4. Free the parser with ghostty_sgr_free() when done + * + * ## Example + * + * @code{.c} + * #include + * #include + * #include + * + * int main() { + * // Create parser + * GhosttySgrParser parser; + * GhosttyResult result = ghostty_sgr_new(NULL, &parser); + * assert(result == GHOSTTY_SUCCESS); + * + * // Parse "bold, red foreground" sequence: ESC[1;31m + * uint16_t params[] = {1, 31}; + * result = ghostty_sgr_set_params(parser, params, NULL, 2); + * assert(result == GHOSTTY_SUCCESS); + * + * // Iterate through attributes + * GhosttySgrAttribute attr; + * while (ghostty_sgr_next(parser, &attr)) { + * switch (attr.tag) { + * case GHOSTTY_SGR_ATTR_BOLD: + * printf("Bold enabled\n"); + * break; + * case GHOSTTY_SGR_ATTR_FG_8: + * printf("Foreground color: %d\n", attr.value.fg_8); + * break; + * default: + * break; + * } + * } + * + * // Cleanup + * ghostty_sgr_free(parser); + * return 0; + * } + * @endcode + * + * @{ + */ + +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Opaque handle to an SGR parser instance. + * + * This handle represents an SGR (Select Graphic Rendition) parser that can + * be used to parse SGR sequences and extract individual text attributes. + * + * @ingroup sgr + */ +typedef struct GhosttySgrParser* GhosttySgrParser; + +/** + * SGR attribute tags. + * + * These values identify the type of an SGR attribute in a tagged union. + * Use the tag to determine which field in the attribute value union to access. + * + * @ingroup sgr + */ +typedef enum { + GHOSTTY_SGR_ATTR_UNSET = 0, + GHOSTTY_SGR_ATTR_UNKNOWN = 1, + GHOSTTY_SGR_ATTR_BOLD = 2, + GHOSTTY_SGR_ATTR_RESET_BOLD = 3, + GHOSTTY_SGR_ATTR_ITALIC = 4, + GHOSTTY_SGR_ATTR_RESET_ITALIC = 5, + GHOSTTY_SGR_ATTR_FAINT = 6, + GHOSTTY_SGR_ATTR_UNDERLINE = 7, + GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8, + GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9, + GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10, + GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11, + GHOSTTY_SGR_ATTR_OVERLINE = 12, + GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13, + GHOSTTY_SGR_ATTR_BLINK = 14, + GHOSTTY_SGR_ATTR_RESET_BLINK = 15, + GHOSTTY_SGR_ATTR_INVERSE = 16, + GHOSTTY_SGR_ATTR_RESET_INVERSE = 17, + GHOSTTY_SGR_ATTR_INVISIBLE = 18, + GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19, + GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20, + GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21, + GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22, + GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23, + GHOSTTY_SGR_ATTR_BG_8 = 24, + GHOSTTY_SGR_ATTR_FG_8 = 25, + GHOSTTY_SGR_ATTR_RESET_FG = 26, + GHOSTTY_SGR_ATTR_RESET_BG = 27, + GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28, + GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29, + GHOSTTY_SGR_ATTR_BG_256 = 30, + GHOSTTY_SGR_ATTR_FG_256 = 31, +} GhosttySgrAttributeTag; + +/** + * Underline style types. + * + * @ingroup sgr + */ +typedef enum { + GHOSTTY_SGR_UNDERLINE_NONE = 0, + GHOSTTY_SGR_UNDERLINE_SINGLE = 1, + GHOSTTY_SGR_UNDERLINE_DOUBLE = 2, + GHOSTTY_SGR_UNDERLINE_CURLY = 3, + GHOSTTY_SGR_UNDERLINE_DOTTED = 4, + GHOSTTY_SGR_UNDERLINE_DASHED = 5, +} GhosttySgrUnderline; + +/** + * Unknown SGR attribute data. + * + * Contains the full parameter list and the partial list where parsing + * encountered an unknown or invalid sequence. + * + * @ingroup sgr + */ +typedef struct { + const uint16_t* full_ptr; + size_t full_len; + const uint16_t* partial_ptr; + size_t partial_len; +} GhosttySgrUnknown; + +/** + * SGR attribute value union. + * + * This union contains all possible attribute values. Use the tag field + * to determine which union member is active. Attributes without associated + * data (like bold, italic) don't use the union value. + * + * @ingroup sgr + */ +typedef union { + GhosttySgrUnknown unknown; + GhosttySgrUnderline underline; + GhosttyColorRgb underline_color; + GhosttyColorPaletteIndex underline_color_256; + GhosttyColorRgb direct_color_fg; + GhosttyColorRgb direct_color_bg; + GhosttyColorPaletteIndex bg_8; + GhosttyColorPaletteIndex fg_8; + GhosttyColorPaletteIndex bright_bg_8; + GhosttyColorPaletteIndex bright_fg_8; + GhosttyColorPaletteIndex bg_256; + GhosttyColorPaletteIndex fg_256; + uint64_t _padding[8]; +} GhosttySgrAttributeValue; + +/** + * SGR attribute (tagged union). + * + * A complete SGR attribute with both its type tag and associated value. + * Always check the tag field to determine which value union member is valid. + * + * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be + * identified by tag alone; the value union is not used for these and + * the memory in the value field is undefined. + * + * @ingroup sgr + */ +typedef struct { + GhosttySgrAttributeTag tag; + GhosttySgrAttributeValue value; +} GhosttySgrAttribute; + +/** + * Create a new SGR parser instance. + * + * Creates a new SGR (Select Graphic Rendition) parser using the provided + * allocator. The parser must be freed using ghostty_sgr_free() when + * no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or + * NULL to use the default allocator + * @param parser Pointer to store the created parser handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup sgr + */ +GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator, + GhosttySgrParser* parser); + +/** + * Free an SGR parser instance. + * + * Releases all resources associated with the SGR parser. After this call, + * the parser handle becomes invalid and must not be used. This includes + * any attributes previously returned by ghostty_sgr_next(). + * + * @param parser The parser handle to free (may be NULL) + * + * @ingroup sgr + */ +void ghostty_sgr_free(GhosttySgrParser parser); + +/** + * Reset an SGR parser instance to the beginning of the parameter list. + * + * Resets the parser's iteration state without clearing the parameters. + * After calling this, ghostty_sgr_next() will start from the beginning + * of the parameter list again. + * + * @param parser The parser handle to reset, must not be NULL + * + * @ingroup sgr + */ +void ghostty_sgr_reset(GhosttySgrParser parser); + +/** + * Set SGR parameters for parsing. + * + * Sets the SGR parameter list to parse. Parameters are the numeric values + * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}). + * + * The separators array optionally specifies the separator type for each + * parameter position. Each byte should be either ';' for semicolon or ':' + * for colon. This is needed for certain color formats that use colon + * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator + * values are treated as semicolons. The separators array must have the same + * length as the params array, if it is not NULL. + * + * If separators is NULL, all parameters are assumed to be semicolon-separated. + * + * This function makes an internal copy of the parameter and separator data, + * so the caller can safely free or modify the input arrays after this call. + * + * After calling this function, the parser is automatically reset and ready + * to iterate from the beginning. + * + * @param parser The parser handle, must not be NULL + * @param params Array of SGR parameter values + * @param separators Optional array of separator characters (';' or ':'), or + * NULL + * @param len Number of parameters (and separators if provided) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup sgr + */ +GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser, + const uint16_t* params, + const char* separators, + size_t len); + +/** + * Get the next SGR attribute. + * + * Parses and returns the next attribute from the parameter list. + * Call this function repeatedly until it returns false to process + * all attributes in the sequence. + * + * @param parser The parser handle, must not be NULL + * @param attr Pointer to store the next attribute + * @return true if an attribute was returned, false if no more attributes + * + * @ingroup sgr + */ +bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr); + +/** + * Get the full parameter list from an unknown SGR attribute. + * + * This function retrieves the full parameter list that was provided to the + * parser when an unknown attribute was encountered. Primarily useful in + * WebAssembly environments where accessing struct fields directly is difficult. + * + * @param unknown The unknown attribute data + * @param ptr Pointer to store the pointer to the parameter array (may be NULL) + * @return The length of the full parameter array + * + * @ingroup sgr + */ +size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown, + const uint16_t** ptr); + +/** + * Get the partial parameter list from an unknown SGR attribute. + * + * This function retrieves the partial parameter list where parsing stopped + * when an unknown attribute was encountered. Primarily useful in WebAssembly + * environments where accessing struct fields directly is difficult. + * + * @param unknown The unknown attribute data + * @param ptr Pointer to store the pointer to the parameter array (may be NULL) + * @return The length of the partial parameter array + * + * @ingroup sgr + */ +size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown, + const uint16_t** ptr); + +/** + * Get the tag from an SGR attribute. + * + * This function extracts the tag that identifies which type of attribute + * this is. Primarily useful in WebAssembly environments where accessing + * struct fields directly is difficult. + * + * @param attr The SGR attribute + * @return The attribute tag + * + * @ingroup sgr + */ +GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr); + +/** + * Get the value from an SGR attribute. + * + * This function returns a pointer to the value union from an SGR attribute. Use + * the tag to determine which field of the union is valid. Primarily useful in + * WebAssembly environments where accessing struct fields directly is difficult. + * + * @param attr Pointer to the SGR attribute + * @return Pointer to the attribute value union + * + * @ingroup sgr + */ +GhosttySgrAttributeValue* ghostty_sgr_attribute_value( + GhosttySgrAttribute* attr); + +#ifdef __wasm__ +/** + * Allocate memory for an SGR attribute (WebAssembly only). + * + * This is a convenience function for WebAssembly environments to allocate + * memory for an SGR attribute structure that can be passed to ghostty_sgr_next. + * + * @return Pointer to the allocated attribute structure + * + * @ingroup wasm + */ +GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); + +/** + * Free memory for an SGR attribute (WebAssembly only). + * + * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute. + * + * @param attr Pointer to the attribute structure to free + * + * @ingroup wasm + */ +void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr); +#endif + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_SGR_H */ diff --git a/include/ghostty/vt/wasm.h b/include/ghostty/vt/wasm.h new file mode 100644 index 000000000..37a826326 --- /dev/null +++ b/include/ghostty/vt/wasm.h @@ -0,0 +1,159 @@ +/** + * @file wasm.h + * + * WebAssembly utility functions for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_WASM_H +#define GHOSTTY_VT_WASM_H + +#ifdef __wasm__ + +#include +#include + +/** @defgroup wasm WebAssembly Utilities + * + * Convenience functions for allocating various types in WebAssembly builds. + * **These are only available the libghostty-vt wasm module.** + * + * Ghostty relies on pointers to various types for ABI compatibility, and + * creating those pointers in Wasm can be tedious. These functions provide + * a purely additive set of utilities that simplify memory management in + * Wasm environments without changing the core C library API. + * + * @note These functions always use the default allocator. If you need + * custom allocation strategies, you should allocate types manually using + * your custom allocator. This is a very rare use case in the WebAssembly + * world so these are optimized for simplicity. + * + * ## Example Usage + * + * Here's a simple example of using the Wasm utilities with the key encoder: + * + * @code + * const { exports } = wasmInstance; + * const view = new DataView(wasmMemory.buffer); + * + * // Create key encoder + * const encoderPtr = exports.ghostty_wasm_alloc_opaque(); + * exports.ghostty_key_encoder_new(null, encoderPtr); + * const encoder = view.getUint32(encoder, true); + * + * // Configure encoder with Kitty protocol flags + * const flagsPtr = exports.ghostty_wasm_alloc_u8(); + * view.setUint8(flagsPtr, 0x1F); + * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr); + * + * // Allocate output buffer and size pointer + * const bufferSize = 32; + * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize); + * const writtenPtr = exports.ghostty_wasm_alloc_usize(); + * + * // Encode the key event + * exports.ghostty_key_encoder_encode( + * encoder, eventPtr, bufPtr, bufferSize, writtenPtr + * ); + * + * // Read encoded output + * const bytesWritten = view.getUint32(writtenPtr, true); + * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten); + * @endcode + * + * @remark The code above is pretty ugly! This is the lowest level interface + * to the libghostty-vt Wasm module. In practice, this should be wrapped + * in a higher-level API that abstracts away all this. + * + * @{ + */ + +/** + * Allocate an opaque pointer. This can be used for any opaque pointer + * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. + * + * @return Pointer to allocated opaque pointer, or NULL if allocation failed + * @ingroup wasm + */ +void** ghostty_wasm_alloc_opaque(void); + +/** + * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). + * + * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @ingroup wasm + */ +void ghostty_wasm_free_opaque(void **ptr); + +/** + * Allocate an array of uint8_t values. + * + * @param len Number of uint8_t elements to allocate + * @return Pointer to allocated array, or NULL if allocation failed + * @ingroup wasm + */ +uint8_t* ghostty_wasm_alloc_u8_array(size_t len); + +/** + * Free an array allocated by ghostty_wasm_alloc_u8_array(). + * + * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) + * @param len Length of the array (must match the length passed to alloc) + * @ingroup wasm + */ +void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); + +/** + * Allocate an array of uint16_t values. + * + * @param len Number of uint16_t elements to allocate + * @return Pointer to allocated array, or NULL if allocation failed + * @ingroup wasm + */ +uint16_t* ghostty_wasm_alloc_u16_array(size_t len); + +/** + * Free an array allocated by ghostty_wasm_alloc_u16_array(). + * + * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) + * @param len Length of the array (must match the length passed to alloc) + * @ingroup wasm + */ +void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); + +/** + * Allocate a single uint8_t value. + * + * @return Pointer to allocated uint8_t, or NULL if allocation failed + * @ingroup wasm + */ +uint8_t* ghostty_wasm_alloc_u8(void); + +/** + * Free a uint8_t allocated by ghostty_wasm_alloc_u8(). + * + * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @ingroup wasm + */ +void ghostty_wasm_free_u8(uint8_t *ptr); + +/** + * Allocate a single size_t value. + * + * @return Pointer to allocated size_t, or NULL if allocation failed + * @ingroup wasm + */ +size_t* ghostty_wasm_alloc_usize(void); + +/** + * Free a size_t allocated by ghostty_wasm_alloc_usize(). + * + * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @ingroup wasm + */ +void ghostty_wasm_free_usize(size_t *ptr); + +/** @} */ + +#endif /* __wasm__ */ + +#endif /* GHOSTTY_VT_WASM_H */ diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index ff391c0f8..2bf3b0bae 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -61,7 +61,7 @@ NSMenuItem default - New Ghostty Tab Here + New $(INFOPLIST_KEY_CFBundleDisplayName) Tab Here NSMessage openTab @@ -80,7 +80,7 @@ NSMenuItem default - New Ghostty Window Here + New $(INFOPLIST_KEY_CFBundleDisplayName) Window Here NSMessage openWindow diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index e53f6d468..eb5d706c3 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -96,6 +96,7 @@ Features/QuickTerminal/QuickTerminalController.swift, Features/QuickTerminal/QuickTerminalPosition.swift, Features/QuickTerminal/QuickTerminalScreen.swift, + Features/QuickTerminal/QuickTerminalScreenStateCache.swift, Features/QuickTerminal/QuickTerminalSize.swift, Features/QuickTerminal/QuickTerminalSpaceBehavior.swift, Features/QuickTerminal/QuickTerminalWindow.swift, @@ -114,6 +115,7 @@ Features/Terminal/ErrorView.swift, Features/Terminal/TerminalController.swift, Features/Terminal/TerminalRestorable.swift, + Features/Terminal/TerminalTabColor.swift, Features/Terminal/TerminalView.swift, "Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift", "Features/Terminal/Window Styles/Terminal.xib", @@ -125,7 +127,14 @@ "Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift", "Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift", "Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift", + Features/Update/UpdateBadge.swift, + Features/Update/UpdateController.swift, Features/Update/UpdateDelegate.swift, + Features/Update/UpdateDriver.swift, + Features/Update/UpdatePill.swift, + Features/Update/UpdatePopoverView.swift, + Features/Update/UpdateSimulator.swift, + Features/Update/UpdateViewModel.swift, "Ghostty/FullscreenMode+Extension.swift", Ghostty/Ghostty.Command.swift, Ghostty/Ghostty.Error.swift, @@ -134,6 +143,7 @@ Ghostty/Ghostty.Surface.swift, Ghostty/InspectorView.swift, "Ghostty/NSEvent+Extension.swift", + Ghostty/SurfaceScrollView.swift, Ghostty/SurfaceView_AppKit.swift, Helpers/AppInfo.swift, Helpers/CodableBridge.swift, @@ -147,6 +157,7 @@ "Helpers/Extensions/NSAppearance+Extension.swift", "Helpers/Extensions/NSApplication+Extension.swift", "Helpers/Extensions/NSImage+Extension.swift", + "Helpers/Extensions/NSMenu+Extension.swift", "Helpers/Extensions/NSMenuItem+Extension.swift", "Helpers/Extensions/NSPasteboard+Extension.swift", "Helpers/Extensions/NSScreen+Extension.swift", @@ -160,6 +171,7 @@ Helpers/KeyboardLayout.swift, Helpers/LastWindowPosition.swift, Helpers/MetalView.swift, + Helpers/NonDraggableHostingView.swift, Helpers/PermissionRequest.swift, Helpers/Private/CGS.swift, Helpers/Private/Dock.swift, @@ -545,6 +557,7 @@ INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; + INFOPLIST_PREPROCESS = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -767,7 +780,7 @@ EXECUTABLE_NAME = ghostty; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Ghostty-Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = Ghostty; + INFOPLIST_KEY_CFBundleDisplayName = "Ghostty[DEBUG]"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth."; @@ -785,6 +798,7 @@ INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; + INFOPLIST_PREPROCESS = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -839,6 +853,7 @@ INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; + INFOPLIST_PREPROCESS = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", diff --git a/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index db3dd11a5..89573fb88 100644 --- a/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "06beff60e3b609a485290e4d5d2d1e2eedb1e55d", - "version" : "2.7.3" + "revision" : "9a1d2a19d3595fcf8d9c447173f9a1687b3dcadb", + "version" : "2.8.0" } } ], diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 942aecdd4..043d85e1e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftUI import UserNotifications import OSLog import Sparkle @@ -43,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? @@ -62,6 +68,8 @@ class AppDelegate: NSObject, @IBOutlet private var menuDecreaseFontSize: NSMenuItem? @IBOutlet private var menuResetFontSize: NSMenuItem? @IBOutlet private var menuChangeTitle: NSMenuItem? + @IBOutlet private var menuChangeTabTitle: NSMenuItem? + @IBOutlet private var menuReadonly: NSMenuItem? @IBOutlet private var menuQuickTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuCommandPalette: NSMenuItem? @@ -98,8 +106,10 @@ class AppDelegate: NSObject, ) /// Manages updates - let updaterController: SPUStandardUpdaterController - let updaterDelegate: UpdaterDelegate = UpdaterDelegate() + let updateController = UpdateController() + var updateViewModel: UpdateViewModel { + updateController.viewModel + } /// The elapsed time since the process was started var timeSinceLaunch: TimeInterval { @@ -107,7 +117,7 @@ class AppDelegate: NSObject, } /// Tracks the windows that we hid for toggleVisibility. - private var hiddenState: ToggleVisibilityState? = nil + private(set) var hiddenState: ToggleVisibilityState? = nil /// The observer for the app appearance. private var appearanceObserver: NSKeyValueObservation? = nil @@ -116,25 +126,9 @@ class AppDelegate: NSObject, private var signals: [DispatchSourceSignal] = [] /// The custom app icon image that is currently in use. - @Published private(set) var appIcon: NSImage? = nil { - didSet { - NSApplication.shared.applicationIconImage = appIcon - let appPath = Bundle.main.bundlePath - NSWorkspace.shared.setIcon(appIcon, forFile: appPath, options: []) - NSWorkspace.shared.noteFileSystemChanged(appPath) - } - } + @Published private(set) var appIcon: NSImage? = nil override init() { - updaterController = SPUStandardUpdaterController( - // Important: we must not start the updater here because we need to read our configuration - // first to determine whether we're automatically checking, downloading, etc. The updater - // is started later in applicationDidFinishLaunching - startingUpdater: false, - updaterDelegate: updaterDelegate, - userDriverDelegate: nil - ) - super.init() ghostty.delegate = self @@ -179,7 +173,7 @@ class AppDelegate: NSObject, ghosttyConfigDidChange(config: ghostty.config) // Start our update checker. - updaterController.startUpdater() + updateController.startUpdater() // Register our service provider. This must happen after everything is initialized. NSApp.servicesProvider = ServiceProvider() @@ -293,6 +287,11 @@ class AppDelegate: NSObject, } } + func applicationDidHide(_ notification: Notification) { + // Keep track of our hidden state to restore properly + self.hiddenState = .init() + } + func applicationDidBecomeActive(_ notification: Notification) { // If we're back manually then clear the hidden state because macOS handles it. self.hiddenState = nil @@ -323,6 +322,12 @@ class AppDelegate: NSObject, func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { let windows = NSApplication.shared.windows if (windows.isEmpty) { return .terminateNow } + + // If we've already accepted to install an update, then we don't need to + // confirm quit. The user is already expecting the update to happen. + if updateController.isInstalling { + return .terminateNow + } // This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't // quite work with SwiftUI because windows are retained on close. So instead we check @@ -471,7 +476,12 @@ class AppDelegate: NSObject, } switch ghostty.config.macosDockDropBehavior { - case .new_tab: _ = TerminalController.newTab(ghostty, withBaseConfig: config) + case .new_tab: + _ = TerminalController.newTab( + ghostty, + from: TerminalController.preferredParent?.window, + withBaseConfig: config + ) case .new_window: _ = TerminalController.newWindow(ghostty, withBaseConfig: config) } @@ -533,8 +543,9 @@ class AppDelegate: NSObject, self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller") self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection") self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") - self.menuChangeTitle?.setImageIfDesired(systemSymbolName: "pencil.line") + self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") + self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill") self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") @@ -550,6 +561,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. @@ -578,6 +590,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) @@ -597,6 +612,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle) + syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle) syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop) @@ -714,6 +730,10 @@ class AppDelegate: NSObject, } @objc private func ghosttyBellDidRing(_ notification: Notification) { + if (ghostty.config.bellFeatures.contains(.system)) { + NSSound.beep() + } + if (ghostty.config.bellFeatures.contains(.attention)) { // Bounce the dock icon if we're not focused. NSApp.requestUserAttention(.informationalRequest) @@ -806,13 +826,21 @@ class AppDelegate: NSObject, // defined by our "auto-update" configuration (if set) or fall back to Sparkle // user-based defaults. if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false { - updaterController.updater.automaticallyChecksForUpdates = false - updaterController.updater.automaticallyDownloadsUpdates = false + updateController.updater.automaticallyChecksForUpdates = false + updateController.updater.automaticallyDownloadsUpdates = false } else if let autoUpdate = config.autoUpdate { - updaterController.updater.automaticallyChecksForUpdates = + updateController.updater.automaticallyChecksForUpdates = autoUpdate == .check || autoUpdate == .download - updaterController.updater.automaticallyDownloadsUpdates = + updateController.updater.automaticallyDownloadsUpdates = autoUpdate == .download + /** + To test `auto-update` easily, uncomment the line below and + delete `SUEnableAutomaticChecks` in Ghostty-Info.plist. + + Note: When `auto-update = download`, you may need to + `Clean Build Folder` if a background install has already begun. + */ + //updateController.updater.checkForUpdatesInBackground() } // Config could change keybindings, so update everything that depends on that @@ -860,49 +888,64 @@ class AppDelegate: NSObject, } else { GlobalEventTap.shared.disable() } + Task { + await updateAppIcon(from: config) + } } /// Sync the appearance of our app with the theme specified in the config. private func syncAppearance(config: Ghostty.Config) { NSApplication.shared.appearance = .init(ghosttyConfig: config) - + } + + // Using AppIconActor to ensure this work + // happens synchronously in the background + @AppIconActor + private func updateAppIcon(from config: Ghostty.Config) async { + var appIcon: NSImage? + var appIconName: String? = config.macosIcon.rawValue + switch (config.macosIcon) { case .official: - self.appIcon = nil + // Discard saved icon name + appIconName = nil break - case .blueprint: - self.appIcon = NSImage(named: "BlueprintImage")! + appIcon = NSImage(named: "BlueprintImage")! case .chalkboard: - self.appIcon = NSImage(named: "ChalkboardImage")! + appIcon = NSImage(named: "ChalkboardImage")! case .glass: - self.appIcon = NSImage(named: "GlassImage")! + appIcon = NSImage(named: "GlassImage")! case .holographic: - self.appIcon = NSImage(named: "HolographicImage")! + appIcon = NSImage(named: "HolographicImage")! case .microchip: - self.appIcon = NSImage(named: "MicrochipImage")! + appIcon = NSImage(named: "MicrochipImage")! case .paper: - self.appIcon = NSImage(named: "PaperImage")! + appIcon = NSImage(named: "PaperImage")! case .retro: - self.appIcon = NSImage(named: "RetroImage")! + appIcon = NSImage(named: "RetroImage")! case .xray: - self.appIcon = NSImage(named: "XrayImage")! + appIcon = NSImage(named: "XrayImage")! case .custom: if let userIcon = NSImage(contentsOfFile: config.macosCustomIcon) { - self.appIcon = userIcon + appIcon = userIcon + appIconName = config.macosCustomIcon } else { - self.appIcon = nil // Revert back to official icon if invalid location + 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( @@ -910,8 +953,38 @@ class AppDelegate: NSObject, ghostColor: ghostColor, frame: config.macosIconFrame ).makeImage() else { break } - self.appIcon = icon + 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 + + let appPath = Bundle.main.bundlePath + NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: []) + NSWorkspace.shared.noteFileSystemChanged(appPath) + + await MainActor.run { + self.appIcon = newIcon + NSApplication.shared.applicationIconImage = newIcon + } + UserDefaults.standard.set(appIconName, forKey: "CustomGhosttyIcon") } //MARK: - Restorable State @@ -1004,7 +1077,8 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - updaterController.checkForUpdates(sender) + updateController.checkForUpdates() + //UpdateSimulator.happyPath.simulate(with: updateViewModel) } @IBAction func newWindow(_ sender: Any?) { @@ -1012,7 +1086,10 @@ class AppDelegate: NSObject, } @IBAction func newTab(_ sender: Any?) { - _ = TerminalController.newTab(ghostty) + _ = TerminalController.newTab( + ghostty, + from: TerminalController.preferredParent?.window + ) } @IBAction func closeAllWindows(_ sender: Any?) { @@ -1046,8 +1123,6 @@ class AppDelegate: NSObject, guard let keyWindow = NSApp.keyWindow, !keyWindow.styleMask.contains(.fullScreen) else { return } - // Keep track of our hidden state to restore properly - self.hiddenState = .init() NSApp.hide(nil) return } @@ -1096,11 +1171,11 @@ class AppDelegate: NSObject, } } - private struct ToggleVisibilityState { + struct ToggleVisibilityState { let hiddenWindows: [Weak] let keyWindow: Weak? - init() { + fileprivate init() { // We need to know the key window so that we can bring focus back to the // right window if it was hidden. self.keyWindow = if let keyWindow = NSApp.keyWindow { @@ -1113,10 +1188,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() { @@ -1188,3 +1272,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..a321061dd 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -16,6 +16,7 @@ + @@ -26,7 +27,12 @@ + + + + + @@ -41,6 +47,7 @@ + @@ -245,6 +252,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -277,12 +317,24 @@ - + + + + + + + + + + + + + diff --git a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift index 923d22c97..0155cf855 100644 --- a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift @@ -12,8 +12,10 @@ struct CloseTerminalIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = .background +#endif @MainActor func perform() async throws -> some IntentResult { diff --git a/macos/Sources/Features/App Intents/CommandPaletteIntent.swift b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift index fa983054b..2f07d7861 100644 --- a/macos/Sources/Features/App Intents/CommandPaletteIntent.swift +++ b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift @@ -19,8 +19,10 @@ struct CommandPaletteIntent: AppIntent { ) var command: CommandEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = .background +#endif @MainActor func perform() async throws -> some IntentResult & ReturnsValue { diff --git a/macos/Sources/Features/App Intents/FocusTerminalIntent.swift b/macos/Sources/Features/App Intents/FocusTerminalIntent.swift index 4e813e842..21dd71b15 100644 --- a/macos/Sources/Features/App Intents/FocusTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/FocusTerminalIntent.swift @@ -12,8 +12,10 @@ struct FocusTerminalIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = .background +#endif @MainActor func perform() async throws -> some IntentResult { diff --git a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift index 1cbaa9d68..563e3719b 100644 --- a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift +++ b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift @@ -17,8 +17,10 @@ struct GetTerminalDetailsIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = .background +#endif static var parameterSummary: some ParameterSummary { Summary("Get \(\.$detail) from \(\.$terminal)") diff --git a/macos/Sources/Features/App Intents/InputIntent.swift b/macos/Sources/Features/App Intents/InputIntent.swift index 17c97fbbb..d169b3a8c 100644 --- a/macos/Sources/Features/App Intents/InputIntent.swift +++ b/macos/Sources/Features/App Intents/InputIntent.swift @@ -24,8 +24,10 @@ struct InputTextIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = [.background, .foreground] +#endif @MainActor func perform() async throws -> some IntentResult { @@ -74,8 +76,10 @@ struct KeyEventIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = [.background, .foreground] +#endif @MainActor func perform() async throws -> some IntentResult { @@ -136,8 +140,10 @@ struct MouseButtonIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = [.background, .foreground] +#endif @MainActor func perform() async throws -> some IntentResult { @@ -197,8 +203,10 @@ struct MousePosIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = [.background, .foreground] +#endif @MainActor func perform() async throws -> some IntentResult { @@ -265,8 +273,10 @@ struct MouseScrollIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = [.background, .foreground] +#endif @MainActor func perform() async throws -> some IntentResult { diff --git a/macos/Sources/Features/App Intents/KeybindIntent.swift b/macos/Sources/Features/App Intents/KeybindIntent.swift index b31da4a50..a8cea8561 100644 --- a/macos/Sources/Features/App Intents/KeybindIntent.swift +++ b/macos/Sources/Features/App Intents/KeybindIntent.swift @@ -16,8 +16,10 @@ struct KeybindIntent: AppIntent { ) var action: String +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = [.background, .foreground] +#endif @MainActor func perform() async throws -> some IntentResult & ReturnsValue { diff --git a/macos/Sources/Features/App Intents/NewTerminalIntent.swift b/macos/Sources/Features/App Intents/NewTerminalIntent.swift index 46a752198..be5c65bfa 100644 --- a/macos/Sources/Features/App Intents/NewTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/NewTerminalIntent.swift @@ -45,8 +45,10 @@ struct NewTerminalIntent: AppIntent { // Performing in the background can avoid opening multiple windows at the same time // using `foreground` will cause `perform` and `AppDelegate.applicationDidBecomeActive(_:)`/`AppDelegate.applicationShouldHandleReopen(_:hasVisibleWindows:)` running at the 'same' time +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = .background +#endif @available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes") static var openAppWhenRun = false diff --git a/macos/Sources/Features/App Intents/QuickTerminalIntent.swift b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift index 2e6c9850c..2048a3b88 100644 --- a/macos/Sources/Features/App Intents/QuickTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift @@ -5,8 +5,10 @@ struct QuickTerminalIntent: AppIntent { static var title: LocalizedStringResource = "Open the Quick Terminal" static var description = IntentDescription("Open the Quick Terminal. If it is already open, then do nothing.") +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = .background +#endif @MainActor func perform() async throws -> some IntentResult & ReturnsValue<[TerminalEntity]> { diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index 1a7272e16..6423e3cf6 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -45,19 +45,16 @@ struct ClipboardConfirmationView: View { .font(.system(size: 42)) .padding() .frame(alignment: .center) - + Text(request.text()) .frame(maxWidth: .infinity, alignment: .leading) .padding() } - - ScrollView { - Text(contents) - .textSelection(.enabled) - .font(.system(.body, design: .monospaced)) - .padding(.all, 4) - } - + + TextEditor(text: .constant(contents)) + .focusable(false) + .font(.system(.body, design: .monospaced)) + HStack { Spacer() Button(Action.text(.cancel, request)) { onCancel() } diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 8d15cbf9a..79c3ca756 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -5,7 +5,28 @@ struct CommandOption: Identifiable, Hashable { let title: String let description: String? let symbols: [String]? + let leadingIcon: String? + let badge: String? + let emphasis: Bool let action: () -> Void + + init( + title: String, + description: String? = nil, + symbols: [String]? = nil, + leadingIcon: String? = nil, + badge: String? = nil, + emphasis: Bool = false, + action: @escaping () -> Void + ) { + self.title = title + self.description = description + self.symbols = symbols + self.leadingIcon = leadingIcon + self.badge = badge + self.emphasis = emphasis + self.action = action + } static func == (lhs: CommandOption, rhs: CommandOption) -> Bool { lhs.id == rhs.id @@ -23,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. @@ -51,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 @@ -123,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 + } } } @@ -132,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 @@ -164,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) @@ -198,7 +240,7 @@ fileprivate struct CommandTable: View { } else { ScrollViewReader { proxy in ScrollView { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 4) { ForEach(Array(options.enumerated()), id: \.1.id) { index, option in CommandRow( option: option, @@ -240,15 +282,36 @@ fileprivate struct CommandRow: View { var body: some View { Button(action: action) { - HStack { + HStack(spacing: 8) { + if let icon = option.leadingIcon { + Image(systemName: icon) + .foregroundStyle(option.emphasis ? Color.accentColor : .secondary) + .font(.system(size: 14, weight: .medium)) + } + Text(option.title) + .fontWeight(option.emphasis ? .medium : .regular) + Spacer() + + if let badge = option.badge, !badge.isEmpty { + Text(badge) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background( + Capsule().fill(Color.accentColor.opacity(0.15)) + ) + .foregroundStyle(Color.accentColor) + } + if let symbols = option.symbols { ShortcutSymbolsView(symbols: symbols) .foregroundStyle(.secondary) } } .padding(8) + .contentShape(Rectangle()) .background( isSelected ? Color.accentColor.opacity(0.2) @@ -256,6 +319,10 @@ fileprivate struct CommandRow: View { ? Color.secondary.opacity(0.2) : Color.clear) ) + .overlay( + RoundedRectangle(cornerRadius: 5) + .strokeBorder(Color.accentColor.opacity(option.emphasis && !isSelected ? 0.3 : 0), lineWidth: 1.5) + ) .cornerRadius(5) } .help(option.description ?? "") diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index d02828494..96ff3d0c1 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -11,15 +11,54 @@ struct TerminalCommandPaletteView: View { /// The configuration so we can lookup keyboard shortcuts. @ObservedObject var ghosttyConfig: Ghostty.Config + + /// The update view model for showing update commands. + var updateViewModel: UpdateViewModel? /// The callback when an action is submitted. var onAction: ((String) -> Void) // The commands available to the command palette. private var commandOptions: [CommandOption] { - guard let surface = surfaceView.surfaceModel else { return [] } + var options: [CommandOption] = [] + + // Add update command if an update is installable. This must always be the first so + // it is at the top. + if let updateViewModel, updateViewModel.state.isInstallable { + // We override the update available one only because we want to properly + // convey it'll go all the way through. + let title: String + if case .updateAvailable = updateViewModel.state { + title = "Update Ghostty and Restart" + } else { + title = updateViewModel.text + } + + options.append(CommandOption( + title: title, + description: updateViewModel.description, + leadingIcon: updateViewModel.iconName ?? "shippingbox.fill", + badge: updateViewModel.badge, + emphasis: true + ) { + (NSApp.delegate as? AppDelegate)?.updateController.installUpdate() + }) + } + + // Add cancel/skip update command if the update is installable + if let updateViewModel, updateViewModel.state.isInstallable { + options.append(CommandOption( + title: "Cancel or Skip Update", + description: "Dismiss the current update process" + ) { + updateViewModel.state.cancel() + }) + } + + // Add terminal commands + guard let surface = surfaceView.surfaceModel else { return options } do { - return try surface.commands().map { c in + let terminalCommands = try surface.commands().map { c in return CommandOption( title: c.title, description: c.description, @@ -28,9 +67,12 @@ struct TerminalCommandPaletteView: View { onAction(c.action) } } + options.append(contentsOf: terminalCommands) } catch { - return [] + return options } + + return options } var body: some View { @@ -48,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 fcc8c6505..201289736 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -21,13 +21,8 @@ class QuickTerminalController: BaseTerminalController { // The active space when the quick terminal was last shown. private var previousActiveSpace: CGSSpace? = nil - /// The saved state when the quick terminal's surface tree becomes empty. - /// - /// This preserves the user's window size and position when all terminal surfaces - /// are closed (e.g., via the `exit` command). When a new surface is created, - /// the window will be restored to this frame, preventing SwiftUI from resetting - /// the window to its default minimum size. - private var lastClosedFrames: NSMapTable + /// Cache for per-screen window state. + private let screenStateCache = QuickTerminalScreenStateCache() /// Non-nil if we have hidden dock state. private var hiddenDock: HiddenDock? = nil @@ -37,7 +32,7 @@ class QuickTerminalController: BaseTerminalController { /// Tracks if we're currently handling a manual resize to prevent recursion private var isHandlingResize: Bool = false - + init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, @@ -45,10 +40,6 @@ class QuickTerminalController: BaseTerminalController { ) { self.position = position self.derivedConfig = DerivedConfig(ghostty.config) - - // This is a weak to strong mapping, so that our keys being NSScreens - // can remove themselves when they disappear. - self.lastClosedFrames = .weakToStrongObjects() // Important detail here: we initialize with an empty surface tree so // that we don't start a terminal process. This gets started when the @@ -351,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 } @@ -379,17 +373,15 @@ class QuickTerminalController: BaseTerminalController { private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = derivedConfig.quickTerminalScreen.screen else { return } - // Grab our last closed frame to use, and clear our state since we're animating in. - // We only use the last closed frame if we're opening on the same screen. - let lastClosedFrame: NSRect? = lastClosedFrames.object(forKey: screen)?.frame - lastClosedFrames.removeObject(forKey: screen) + // Grab our last closed frame to use from the cache. + let closedFrame = screenStateCache.frame(for: screen) // Move our window off screen to the initial animation position. position.setInitial( in: window, on: screen, terminalSize: derivedConfig.quickTerminalSize, - closedFrame: lastClosedFrame) + closedFrame: closedFrame) // We need to set our window level to a high value. In testing, only // popUpMenu and above do what we want. This gets it above the menu bar @@ -424,7 +416,7 @@ class QuickTerminalController: BaseTerminalController { in: window.animator(), on: screen, terminalSize: derivedConfig.quickTerminalSize, - closedFrame: lastClosedFrame) + closedFrame: closedFrame) }, completionHandler: { // There is a very minor delay here so waiting at least an event loop tick // keeps us safe from the view not being on the window. @@ -513,7 +505,7 @@ class QuickTerminalController: BaseTerminalController { // terminal is reactivated with a new surface. Without this, SwiftUI // would reset the window to its minimum content size. if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen { - lastClosedFrames.setObject(.init(frame: window.frame), forKey: screen) + screenStateCache.save(frame: window.frame, for: screen) } // If we hid the dock then we unhide it. @@ -524,6 +516,10 @@ class QuickTerminalController: BaseTerminalController { if !window.isOnActiveSpace { self.previousApp = nil window.orderOut(self) + // If our application is hidden previously, we hide it again + if (NSApp.delegate as? AppDelegate)?.hiddenState != nil { + NSApp.hide(nil) + } return } @@ -560,12 +556,17 @@ class QuickTerminalController: BaseTerminalController { // This causes the window to be removed from the screen list and macOS // handles what should be focused next. window.orderOut(self) + // If our application is hidden previously, we hide it again + if (NSApp.delegate as? AppDelegate)?.hiddenState != nil { + NSApp.hide(nil) + } }) } private func syncAppearance() { guard let window else { return } + defer { updateColorSchemeForSurfaceTree() } // Change the collection behavior of the window depending on the configuration. window.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior @@ -598,7 +599,6 @@ class QuickTerminalController: BaseTerminalController { alert.alertStyle = .warning alert.beginSheetModal(for: window) } - // MARK: First Responder @IBAction override func closeWindow(_ sender: Any) { @@ -736,14 +736,6 @@ class QuickTerminalController: BaseTerminalController { hidden = false } } - - private class LastClosedState { - let frame: NSRect - - init(frame: NSRect) { - self.frame = frame - } - } } extension Notification.Name { diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift new file mode 100644 index 000000000..7dc53816c --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift @@ -0,0 +1,113 @@ +import Foundation +import Cocoa + +/// Manages cached window state per screen for the quick terminal. +/// +/// This cache tracks the last closed window frame for each screen, allowing the quick terminal +/// to restore to its previous size and position when reopened. It uses stable display UUIDs +/// to survive NSScreen garbage collection and automatically prunes stale entries. +class QuickTerminalScreenStateCache { + /// The maximum number of saved screen states we retain. This is to avoid some kind of + /// pathological memory growth in case we get our screen state serializing wrong. I don't + /// know anyone with more than 10 screens, so let's just arbitrarily go with that. + private static let maxSavedScreens = 10 + + /// Time-to-live for screen entries that are no longer present (14 days). + private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60 + + /// Keyed by display UUID to survive NSScreen garbage collection. + private var stateByDisplay: [UUID: DisplayEntry] = [:] + + init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(onScreensChanged(_:)), + name: NSApplication.didChangeScreenParametersNotification, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + /// Save the window frame for a screen. + func save(frame: NSRect, for screen: NSScreen) { + guard let key = screen.displayUUID else { return } + let entry = DisplayEntry( + frame: frame, + screenSize: screen.frame.size, + scale: screen.backingScaleFactor, + lastSeen: Date() + ) + stateByDisplay[key] = entry + pruneCapacity() + } + + /// Retrieve the last closed frame for a screen, if valid. + func frame(for screen: NSScreen) -> NSRect? { + guard let key = screen.displayUUID, var entry = stateByDisplay[key] else { return nil } + + // Drop on dimension/scale change that makes the entry invalid + if !entry.isValid(for: screen) { + stateByDisplay.removeValue(forKey: key) + return nil + } + + entry.lastSeen = Date() + stateByDisplay[key] = entry + return entry.frame + } + + @objc private func onScreensChanged(_ note: Notification) { + let screens = NSScreen.screens + let now = Date() + let currentIDs = Set(screens.compactMap { $0.displayUUID }) + + for screen in screens { + guard let key = screen.displayUUID else { continue } + if var entry = stateByDisplay[key] { + // Drop on dimension/scale change that makes the entry invalid + if !entry.isValid(for: screen) { + stateByDisplay.removeValue(forKey: key) + } else { + // Update the screen size if it grew (keep entry valid for larger screens) + entry.screenSize = screen.frame.size + entry.lastSeen = now + stateByDisplay[key] = entry + } + } + } + + // TTL prune for non-present screens + stateByDisplay = stateByDisplay.filter { key, entry in + currentIDs.contains(key) || now.timeIntervalSince(entry.lastSeen) < Self.screenStaleTTL + } + + pruneCapacity() + } + + private func pruneCapacity() { + guard stateByDisplay.count > Self.maxSavedScreens else { return } + let toRemove = stateByDisplay + .sorted { $0.value.lastSeen < $1.value.lastSeen } + .prefix(stateByDisplay.count - Self.maxSavedScreens) + for (key, _) in toRemove { + stateByDisplay.removeValue(forKey: key) + } + } + + private struct DisplayEntry { + var frame: NSRect + var screenSize: CGSize + var scale: CGFloat + var lastSeen: Date + + /// Returns true if this entry is still valid for the given screen. + /// Valid if the scale matches and the cached size is not larger than the current screen size. + /// This allows entries to persist when screens grow, but invalidates them when screens shrink. + func isValid(for screen: NSScreen) -> Bool { + guard scale == screen.backingScaleFactor else { return false } + return screenSize.width <= screen.frame.size.width && screenSize.height <= screen.frame.size.height + } + } +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift index 9f86a7c2b..08bbcb8d9 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift @@ -33,6 +33,7 @@ struct QuickTerminalSize { case GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS: self = .pixels(cStruct.value.pixels) default: + assertionFailure() return nil } } diff --git a/macos/Sources/Features/Settings/SettingsView.swift b/macos/Sources/Features/Settings/SettingsView.swift index 82d24181a..6b0a2c46c 100644 --- a/macos/Sources/Features/Settings/SettingsView.swift +++ b/macos/Sources/Features/Settings/SettingsView.swift @@ -14,7 +14,7 @@ struct SettingsView: View { VStack(alignment: .leading) { Text("Coming Soon. 🚧").font(.title) Text("You can't configure settings in the GUI yet. To modify settings, " + - "edit the file at $HOME/.config/ghostty/config and restart Ghostty.") + "edit the file at $HOME/.config/ghostty/config.ghostty and restart Ghostty.") .multilineTextAlignment(.leading) .lineLimit(nil) } diff --git a/macos/Sources/Features/Splits/SplitView.swift b/macos/Sources/Features/Splits/SplitView.swift index 3dc3c36a3..42de97590 100644 --- a/macos/Sources/Features/Splits/SplitView.swift +++ b/macos/Sources/Features/Splits/SplitView.swift @@ -21,6 +21,9 @@ struct SplitView: View { let left: L let right: R + /// Called when the divider is double-tapped to equalize splits. + let onEqualize: () -> Void + /// The minimum size (in points) of a split let minSize: CGFloat = 10 @@ -56,6 +59,9 @@ struct SplitView: View { split: $split) .position(splitterPoint) .gesture(dragGesture(geo.size, splitterPoint: splitterPoint)) + .onTapGesture(count: 2) { + onEqualize() + } } .accessibilityElement(children: .contain) .accessibilityLabel(splitViewLabel) @@ -69,7 +75,8 @@ struct SplitView: View { dividerColor: Color, resizeIncrements: NSSize = .init(width: 1, height: 1), @ViewBuilder left: (() -> L), - @ViewBuilder right: (() -> R) + @ViewBuilder right: (() -> R), + onEqualize: @escaping () -> Void ) { self.direction = direction self._split = split @@ -77,6 +84,7 @@ struct SplitView: View { self.resizeIncrements = resizeIncrements self.left = left() self.right = right() + self.onEqualize = onEqualize } private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture { diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 6b8171ff5..103413c70 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -55,6 +55,10 @@ struct TerminalSplitSubtreeView: View { }, right: { TerminalSplitSubtreeView(node: split.right, onResize: onResize) + }, + onEqualize: { + guard let surface = node.leftmostLeaf().surface else { return } + ghostty.splitEqualize(surface: surface) } ) } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index f660ea3ad..6336f0f55 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -48,6 +48,9 @@ class BaseTerminalController: NSWindowController, /// This can be set to show/hide the command palette. @Published var commandPaletteIsShowing: Bool = false + + /// Set if the terminal view should show the update overlay. + @Published var updateOverlayIsVisible: Bool = false /// Whether the terminal surface should focus when the mouse is over it. var focusFollowsMouse: Bool { @@ -69,12 +72,24 @@ class BaseTerminalController: NSWindowController, /// The previous frame information from the window private var savedFrame: SavedFrame? = nil + /// Cache previously applied appearance to avoid unnecessary updates + private var appliedColorScheme: ghostty_color_scheme_e? + /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// An override title for the tab/window set by the user via prompt_tab_title. + /// When set, this takes precedence over the computed title from the terminal. + var titleOverride: String? = nil { + didSet { applyTitleToWindow() } + } + + /// The last computed title from the focused surface (without the override). + private var lastComputedTitle: String = "👻" + /// The time that undo/redo operations that contain running ptys are valid for. var undoExpiration: Duration { ghostty.config.undoTimeout @@ -319,6 +334,37 @@ class BaseTerminalController: NSWindowController, self.alert = alert } + /// Prompt the user to change the tab/window title. + func promptTabTitle() { + guard let window else { return } + + let alert = NSAlert() + alert.messageText = "Change Tab Title" + alert.informativeText = "Leave blank to restore the default." + alert.alertStyle = .informational + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24)) + textField.stringValue = titleOverride ?? window.title + alert.accessoryView = textField + + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + + alert.window.initialFirstResponder = textField + + alert.beginSheetModal(for: window) { [weak self] response in + guard let self else { return } + guard response == .alertFirstButtonReturn else { return } + + let newTitle = textField.stringValue + if newTitle.isEmpty { + self.titleOverride = nil + } else { + self.titleOverride = newTitle + } + } + } + /// Close a surface from a view. func closeSurface( _ view: Ghostty.SurfaceView, @@ -566,23 +612,12 @@ class BaseTerminalController: NSWindowController, // Get the direction from the notification guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return } guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return } - - // Convert Ghostty.SplitFocusDirection to our SplitTree.FocusDirection - let focusDirection: SplitTree.FocusDirection - switch direction { - case .previous: focusDirection = .previous - case .next: focusDirection = .next - case .up: focusDirection = .spatial(.up) - case .down: focusDirection = .spatial(.down) - case .left: focusDirection = .spatial(.left) - case .right: focusDirection = .spatial(.right) - } // Find the node for the target surface guard let targetNode = surfaceTree.root?.node(view: target) else { return } // Find the next surface to focus - guard let nextSurface = surfaceTree.focusTarget(for: focusDirection, from: targetNode) else { + guard let nextSurface = surfaceTree.focusTarget(for: direction.toSplitTreeFocusDirection(), from: targetNode) else { return } @@ -723,10 +758,13 @@ class BaseTerminalController: NSWindowController, } private func titleDidChange(to: String) { + lastComputedTitle = to + applyTitleToWindow() + } + + private func applyTitleToWindow() { guard let window else { return } - - // Set the main window title - window.title = to + window.title = titleOverride ?? lastComputedTitle } func pwdDidChange(to: URL?) { @@ -818,7 +856,18 @@ class BaseTerminalController: NSWindowController, } } - func fullscreenDidChange() {} + func fullscreenDidChange() { + guard let fullscreenStyle else { return } + + // When we enter fullscreen, we want to show the update overlay so that it + // is easily visible. For native fullscreen this is visible by showing the + // menubar but we don't want to rely on that. + if fullscreenStyle.isFullscreen { + updateOverlayIsVisible = true + } else { + updateOverlayIsVisible = defaultUpdateOverlayVisibility() + } + } // MARK: Clipboard Confirmation @@ -900,6 +949,28 @@ class BaseTerminalController: NSWindowController, fullscreenStyle = NativeFullscreen(window) fullscreenStyle?.delegate = self } + + // Set our update overlay state + updateOverlayIsVisible = defaultUpdateOverlayVisibility() + } + + func defaultUpdateOverlayVisibility() -> Bool { + guard let window else { return true } + + // No titlebar we always show the update overlay because it can't support + // updates in the titlebar + guard window.styleMask.contains(.titled) else { + return true + } + + // If it's a non terminal window we can't trust it has an update accessory, + // so we always want to show the overlay. + guard let window = window as? TerminalWindow else { + return true + } + + // Show the overlay if the window isn't. + return !window.supportsUpdateAccessory } // MARK: NSWindowDelegate @@ -989,6 +1060,10 @@ class BaseTerminalController: NSWindowController, window.performClose(sender) } + @IBAction func changeTabTitle(_ sender: Any) { + promptTabTitle() + } + @IBAction func splitRight(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT) @@ -1087,6 +1162,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 } @@ -1111,3 +1202,46 @@ class BaseTerminalController: NSWindowController, } } } + +extension BaseTerminalController: NSMenuItemValidation { + func validateMenuItem(_ item: NSMenuItem) -> Bool { + switch item.action { + case #selector(findHide): + return focusedSurface?.searchState != nil + + default: + return true + } + } + + // MARK: - Surface Color Scheme + + /// Update the surface tree's color scheme only when it actually changes. + /// + /// Calling ``ghostty_surface_set_color_scheme`` triggers + /// ``syncAppearance(_:)`` via notification, + /// so we avoid redundant calls. + func updateColorSchemeForSurfaceTree() { + /// Derive the target scheme from `window-theme` or system appearance. + /// We set the scheme on surfaces so they pick the correct theme + /// and let ``syncAppearance(_:)`` update the window accordingly. + /// + /// Using App's effectiveAppearance here to prevent incorrect updates. + let themeAppearance = NSApplication.shared.effectiveAppearance + let scheme: ghostty_color_scheme_e + if themeAppearance.isDark { + scheme = GHOSTTY_COLOR_SCHEME_DARK + } else { + scheme = GHOSTTY_COLOR_SCHEME_LIGHT + } + guard scheme != appliedColorScheme else { + return + } + for surfaceView in surfaceTree { + if let surface = surfaceView.surface { + ghostty_surface_set_color_scheme(surface, scheme) + } + } + appliedColorScheme = scheme + } +} diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 779c13d9c..a980723ba 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -23,11 +23,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr case "hidden": "TerminalHiddenTitlebar" case "transparent": "TerminalTransparentTitlebar" case "tabs": +#if compiler(>=6.2) if #available(macOS 26.0, *) { "TerminalTabsTitlebarTahoe" } else { "TerminalTabsTitlebarVentura" } +#else + "TerminalTabsTitlebarVentura" +#endif default: defaultValue } @@ -50,6 +54,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig + /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] @@ -100,6 +105,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr selector: #selector(onCloseOtherTabs), name: .ghosttyCloseOtherTabs, object: nil) + center.addObserver( + self, + selector: #selector(onCloseTabsOnTheRight), + name: .ghosttyCloseTabsOnTheRight, + object: nil) center.addObserver( self, selector: #selector(onResetWindowSize), @@ -139,7 +149,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { super.surfaceTreeDidChange(from: from, to: to) - + // Whenever our surface tree changes in any way (new split, close split, etc.) // we want to invalidate our state. invalidateRestorableState() @@ -186,7 +196,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr $0.window?.isMainWindow ?? false } ?? lastMain ?? all.last } - + // The last controller to be main. We use this when paired with "preferredParent" // to find the preferred window to attach new tabs, perform actions, etc. We // always prefer the main window but if there isn't any (because we're triggered @@ -373,9 +383,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr withTarget: controller, expiresAfter: controller.undoExpiration ) { target in - // Close the tab when undoing - undoManager.disableUndoRegistration { - target.closeTab(nil) + // Close the tab when undoing. We do this in a DispatchQueue because + // for some people on macOS Tahoe this caused a crash and the queue + // fixes it. + // https://github.com/ghostty-org/ghostty/pull/9512 + DispatchQueue.main.async { + undoManager.disableUndoRegistration { + target.closeTab(nil) + } } // Register redo action @@ -416,15 +431,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return } - - // This is a surface-level config update. If we have the surface, we - // update our appearance based on it. - guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree.contains(surfaceView) else { return } - - // We can't use surfaceView.derivedConfig because it may not be updated - // yet since it also responds to notifications. - syncAppearance(.init(config)) + /// Surface-level config will be updated in + /// ``Ghostty/Ghostty/SurfaceView/derivedConfig`` then + /// ``TerminalController/focusedSurfaceDidChange(to:)`` } /// Update the accessory view of each tab according to the keyboard @@ -499,53 +508,27 @@ 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 } + /// 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 } + guard let y = derivedConfig.windowPositionY else { return frame } - 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 } + // Convert top-left coordinates to bottom-left origin using our utility extension + let origin = screen.origin( + fromTopLeftOffsetX: CGFloat(x), + offsetY: CGFloat(y), + windowSize: frame.size) - // 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 + // Clamp the origin to ensure the window stays fully visible on screen + var safeOrigin = origin + let vf = screen.visibleFrame + safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width) + safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.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 frame - } - - 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 frame + // Return our new origin + var result = frame + result.origin = safeOrigin + return result } /// This is called anytime a node in the surface tree is being removed. @@ -576,7 +559,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeWindowImmediately() return } - + // Undo if let undoManager, let undoState { // Register undo action to restore the tab @@ -597,15 +580,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - + window.close() } - + private func closeOtherTabsImmediately() { guard let window = window else { return } guard let tabGroup = window.tabGroup else { return } guard tabGroup.windows.count > 1 else { return } - + // Start an undo grouping if let undoManager { undoManager.beginUndoGrouping() @@ -613,7 +596,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr defer { undoManager?.endUndoGrouping() } - + // Iterate through all tabs except the current one. for window in tabGroup.windows where window != self.window { // We ignore any non-terminal tabs. They don't currently exist and we can't @@ -625,10 +608,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr controller.closeTabImmediately(registerRedo: false) } } - + if let undoManager { undoManager.setActionName("Close Other Tabs") - + // We need to register an undo that refocuses this window. Otherwise, the // undo operation above for each tab will steal focus. undoManager.registerUndo( @@ -638,7 +621,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr DispatchQueue.main.async { target.window?.makeKeyAndOrderFront(nil) } - + // Register redo action undoManager.registerUndo( withTarget: target, @@ -650,6 +633,46 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + private func closeTabsOnTheRightImmediately() { + guard let window = window else { return } + guard let tabGroup = window.tabGroup else { return } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return } + + let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } + guard !tabsToClose.isEmpty else { return } + + undoManager?.beginUndoGrouping() + defer { + undoManager?.endUndoGrouping() + } + + for (_, candidate) in tabsToClose { + if let controller = candidate.windowController as? TerminalController { + controller.closeTabImmediately(registerRedo: false) + } + } + + if let undoManager { + undoManager.setActionName("Close Tabs to the Right") + + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration + ) { target in + DispatchQueue.main.async { + target.window?.makeKeyAndOrderFront(nil) + } + + undoManager.registerUndo( + withTarget: target, + expiresAfter: target.undoExpiration + ) { target in + target.closeTabsOnTheRightImmediately() + } + } + } + } + /// Closes the current window (including any other tabs) immediately and without /// confirmation. This will setup proper undo state so the action can be undone. private func closeWindowImmediately() { @@ -724,7 +747,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr case (nil, nil): return true } } - + // Find the index of the key window in our sorted states. This is a bit verbose // but we only need this for this style of undo so we don't want to add it to // UndoState. @@ -750,12 +773,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let controllers = undoStates.map { undoState in TerminalController(ghostty, with: undoState) } - + // The first controller becomes the parent window for all tabs. // If we don't have a first controller (shouldn't be possible?) // then we can't restore tabs. guard let firstController = controllers.first else { return } - + // Add all subsequent controllers as tabs to the first window for controller in controllers.dropFirst() { controller.showWindow(nil) @@ -764,7 +787,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr firstWindow.addTabbedWindow(newWindow, ordered: .above) } } - + // Make the appropriate window key. If we had a key window, restore it. // Otherwise, make the last window key. if let keyWindowIndex, keyWindowIndex < controllers.count { @@ -785,32 +808,25 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// Close all windows, asking for confirmation if necessary. static func closeAllWindows() { - let needsConfirm: Bool = all.contains { - $0.surfaceTree.contains { $0.needsConfirmQuit } - } - - if (!needsConfirm) { + // The window we use for confirmations. Try to find the first window that + // needs quit confirmation. This lets us attach the confirmation to something + // that is running. + guard let confirmWindow = all + .first(where: { $0.surfaceTree.contains(where: { $0.needsConfirmQuit }) })? + .surfaceTree.first(where: { $0.needsConfirmQuit })? + .window + else { closeAllWindowsImmediately() return } - // If we don't have a main window, we just close all windows because - // we have no window to show the modal on top of. I'm sure there's a way - // to do an app-level alert but I don't know how and this case should never - // really happen. - guard let alertWindow = preferredParent?.window else { - closeAllWindowsImmediately() - return - } - - // If we need confirmation by any, show one confirmation for all windows let alert = NSAlert() alert.messageText = "Close All Windows?" alert.informativeText = "All terminal sessions will be terminated." alert.addButton(withTitle: "Close All Windows") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning - alert.beginSheetModal(for: alertWindow, completionHandler: { response in + alert.beginSheetModal(for: confirmWindow, completionHandler: { response in if (response == .alertFirstButtonReturn) { // This is important so that we avoid losing focus when Stage // Manager is used (#8336) @@ -837,6 +853,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let focusedSurface: UUID? let tabIndex: Int? weak var tabGroup: NSWindowTabGroup? + let tabColor: TerminalTabColor } convenience init(_ ghostty: Ghostty.App, @@ -848,6 +865,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr showWindow(nil) if let window { window.setFrame(undoState.frame, display: true) + if let terminalWindow = window as? TerminalWindow { + terminalWindow.tabColor = undoState.tabColor + } // If we have a tab group and index, restore the tab to its original position if let tabGroup = undoState.tabGroup, @@ -883,7 +903,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr surfaceTree: surfaceTree, focusedSurface: focusedSurface?.id, tabIndex: window.tabGroup?.windows.firstIndex(of: window), - tabGroup: window.tabGroup) + tabGroup: window.tabGroup, + tabColor: (window as? TerminalWindow)?.tabColor ?? .none) } //MARK: - NSWindowController @@ -897,9 +918,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 @@ -919,19 +937,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. @@ -1042,7 +1079,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr if let window { LastWindowPosition.shared.save(window) } - + // Remember our last main Self.lastMain = self } @@ -1089,27 +1126,27 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr @IBAction func closeOtherTabs(_ sender: Any?) { guard let window = window else { return } guard let tabGroup = window.tabGroup else { return } - + // If we only have one window then we have no other tabs to close guard tabGroup.windows.count > 1 else { return } - + // Check if we have to confirm close. guard tabGroup.windows.contains(where: { window in // Ignore ourself if window == self.window { return false } - + // Ignore non-terminals guard let controller = window.windowController as? TerminalController else { return false } - + // Check if any surfaces require confirmation return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) }) else { self.closeOtherTabsImmediately() return } - + confirmClose( messageText: "Close Other Tabs?", informativeText: "At least one other tab still has a running process. If you close the tab the process will be killed." @@ -1118,9 +1155,38 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + @IBAction func closeTabsOnTheRight(_ sender: Any?) { + guard let window = window else { return } + guard let tabGroup = window.tabGroup else { return } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return } + + let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } + guard !tabsToClose.isEmpty else { return } + + let needsConfirm = tabsToClose.contains { (_, candidate) in + guard let controller = candidate.windowController as? TerminalController else { + return false + } + + return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) + } + + if !needsConfirm { + self.closeTabsOnTheRightImmediately() + return + } + + confirmClose( + messageText: "Close Tabs on the Right?", + informativeText: "At least one tab to the right still has a running process. If you close the tab the process will be killed." + ) { + self.closeTabsOnTheRightImmediately() + } + } + @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?) { @@ -1130,24 +1196,19 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // if we're closing the window. If we don't have a tabgroup for any // reason we check ourselves. let windows: [NSWindow] = window.tabGroup?.windows ?? [window] - - // Check if any windows require close confirmation. - let needsConfirm = windows.contains { tabWindow in - guard let controller = tabWindow.windowController as? TerminalController else { - return false - } - return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) - } - - // If none need confirmation then we can just close all the windows. - if !needsConfirm { + guard let confirmController = windows + .compactMap({ $0.windowController as? TerminalController }) + .first(where: { $0.surfaceTree.contains(where: { $0.needsConfirmQuit }) }) + else { closeWindowImmediately() return } - confirmClose( + // We call confirmClose on the proper controller so the alert is + // attached to the window that needs confirmation. + confirmController.confirmClose( messageText: "Close Window?", - informativeText: "All terminal sessions in this window will be terminated." + informativeText: "All terminal sessions in this window will be terminated.", ) { self.closeWindowImmediately() } @@ -1164,7 +1225,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } //MARK: - TerminalViewDelegate - + override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { super.focusedSurfaceDidChange(to: to) @@ -1228,7 +1289,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Get our target window let targetWindow = tabbedWindows[finalIndex] - + // Moving tabs on macOS 26 RC causes very nasty visual glitches in the titlebar tabs. // I believe this is due to messed up constraints for our hacky tab bar. I'd like to // find a better workaround. For now, this improves things dramatically. @@ -1241,7 +1302,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr DispatchQueue.main.async { selectedWindow.makeKey() } - + return } } @@ -1324,6 +1385,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeOtherTabs(self) } + @objc private func onCloseTabsOnTheRight(notification: SwiftUI.Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree.contains(target) else { return } + closeTabsOnTheRight(self) + } + @objc private func onCloseWindow(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(target) else { return } @@ -1358,12 +1425,16 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let macosWindowButtons: Ghostty.MacOSWindowButtons let macosTitlebarStyle: String let maximize: Bool + let windowPositionX: Int16? + let windowPositionY: Int16? init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.macosWindowButtons = .visible self.macosTitlebarStyle = "system" self.maximize = false + self.windowPositionX = nil + self.windowPositionY = nil } init(_ config: Ghostty.Config) { @@ -1371,43 +1442,99 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr self.macosWindowButtons = config.macosWindowButtons self.macosTitlebarStyle = config.macosTitlebarStyle self.maximize = config.maximize + self.windowPositionX = config.windowPositionX + self.windowPositionY = config.windowPositionY } } } // MARK: NSMenuItemValidation -extension TerminalController: NSMenuItemValidation { - func validateMenuItem(_ item: NSMenuItem) -> Bool { +extension TerminalController { + override func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { + case #selector(closeTabsOnTheRight): + guard let window, let tabGroup = window.tabGroup else { return false } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false } + return tabGroup.windows.enumerated().contains { $0.offset > currentIndex } + case #selector(returnToDefaultSize): guard let window else { return false } - + // Native fullscreen windows can't revert to default size. if window.styleMask.contains(.fullScreen) { return false } - + // If we're fullscreen at all then we can't change size if fullscreenStyle?.isFullscreen ?? false { return false } - + // 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/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 1e640967e..425f7ffb1 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,14 +4,20 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 5 + static let version: Int = 7 let focusedSurface: String? let surfaceTree: SplitTree + let effectiveFullscreenMode: FullscreenMode? + let tabColor: TerminalTabColor + let titleOverride: String? init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree + self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode + self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none + self.titleOverride = controller.titleOverride } init?(coder aDecoder: NSCoder) { @@ -28,6 +34,9 @@ class TerminalRestorableState: Codable { self.surfaceTree = v.value.surfaceTree self.focusedSurface = v.value.focusedSurface + self.effectiveFullscreenMode = v.value.effectiveFullscreenMode + self.tabColor = v.value.tabColor + self.titleOverride = v.value.titleOverride } func encode(with coder: NSCoder) { @@ -91,6 +100,12 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } + // Restore our tab color + (window as? TerminalWindow)?.tabColor = state.tabColor + + // Restore the tab title override + c.titleOverride = state.titleOverride + // Setup our restored state on the controller // Find the focused surface in surfaceTree if let focusedStr = state.focusedSurface { @@ -109,6 +124,13 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { } completionHandler(window, nil) + guard let mode = state.effectiveFullscreenMode, mode != .native else { + // We let AppKit handle native fullscreen + return + } + // Give the window to AppKit first, then adjust its frame and style + // to minimise any visible frame changes. + c.toggleFullscreen(mode: mode) } /// This restores the focus state of the surfaceview within the given window. When restoring, diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift new file mode 100644 index 000000000..08d89324c --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -0,0 +1,185 @@ +import AppKit +import SwiftUI + +enum TerminalTabColor: Int, CaseIterable, Codable { + case none + case blue + case purple + case pink + case red + case orange + case yellow + case green + case teal + case graphite + + var localizedName: String { + switch self { + case .none: + return "None" + case .blue: + return "Blue" + case .purple: + return "Purple" + case .pink: + return "Pink" + case .red: + return "Red" + case .orange: + return "Orange" + case .yellow: + return "Yellow" + case .green: + return "Green" + case .teal: + return "Teal" + case .graphite: + return "Graphite" + } + } + + var displayColor: NSColor? { + switch self { + case .none: + return nil + case .blue: + return .systemBlue + case .purple: + return .systemPurple + case .pink: + return .systemPink + case .red: + return .systemRed + case .orange: + return .systemOrange + case .yellow: + return .systemYellow + case .green: + return .systemGreen + case .teal: + if #available(macOS 13.0, *) { + return .systemMint + } else { + return .systemTeal + } + case .graphite: + return .systemGray + } + } + + func swatchImage(selected: Bool) -> NSImage { + let size = NSSize(width: 18, height: 18) + return NSImage(size: size, flipped: false) { rect in + let circleRect = rect.insetBy(dx: 1, dy: 1) + let circlePath = NSBezierPath(ovalIn: circleRect) + + if let fillColor = self.displayColor { + fillColor.setFill() + circlePath.fill() + } else { + NSColor.clear.setFill() + circlePath.fill() + NSColor.quaternaryLabelColor.setStroke() + circlePath.lineWidth = 1 + circlePath.stroke() + } + + if self == .none { + let slash = NSBezierPath() + slash.move(to: NSPoint(x: circleRect.minX + 2, y: circleRect.minY + 2)) + slash.line(to: NSPoint(x: circleRect.maxX - 2, y: circleRect.maxY - 2)) + slash.lineWidth = 1.5 + NSColor.secondaryLabelColor.setStroke() + slash.stroke() + } + + if selected { + let highlight = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)) + highlight.lineWidth = 2 + NSColor.controlAccentColor.setStroke() + highlight.stroke() + } + + return true + } + } +} + +// MARK: - Menu View + +/// A SwiftUI view displaying a color palette for tab color selection. +/// Used as a custom view inside an NSMenuItem in the tab context menu. +struct TabColorMenuView: View { + @State private var currentSelection: TerminalTabColor + let onSelect: (TerminalTabColor) -> Void + + init(selectedColor: TerminalTabColor, onSelect: @escaping (TerminalTabColor) -> Void) { + self._currentSelection = State(initialValue: selectedColor) + self.onSelect = onSelect + } + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text("Tab Color") + .padding(.bottom, 2) + + ForEach(Self.paletteRows, id: \.self) { row in + HStack(spacing: 2) { + ForEach(row, id: \.self) { color in + TabColorSwatch( + color: color, + isSelected: color == currentSelection + ) { + currentSelection = color + onSelect(color) + } + } + } + } + } + .padding(.leading, Self.leadingPadding) + .padding(.trailing, 12) + .padding(.top, 4) + .padding(.bottom, 4) + } + + static let paletteRows: [[TerminalTabColor]] = [ + [.none, .blue, .purple, .pink, .red], + [.orange, .yellow, .green, .teal, .graphite], + ] + + /// Leading padding to align with the menu's icon gutter. + /// macOS 26 introduced icons in menus, requiring additional padding. + private static var leadingPadding: CGFloat { + if #available(macOS 26.0, *) { + return 40 + } else { + return 12 + } + } +} + +/// A single color swatch button in the tab color palette. +private struct TabColorSwatch: View { + let color: TerminalTabColor + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Group { + if color == .none { + Image(systemName: isSelected ? "circle.slash" : "circle") + .foregroundStyle(.secondary) + } else if let displayColor = color.displayColor { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle.fill") + .foregroundStyle(Color(nsColor: displayColor)) + } + } + .font(.system(size: 16)) + .frame(width: 20, height: 20) + } + .buttonStyle(.plain) + .help(color.localizedName) + } +} diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index b5be0ae42..fd53a617b 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -31,6 +31,9 @@ protocol TerminalViewModel: ObservableObject { /// The command palette state. var commandPaletteIsShowing: Bool { get set } + + /// The update overlay should be visible. + var updateOverlayIsVisible: Bool { get } } /// The main terminal view. This terminal view supports splits. @@ -42,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() @@ -97,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 : []) @@ -105,10 +110,34 @@ struct TerminalView: View { TerminalCommandPaletteView( surfaceView: surfaceView, isPresented: $viewModel.commandPaletteIsShowing, - ghosttyConfig: ghostty.config) { action in + ghosttyConfig: ghostty.config, + updateViewModel: (NSApp.delegate as? AppDelegate)?.updateViewModel) { action in self.delegate?.performAction(action, on: surfaceView) } } + + // Show update information above all else. + if viewModel.updateOverlayIsVisible { + UpdateOverlay() + } + } + .frame(maxWidth: .greatestFiniteMagnitude, maxHeight: .greatestFiniteMagnitude) + } + } +} + +fileprivate struct UpdateOverlay: View { + var body: some View { + if let appDelegate = NSApp.delegate as? AppDelegate { + VStack { + Spacer() + + HStack { + Spacer() + UpdatePill(model: appDelegate.updateViewModel) + .padding(.bottom, 9) + .padding(.trailing, 9) + } } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift index dc7dd7633..dd8b258f3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift @@ -1,6 +1,9 @@ import AppKit class HiddenTitlebarTerminalWindow: TerminalWindow { + // No titlebar, we don't support accessories. + override var supportsUpdateAccessory: Bool { false } + override func awakeFromNib() { super.awakeFromNib() diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 3ab6293dc..0c0ac0646 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -5,6 +5,12 @@ import GhosttyKit /// The base class for all standalone, "normal" terminal windows. This sets the basic /// style and configuration of the window based on the app configuration. class TerminalWindow: NSWindow { + /// Posted when a terminal window awakes from nib. + static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake") + + /// Posted when a terminal window will close + static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose") + /// This is the key in UserDefaults to use for the default `level` value. This is /// used by the manual float on top menu item feature. static let defaultLevelKey: String = "TerminalDefaultLevel" @@ -15,14 +21,47 @@ class TerminalWindow: NSWindow { /// Reset split zoom button in titlebar private let resetZoomAccessory = NSTitlebarAccessoryViewController() + /// Update notification UI in titlebar + private let updateAccessory = NSTitlebarAccessoryViewController() + + /// Visual indicator that mirrors the selected tab color. + private lazy var tabColorIndicator: NSHostingView = { + let view = NSHostingView(rootView: TabColorIndicatorView(tabColor: tabColor)) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() + + /// Sets up our tab context menu + private var tabMenuObserver: NSObjectProtocol? = nil + + /// Whether this window supports the update accessory. If this is false, then views within this + /// window should determine how to show update notifications. + var supportsUpdateAccessory: Bool { + // Native window supports it. + true + } + + /// Glass effect view for liquid glass background when transparency is enabled + private var glassEffectView: NSView? /// Gets the terminal controller from the window controller. var terminalController: TerminalController? { windowController as? TerminalController } + /// The color assigned to this window's tab. Setting this updates the tab color indicator + /// and marks the window's restorable state as dirty. + var tabColor: TerminalTabColor = .none { + didSet { + guard tabColor != oldValue else { return } + tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor) + invalidateRestorableState() + } + } + // MARK: NSWindow Overrides override var toolbar: NSToolbar? { @@ -35,6 +74,20 @@ class TerminalWindow: NSWindow { } override func awakeFromNib() { + // Notify that this terminal window has loaded + NotificationCenter.default.post(name: Self.terminalDidAwake, object: self) + + // This is fragile, but there doesn't seem to be an official API for customizing + // native tab bar menus. + tabMenuObserver = NotificationCenter.default.addObserver( + forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"), + object: nil, + queue: .main + ) { [weak self] n in + guard let self, let menu = n.object as? NSMenu else { return } + self.configureTabContextMenuIfNeeded(menu) + } + // This is required so that window restoration properly creates our tabs // again. I'm not sure why this is required. If you don't do this, then // tabs restore as separate windows. @@ -42,14 +95,14 @@ class TerminalWindow: NSWindow { DispatchQueue.main.async { self.tabbingMode = .automatic } - + // All new windows are based on the app config at the time of creation. guard let appDelegate = NSApp.delegate as? AppDelegate else { return } let config = appDelegate.ghostty.config // Setup our initial config derivedConfig = .init(config) - + // If there is a hardcoded title in the configuration, we set that // immediately. Future `set_title` apprt actions will override this // if necessary but this ensures our window loads with the proper @@ -65,8 +118,7 @@ class TerminalWindow: NSWindow { // fallback to original centering behavior setInitialWindowPosition( x: config.windowPositionX, - y: config.windowPositionY, - windowDecorations: config.windowDecorations) + y: config.windowPositionY) // If our traffic buttons should be hidden, then hide them if config.macosWindowButtons == .hidden { @@ -85,14 +137,32 @@ class TerminalWindow: NSWindow { })) addTitlebarAccessoryViewController(resetZoomAccessory) resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false + + // Create update notification accessory + if supportsUpdateAccessory { + updateAccessory.layoutAttribute = .right + updateAccessory.view = NonDraggableHostingView(rootView: UpdateAccessoryView( + viewModel: viewModel, + model: appDelegate.updateViewModel + )) + addTitlebarAccessoryViewController(updateAccessory) + updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false + } } // Setup the accessory view for tabs that shows our keyboard shortcuts, // zoomed state, etc. Note I tried to use SwiftUI here but ran into issues // where buttons were not clickable. - let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton]) + tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor) + + let stackView = NSStackView() + stackView.orientation = .horizontal stackView.setHuggingPriority(.defaultHigh, for: .horizontal) - stackView.spacing = 3 + stackView.spacing = 4 + stackView.alignment = .centerY + stackView.addArrangedSubview(tabColorIndicator) + stackView.addArrangedSubview(keyEquivalentLabel) + stackView.addArrangedSubview(resetZoomTabButton) tab.accessoryView = stackView // Get our saved level @@ -104,6 +174,11 @@ class TerminalWindow: NSWindow { override var canBecomeKey: Bool { return true } override var canBecomeMain: Bool { return true } + override func close() { + NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) + super.close() + } + override func becomeKey() { super.becomeKey() resetZoomTabButton.contentTintColor = .controlAccentColor @@ -124,6 +199,12 @@ class TerminalWindow: NSWindow { } else { tabBarDidDisappear() } + viewModel.isMainWindow = true + } + + override func resignMain() { + super.resignMain() + viewModel.isMainWindow = false } override func mergeAllWindows(_ sender: Any?) { @@ -162,9 +243,35 @@ class TerminalWindow: NSWindow { /// added. static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + func findTitlebarView() -> NSView? { + // Find our tab bar. If it doesn't exist we don't do anything. + // + // In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`. + // In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes; + // in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`. + // ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205 + guard let themeFrameView = contentView?.rootView else { return nil } + let titlebarView = if themeFrameView.responds(to: Selector(("titlebarView"))) { + themeFrameView.value(forKey: "titlebarView") as? NSView + } else { + NSView?.none + } + return titlebarView + } + + func findTabBar() -> NSView? { + findTitlebarView()?.firstDescendant(withClassName: "NSTabBar") + } + /// Returns true if there is a tab bar visible on this window. var hasTabBar: Bool { - contentView?.firstViewFromRoot(withClassName: "NSTabBar") != nil + findTabBar() != nil + } + + var hasMoreThanOneTabs: Bool { + /// accessing ``tabGroup?.windows`` here + /// will cause other edge cases, be careful + (tabbedWindows?.count ?? 0) > 1 } func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool { @@ -198,6 +305,9 @@ class TerminalWindow: NSWindow { if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) { removeTitlebarAccessoryViewController(at: idx) } + + // We don't need to do this with the update accessory. I don't know why but + // everything works fine. } private func tabBarDidDisappear() { @@ -260,7 +370,7 @@ class TerminalWindow: NSWindow { button.isBordered = false button.allowsExpansionToolTips = true button.toolTip = "Reset Zoom" - button.contentTintColor = .controlAccentColor + button.contentTintColor = isMainWindow ? .controlAccentColor : .secondaryLabelColor button.state = .on button.image = NSImage(named:"ResetZoom") button.frame = NSRect(x: 0, y: 0, width: 20, height: 20) @@ -277,6 +387,12 @@ class TerminalWindow: NSWindow { // Whenever we change the window title we must also update our // tab title if we're using custom fonts. tab.attributedTitle = attributedTitle + /// We also needs to update this here, just in case + /// the value is not what we want + /// + /// Check ``titlebarFont`` down below + /// to see why we need to check `hasMoreThanOneTabs` here + titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs } } @@ -286,6 +402,12 @@ class TerminalWindow: NSWindow { let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) titlebarTextField?.font = font + /// We check `hasMoreThanOneTabs` here because the system + /// may copy this setting to the tab’s text field at some point(e.g. entering/exiting fullscreen), + /// which can cause the title to be vertically misaligned (shifted downward). + /// + /// This behaviour is the opposite of what happens in the title bar’s text field, which is quite odd... + titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs tab.attributedTitle = attributedTitle } } @@ -338,6 +460,7 @@ class TerminalWindow: NSWindow { // have no effect if the window is not visible. Ultimately, we'll have this called // at some point when a surface becomes focused. guard isVisible else { return } + defer { updateColorSchemeForSurfaceTree() } // Basic properties appearance = surfaceConfig.windowAppearance @@ -356,7 +479,15 @@ class TerminalWindow: NSWindow { // Terminal.app more easily. backgroundColor = .white.withAlphaComponent(0.001) - if let appDelegate = NSApp.delegate as? AppDelegate { + // Add liquid glass behind terminal content + if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle { + setupGlassLayer() + } else if let appDelegate = NSApp.delegate as? AppDelegate { + // If we had a prior glass layer we should remove it + if #available(macOS 26.0, *) { + removeGlassLayer() + } + ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -364,6 +495,11 @@ class TerminalWindow: NSWindow { } else { isOpaque = true + // Remove liquid glass when not transparent + if #available(macOS 26.0, *) { + removeGlassLayer() + } + let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor) self.backgroundColor = backgroundColor.withAlphaComponent(1) } @@ -400,9 +536,13 @@ class TerminalWindow: NSWindow { return derivedConfig.backgroundColor.withAlphaComponent(alpha) } - private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { + func updateColorSchemeForSurfaceTree() { + terminalController?.updateColorSchemeForSurfaceTree() + } + + private func setInitialWindowPosition(x: Int16?, y: Int16?) { // If we don't have an X/Y then we try to use the previously saved window pos. - guard let x, let y else { + guard x != nil, y != nil else { if (!LastWindowPosition.shared.restore(self)) { center() } @@ -416,19 +556,14 @@ class TerminalWindow: NSWindow { return } - // Convert top-left coordinates to bottom-left origin using our utility extension - let origin = screen.origin( - fromTopLeftOffsetX: CGFloat(x), - offsetY: CGFloat(y), - windowSize: frame.size) - - // Clamp the origin to ensure the window stays fully visible on screen - var safeOrigin = origin - let vf = screen.visibleFrame - safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width) - safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height) - - setFrameOrigin(safeOrigin) + // We have an X/Y, use our controller function to set it up. + guard let terminalController else { + center() + return + } + + let frame = terminalController.adjustForWindowPosition(frame: frame, on: screen) + setFrameOrigin(frame.origin) } private func hideWindowButtons() { @@ -437,19 +572,75 @@ class TerminalWindow: NSWindow { standardWindowButton(.zoomButton)?.isHidden = true } + deinit { + if let observer = tabMenuObserver { + NotificationCenter.default.removeObserver(observer) + } + } + +#if compiler(>=6.2) + // MARK: Glass + + @available(macOS 26.0, *) + private func setupGlassLayer() { + // Remove existing glass effect view + removeGlassLayer() + + // Get the window content view (parent of the NSHostingView) + guard let contentView else { return } + guard let windowContentView = contentView.superview else { return } + + // Create NSGlassEffectView for native glass effect + let effectView = NSGlassEffectView() + + // Map Ghostty config to NSGlassEffectView style + switch derivedConfig.backgroundBlur { + case .macosGlassRegular: + effectView.style = NSGlassEffectView.Style.regular + case .macosGlassClear: + effectView.style = NSGlassEffectView.Style.clear + default: + // Should not reach here since we check for glass style before calling + // setupGlassLayer() + assertionFailure() + } + + effectView.cornerRadius = derivedConfig.windowCornerRadius + effectView.tintColor = preferredBackgroundColor + effectView.frame = windowContentView.bounds + effectView.autoresizingMask = [.width, .height] + + // Position BELOW the terminal content to act as background + windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView) + glassEffectView = effectView + } + + @available(macOS 26.0, *) + private func removeGlassLayer() { + glassEffectView?.removeFromSuperview() + glassEffectView = nil + } +#endif // compiler(>=6.2) + // MARK: Config struct DerivedConfig { let title: String? + let backgroundBlur: Ghostty.Config.BackgroundBlur let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons + let macosTitlebarStyle: String + let windowCornerRadius: CGFloat init() { self.title = nil self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible + self.backgroundBlur = .disabled + self.macosTitlebarStyle = "transparent" + self.windowCornerRadius = 16 } init(_ config: Ghostty.Config) { @@ -457,6 +648,18 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons + self.backgroundBlur = config.backgroundBlur + self.macosTitlebarStyle = config.macosTitlebarStyle + + // Set corner radius based on macos-titlebar-style + // Native, transparent, and hidden styles use 16pt radius + // Tabs style uses 20pt radius + switch config.macosTitlebarStyle { + case "tabs": + self.windowCornerRadius = 20 + default: + self.windowCornerRadius = 16 + } } } } @@ -467,28 +670,28 @@ extension TerminalWindow { class ViewModel: ObservableObject { @Published var isSurfaceZoomed: Bool = false @Published var hasToolbar: Bool = false + @Published var isMainWindow: Bool = true + + /// Calculates the top padding based on toolbar visibility and macOS version + fileprivate var accessoryTopPadding: CGFloat { + if #available(macOS 26.0, *) { + return hasToolbar ? 10 : 5 + } else { + return hasToolbar ? 9 : 4 + } + } } struct ResetZoomAccessoryView: View { @ObservedObject var viewModel: ViewModel let action: () -> Void - - // The padding from the top that the view appears. This was all just manually - // measured based on the OS. - var topPadding: CGFloat { - if #available(macOS 26.0, *) { - return viewModel.hasToolbar ? 10 : 5 - } else { - return viewModel.hasToolbar ? 9 : 4 - } - } var body: some View { if viewModel.isSurfaceZoomed { VStack { Button(action: action) { Image("ResetZoom") - .foregroundColor(.accentColor) + .foregroundColor(viewModel.isMainWindow ? .accentColor : .secondary) } .buttonStyle(.plain) .help("Reset Split Zoom") @@ -497,10 +700,141 @@ extension TerminalWindow { } // With a toolbar, the window title is taller, so we need more padding // to properly align. - .padding(.top, topPadding) + .padding(.top, viewModel.accessoryTopPadding) // We always need space at the end of the titlebar .padding(.trailing, 10) } } } + + /// A pill-shaped button that displays update status and provides access to update actions. + struct UpdateAccessoryView: View { + @ObservedObject var viewModel: ViewModel + @ObservedObject var model: UpdateViewModel + + var body: some View { + // We use the same top/trailing padding so that it hugs the same. + UpdatePill(model: model) + .padding(.top, viewModel.accessoryTopPadding) + .padding(.trailing, viewModel.accessoryTopPadding) + } + } + +} + +/// A small circle indicator displayed in the tab accessory view that shows +/// the user-assigned tab color. When no color is set, the view is hidden. +private struct TabColorIndicatorView: View { + /// The tab color to display. + let tabColor: TerminalTabColor + + var body: some View { + if let color = tabColor.displayColor { + Circle() + .fill(Color(color)) + .frame(width: 6, height: 6) + } else { + Circle() + .fill(Color.clear) + .frame(width: 6, height: 6) + .hidden() + } + } +} + +// MARK: - Tab Context Menu + +extension TerminalWindow { + private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + private static let changeTitleMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.changeTitleMenuItem") + private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") + + private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") + + func configureTabContextMenuIfNeeded(_ menu: NSMenu) { + guard isTabContextMenu(menu) else { return } + + // Get the target from an existing menu item. The native tab context menu items + // target the specific window/controller that was right-clicked, not the focused one. + // We need to use that same target so validation and action use the correct tab. + let targetController = menu.items + .first { $0.action == NSSelectorFromString("performClose:") } + .flatMap { $0.target as? NSWindow } + .flatMap { $0.windowController as? TerminalController } + + // Close tabs to the right + let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") + item.identifier = Self.closeTabsOnRightMenuItemIdentifier + item.target = targetController + item.setImageIfDesired(systemSymbolName: "xmark") + if menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) == nil, + menu.insertItem(item, after: NSSelectorFromString("performClose:")) == nil { + menu.addItem(item) + } + + // Other close items should have the xmark to match Safari on macOS 26 + for menuItem in menu.items { + if menuItem.action == NSSelectorFromString("performClose:") || + menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { + menuItem.setImageIfDesired(systemSymbolName: "xmark") + } + } + + appendTabModifierSection(to: menu, target: targetController) + } + + private func isTabContextMenu(_ menu: NSMenu) -> Bool { + guard NSApp.keyWindow === self else { return false } + + // These selectors must all exist for it to be a tab context menu. + let requiredSelectors: Set = [ + "performClose:", + "performCloseOtherTabs:", + "moveTabToNewWindow:", + "toggleTabOverview:" + ] + + let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) + return requiredSelectors.isSubset(of: selectorNames) + } + + private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) { + menu.removeItems(withIdentifiers: [ + Self.tabColorSeparatorIdentifier, + Self.changeTitleMenuItemIdentifier, + Self.tabColorPaletteIdentifier + ]) + + let separator = NSMenuItem.separator() + separator.identifier = Self.tabColorSeparatorIdentifier + menu.addItem(separator) + + // Change Title... + let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") + changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier + changeTitleItem.target = target + changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line") + menu.addItem(changeTitleItem) + + let paletteItem = NSMenuItem() + paletteItem.identifier = Self.tabColorPaletteIdentifier + paletteItem.view = makeTabColorPaletteView( + selectedColor: (target?.window as? TerminalWindow)?.tabColor ?? .none + ) { [weak target] color in + (target?.window as? TerminalWindow)?.tabColor = color + } + menu.addItem(paletteItem) + } +} + +private func makeTabColorPaletteView( + selectedColor: TerminalTabColor, + selectionHandler: @escaping (TerminalTabColor) -> Void +) -> NSView { + let hostingView = NSHostingView(rootView: TabColorMenuView( + selectedColor: selectedColor, + onSelect: selectionHandler + )) + hostingView.frame.size = hostingView.intrinsicContentSize + return hostingView } diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 260fac4cc..5d910d2e0 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -8,6 +8,10 @@ import SwiftUI class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { /// The view model for SwiftUI views private var viewModel = ViewModel() + + /// Titlebar tabs can't support the update accessory because of the way we layout + /// the native tabs back into the menu bar. + override var supportsUpdateAccessory: Bool { false } deinit { tabBarObserver = nil @@ -15,9 +19,21 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // MARK: NSWindow + override var titlebarFont: NSFont? { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.viewModel.titleFont = self.titlebarFont + } + } + } + override var title: String { didSet { - viewModel.title = title + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.viewModel.title = self.title + } } } @@ -42,6 +58,45 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Check if we have a tab bar and set it up if we have to. See the comment // on this function to learn why we need to check this here. setupTabBar() + + viewModel.isMainWindow = true + } + + override func resignMain() { + super.resignMain() + + viewModel.isMainWindow = false + } + + /// On our Tahoe titlebar tabs, we need to fix up right click events because they don't work + /// naturally due to whatever mess we made. + override func sendEvent(_ event: NSEvent) { + guard viewModel.hasTabBar else { + super.sendEvent(event) + return + } + + let isRightClick = + event.type == .rightMouseDown || + (event.type == .otherMouseDown && event.buttonNumber == 2) || + (event.type == .leftMouseDown && event.modifierFlags.contains(.control)) + guard isRightClick else { + super.sendEvent(event) + return + } + + guard let tabBarView = findTabBar() else { + super.sendEvent(event) + return + } + + let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil) + guard tabBarView.bounds.contains(locationInTabBar) else { + super.sendEvent(event) + return + } + + tabBarView.rightMouseDown(with: event) } // This is called by macOS for native tabbing in order to add the tab bar. We hook into @@ -49,10 +104,19 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { // If this is the tab bar then we need to set it up for the titlebar guard isTabBar(childViewController) else { + // After dragging a tab into a new window, `hasTabBar` needs to be + // updated to properly review window title + viewModel.hasTabBar = false + super.addTitlebarAccessoryViewController(childViewController) return } + // When an existing tab is being dragged in to another tab group, + // system will also try to add tab bar to this window, so we want to reset observer, + // to put tab bar where we want again + tabBarObserver = nil + // Some setup needs to happen BEFORE it is added, such as layout. If // we don't do this before the call below, we'll trigger an AppKit // assertion. @@ -111,19 +175,24 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // We only want to setup the observer once guard tabBarObserver == nil else { return } - // Find our tab bar. If it doesn't exist we don't do anything. - guard let tabBar = contentView?.rootView.firstDescendant(withClassName: "NSTabBar") else { return } + guard + let titlebarView = findTitlebarView(), + let tabBar = findTabBar() + else { return } // View model updates must happen on their own ticks. - DispatchQueue.main.async { - self.viewModel.hasTabBar = true + DispatchQueue.main.async { [weak self] in + self?.viewModel.hasTabBar = true } // Find our clip view guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } guard let accessoryView = clipView.subviews[safe: 0] else { return } - guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return } guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } + + // Make sure tabBar's height won't be stretched + guard let newTabButton = titlebarView.firstDescendant(withClassName: "NSTabBarNewTabButton") else { return } + tabBar.frame.size.height = newTabButton.frame.width // The container is the view that we'll constrain our tab bar within. let container = toolbarView @@ -205,6 +274,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool case .title: let item = NSToolbarItem(itemIdentifier: .title) item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel)) + // Fix: https://github.com/ghostty-org/ghostty/discussions/9027 + item.view?.setContentCompressionResistancePriority(.required, for: .horizontal) item.visibilityPriority = .user item.isEnabled = true @@ -221,8 +292,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // MARK: SwiftUI class ViewModel: ObservableObject { + @Published var titleFont: NSFont? @Published var title: String = "👻 Ghostty" @Published var hasTabBar: Bool = false + @Published var isMainWindow: Bool = true } } @@ -245,15 +318,24 @@ extension TitlebarTabsTahoeTerminalWindow { var body: some View { if !viewModel.hasTabBar { - Text(title) - .lineLimit(1) - .truncationMode(.tail) + titleText } else { // 1x1.gif strikes again! For real: if we render a zero-sized // view here then the toolbar just disappears our view. I don't - // know. + // know. This appears fixed in 26.1 Beta but keep it safe for 26.0. Color.clear.frame(width: 1, height: 1) } } + + @ViewBuilder + var titleText: some View { + Text(title) + .font(viewModel.titleFont.flatMap(Font.init(_:))) + .foregroundStyle(viewModel.isMainWindow ? .primary : .secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .center) + .opacity(viewModel.hasTabBar ? 0 : 1) // hide when in fullscreen mode, where title bar will appear in the leading area under window buttons + } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 8589877d8..c0aad46b3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -2,6 +2,10 @@ import Cocoa /// Titlebar tabs for macOS 13 to 15. class TitlebarTabsVenturaTerminalWindow: TerminalWindow { + /// Titlebar tabs can't support the update accessory because of the way we layout + /// the native tabs back into the menu bar. + override var supportsUpdateAccessory: Bool { false } + /// This is used to determine if certain elements should be drawn light or dark and should /// be updated whenever the window background color or surrounding elements changes. fileprivate var isLightTheme: Bool = false @@ -139,8 +143,13 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { super.syncAppearance(surfaceConfig) + // override appearance based on the terminal's background color + if let preferredBackgroundColor { + appearance = (preferredBackgroundColor.isLightColor ? NSAppearance(named: .aqua) : NSAppearance(named: .darkAqua)) + } // Update our window light/darkness based on our updated background color + let themeChanged = isLightTheme != OSColor(surfaceConfig.backgroundColor).isLightColor isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor // Update our titlebar color @@ -150,7 +159,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) } - if (isOpaque) { + if (isOpaque || themeChanged) { // If there is transparency, calling this will make the titlebar opaque // so we only call this if we are opaque. updateTabBar() @@ -183,41 +192,33 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // so we need to do it manually. private func updateNewTabButtonOpacity() { guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } - guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { - $0 as? NSImageView != nil - }) as? NSImageView else { return } + guard let newTabButtonImageView = newTabButton.firstDescendant(withClassName: "NSImageView") as? NSImageView else { return } newTabButtonImageView.alphaValue = isKeyWindow ? 1 : 0.5 } - // Color the new tab button's image to match the color of the tab title/keyboard shortcut labels, - // just as it does in the stock tab bar. + /// Update: This method only add a vibrant overlay now, + /// since the image itself supports light/dark tint, + /// and system could restore it any time, + /// altering it will only cause maintenance burden for us. + /// + /// And if we hide original image, + /// ``updateNewTabButtonOpacity`` will not work + /// + /// ~~Color the new tab button's image to match the color of the tab title/keyboard shortcut labels,~~ + /// ~~just as it does in the stock tab bar.~~ private func updateNewTabButtonImage() { guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } - guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { - $0 as? NSImageView != nil - }) as? NSImageView else { return } + guard let newTabButtonImageView = newTabButton.firstDescendant(withClassName: "NSImageView") as? NSImageView else { return } guard let newTabButtonImage = newTabButtonImageView.image else { return } + let imageLayer = newTabButtonImageLayer ?? VibrantLayer(forAppearance: isLightTheme ? .light : .dark)! + imageLayer.frame = NSRect(origin: NSPoint(x: newTabButton.bounds.midX - newTabButtonImage.size.width/2, y: newTabButton.bounds.midY - newTabButtonImage.size.height/2), size: newTabButtonImage.size) + imageLayer.contentsGravity = .resizeAspect + imageLayer.opacity = 0.5 - if newTabButtonImageLayer == nil { - let fillColor: NSColor = isLightTheme ? .black.withAlphaComponent(0.85) : .white.withAlphaComponent(0.85) - let newImage = NSImage(size: newTabButtonImage.size, flipped: false) { rect in - newTabButtonImage.draw(in: rect) - fillColor.setFill() - rect.fill(using: .sourceAtop) - return true - } - let imageLayer = VibrantLayer(forAppearance: isLightTheme ? .light : .dark)! - imageLayer.frame = NSRect(origin: NSPoint(x: newTabButton.bounds.midX - newTabButtonImage.size.width/2, y: newTabButton.bounds.midY - newTabButtonImage.size.height/2), size: newTabButtonImage.size) - imageLayer.contentsGravity = .resizeAspect - imageLayer.contents = newImage - imageLayer.opacity = 0.5 + newTabButtonImageLayer = imageLayer - newTabButtonImageLayer = imageLayer - } - - newTabButtonImageView.isHidden = true newTabButton.layer?.sublayers?.first(where: { $0.className == "VibrantLayer" })?.removeFromSuperlayer() newTabButton.layer?.addSublayer(newTabButtonImageLayer!) } @@ -448,6 +449,13 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { } private func addWindowButtonsBackdrop(titlebarView: NSView, toolbarView: NSView) { + guard windowButtonsBackdrop?.superview != titlebarView else { + /// replacing existing backdrop aggressively + /// may cause incorrect hierarchy + /// + /// because multiple windows are adding this around the 'same time' + return + } windowButtonsBackdrop?.removeFromSuperview() windowButtonsBackdrop = nil @@ -466,16 +474,11 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { private func addWindowDragHandle(titlebarView: NSView, toolbarView: NSView) { // If we already made the view, just make sure it's unhidden and correctly placed as a subview. - if let view = windowDragHandle { - view.removeFromSuperview() - view.isHidden = false - titlebarView.superview?.addSubview(view) - view.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true - view.rightAnchor.constraint(equalTo: toolbarView.rightAnchor).isActive = true - view.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true - view.bottomAnchor.constraint(equalTo: toolbarView.topAnchor, constant: 12).isActive = true + guard windowDragHandle?.superview != titlebarView.superview else { + // similar to `addWindowButtonsBackdrop` return } + windowDragHandle?.removeFromSuperview() let view = WindowDragView() view.identifier = NSUserInterfaceItemIdentifier("_windowDragHandle") @@ -536,7 +539,10 @@ fileprivate class WindowButtonsBackdropView: NSView { // This must be weak because the window has this view. Otherwise // a retain cycle occurs. private weak var terminalWindow: TitlebarTabsVenturaTerminalWindow? - private let isLightTheme: Bool + private var isLightTheme: Bool { + // using up-to-date value from hosting window directly + terminalWindow?.isLightTheme ?? false + } private let overlayLayer = VibrantLayer() var isHighlighted: Bool = true { @@ -565,7 +571,6 @@ fileprivate class WindowButtonsBackdropView: NSView { init(window: TitlebarTabsVenturaTerminalWindow) { self.terminalWindow = window - self.isLightTheme = window.isLightTheme super.init(frame: .zero) diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 7ae628341..57b889b82 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -59,6 +59,10 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { super.syncAppearance(surfaceConfig) + // override appearance based on the terminal's background color + if let preferredBackgroundColor { + appearance = (preferredBackgroundColor.isLightColor ? NSAppearance(named: .aqua) : NSAppearance(named: .darkAqua)) + } // Save our config in case we need to reapply lastSurfaceConfig = surfaceConfig @@ -84,7 +88,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // color of the titlebar in native fullscreen view. if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") { titlebarView.wantsLayer = true - titlebarView.layer?.backgroundColor = preferredBackgroundColor?.cgColor + + // For glass background styles, use a transparent titlebar to let the glass effect show through + // Only apply this for transparent and tabs titlebar styles + let isGlassStyle = derivedConfig.backgroundBlur.isGlassStyle + let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" || + derivedConfig.macosTitlebarStyle == "tabs" + + titlebarView.layer?.backgroundColor = (isGlassStyle && isTransparentTitlebar) + ? NSColor.clear.cgColor + : preferredBackgroundColor?.cgColor } // In all cases, we have to hide the background view since this has multiple subviews diff --git a/macos/Sources/Features/Update/UpdateBadge.swift b/macos/Sources/Features/Update/UpdateBadge.swift new file mode 100644 index 000000000..054fdf971 --- /dev/null +++ b/macos/Sources/Features/Update/UpdateBadge.swift @@ -0,0 +1,83 @@ +import SwiftUI + +/// A badge view that displays the current state of an update operation. +/// +/// Shows different visual indicators based on the update state: +/// - Progress ring for downloading/extracting with progress +/// - Animated rotating icon for checking/installing +/// - Static icon for other states +struct UpdateBadge: View { + /// The update view model that provides the current state and progress + @ObservedObject var model: UpdateViewModel + + /// Current rotation angle for animated icon states + @State private var rotationAngle: Double = 0 + + var body: some View { + badgeContent + .accessibilityLabel(model.text) + } + + @ViewBuilder + private var badgeContent: some View { + switch model.state { + case .downloading(let download): + if let expectedLength = download.expectedLength, expectedLength > 0 { + let progress = min(1, max(0, Double(download.progress) / Double(expectedLength))) + ProgressRingView(progress: progress) + } else { + Image(systemName: "arrow.down.circle") + } + + case .extracting(let extracting): + ProgressRingView(progress: min(1, max(0, extracting.progress))) + + case .checking: + if let iconName = model.iconName { + Image(systemName: iconName) + .rotationEffect(.degrees(rotationAngle)) + .onAppear { + withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) { + rotationAngle = 360 + } + } + .onDisappear { + rotationAngle = 0 + } + } else { + EmptyView() + } + + default: + if let iconName = model.iconName { + Image(systemName: iconName) + } else { + EmptyView() + } + } + } +} + +/// A circular progress indicator with a stroke-based ring design. +/// +/// Displays a partially filled circle that represents progress from 0.0 to 1.0. +fileprivate struct ProgressRingView: View { + /// The current progress value, ranging from 0.0 (empty) to 1.0 (complete) + let progress: Double + + /// The width of the progress ring stroke + let lineWidth: CGFloat = 2 + + var body: some View { + ZStack { + Circle() + .stroke(Color.primary.opacity(0.2), lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: progress) + .stroke(Color.primary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: progress) + } + } +} diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift new file mode 100644 index 000000000..939eed420 --- /dev/null +++ b/macos/Sources/Features/Update/UpdateController.swift @@ -0,0 +1,123 @@ +import Sparkle +import Cocoa +import Combine + +/// Standard controller for managing Sparkle updates in Ghostty. +/// +/// This controller wraps SPUStandardUpdaterController to provide a simpler interface +/// for managing updates with Ghostty's custom driver and delegate. It handles +/// initialization, starting the updater, and provides the check for updates action. +class UpdateController { + private(set) var updater: SPUUpdater + private let userDriver: UpdateDriver + private var installCancellable: AnyCancellable? + + var viewModel: UpdateViewModel { + userDriver.viewModel + } + + /// True if we're installing an update. + var isInstalling: Bool { + installCancellable != nil + } + + /// Initialize a new update controller. + init() { + let hostBundle = Bundle.main + self.userDriver = UpdateDriver( + viewModel: .init(), + hostBundle: hostBundle) + self.updater = SPUUpdater( + hostBundle: hostBundle, + applicationBundle: hostBundle, + userDriver: userDriver, + delegate: userDriver + ) + } + + deinit { + installCancellable?.cancel() + } + + /// Start the updater. + /// + /// This must be called before the updater can check for updates. If starting fails, + /// the error will be shown to the user. + func startUpdater() { + do { + try updater.start() + } catch { + userDriver.viewModel.state = .error(.init( + error: error, + retry: { [weak self] in + self?.userDriver.viewModel.state = .idle + self?.startUpdater() + }, + dismiss: { [weak self] in + self?.userDriver.viewModel.state = .idle + } + )) + } + } + + /// Force install the current update. As long as we're in some "update available" state this will + /// trigger all the steps necessary to complete the update. + func installUpdate() { + // Must be in an installable state + guard viewModel.state.isInstallable else { return } + + // If we're already force installing then do nothing. + guard installCancellable == nil else { return } + + // Setup a combine listener to listen for state changes and to always + // confirm them. If we go to a non-installable state, cancel the listener. + // The sink runs immediately with the current state, so we don't need to + // manually confirm the first state. + installCancellable = viewModel.$state.sink { [weak self] state in + guard let self else { return } + + // If we move to a non-installable state (error, idle, etc.) then we + // stop force installing. + guard state.isInstallable else { + self.installCancellable = nil + return + } + + // Continue the `yes` chain! + state.confirm() + } + } + + /// Check for updates. + /// + /// This is typically connected to a menu item action. + @objc func checkForUpdates() { + // If we're already idle, then just check for updates immediately. + if viewModel.state == .idle { + updater.checkForUpdates() + return + } + + // If we're not idle then we need to cancel any prior state. + installCancellable?.cancel() + viewModel.state.cancel() + + // The above will take time to settle, so we delay the check for some time. + // The 100ms is arbitrary and I'd rather not, but we have to wait more than + // one loop tick it seems. + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in + self?.updater.checkForUpdates() + } + } + + /// Validate the check for updates menu item. + /// + /// - Parameter item: The menu item to validate + /// - Returns: Whether the menu item should be enabled + func validateMenuItem(_ item: NSMenuItem) -> Bool { + if item.action == #selector(checkForUpdates) { + return updater.canCheckForUpdates + } + return true + } +} diff --git a/macos/Sources/Features/Update/UpdateDelegate.swift b/macos/Sources/Features/Update/UpdateDelegate.swift index 4699ba14a..619540851 100644 --- a/macos/Sources/Features/Update/UpdateDelegate.swift +++ b/macos/Sources/Features/Update/UpdateDelegate.swift @@ -1,12 +1,12 @@ import Sparkle import Cocoa -class UpdaterDelegate: NSObject, SPUUpdaterDelegate { +extension UpdateDriver: SPUUpdaterDelegate { func feedURLString(for updater: SPUUpdater) -> String? { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } - + // Sparkle supports a native concept of "channels" but it requires that // you share a single appcast file. We don't want to do that so we // do this instead. @@ -16,6 +16,22 @@ class UpdaterDelegate: NSObject, SPUUpdaterDelegate { } } + /// Called when an update is scheduled to install silently, + /// which occurs when `auto-update = download`. + /// + /// When `auto-update = check`, Sparkle will call the corresponding + /// delegate method on the responsible driver instead. + func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool { + viewModel.state = .installing(.init( + isAutoUpdate: true, + retryTerminatingApplication: immediateInstallHandler, + dismiss: { [weak viewModel] in + viewModel?.state = .idle + } + )) + return true + } + func updaterWillRelaunchApplication(_ updater: SPUUpdater) { // When the updater is relaunching the application we want to get macOS // to invalidate and re-encode all of our restorable state so that when diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift new file mode 100644 index 000000000..3beb4c9be --- /dev/null +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -0,0 +1,212 @@ +import Cocoa +import Sparkle + +/// Implement the SPUUserDriver to modify our UpdateViewModel for custom presentation. +class UpdateDriver: NSObject, SPUUserDriver { + let viewModel: UpdateViewModel + let standard: SPUStandardUserDriver + + init(viewModel: UpdateViewModel, hostBundle: Bundle) { + self.viewModel = viewModel + self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil) + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTerminalWindowWillClose), + name: TerminalWindow.terminalWillCloseNotification, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func handleTerminalWindowWillClose() { + // If we lost the ability to show unobtrusive states, cancel whatever + // update state we're in. This will allow the manual `check for updates` + // call to initialize the standard driver. + // + // We have to do this after a short delay so that the window can fully + // close. + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in + guard let self else { return } + guard !hasUnobtrusiveTarget else { return } + viewModel.state.cancel() + viewModel.state = .idle + } + } + + func show(_ request: SPUUpdatePermissionRequest, + reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) { + viewModel.state = .permissionRequest(.init(request: request, reply: { [weak viewModel] response in + viewModel?.state = .idle + reply(response) + })) + if !hasUnobtrusiveTarget { + standard.show(request, reply: reply) + } + } + + func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { + viewModel.state = .checking(.init(cancel: cancellation)) + + if !hasUnobtrusiveTarget { + standard.showUserInitiatedUpdateCheck(cancellation: cancellation) + } + } + + func showUpdateFound(with appcastItem: SUAppcastItem, + state: SPUUserUpdateState, + reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { + viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply)) + if !hasUnobtrusiveTarget { + standard.showUpdateFound(with: appcastItem, state: state, reply: reply) + } + } + + func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { + // We don't do anything with the release notes here because Ghostty + // doesn't use the release notes feature of Sparkle currently. + } + + func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) { + // We don't do anything with release notes. See `showUpdateReleaseNotes` + } + + func showUpdateNotFoundWithError(_ error: any Error, + acknowledgement: @escaping () -> Void) { + viewModel.state = .notFound(.init(acknowledgement: acknowledgement)) + + if !hasUnobtrusiveTarget { + standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement) + } + } + + func showUpdaterError(_ error: any Error, + acknowledgement: @escaping () -> Void) { + viewModel.state = .error(.init( + error: error, + retry: { [weak self, weak viewModel] in + viewModel?.state = .idle + DispatchQueue.main.async { [weak self] in + guard let self else { return } + guard let delegate = NSApp.delegate as? AppDelegate else { return } + delegate.checkForUpdates(self) + } + }, + dismiss: { [weak viewModel] in + viewModel?.state = .idle + })) + + if !hasUnobtrusiveTarget { + standard.showUpdaterError(error, acknowledgement: acknowledgement) + } else { + acknowledgement() + } + } + + func showDownloadInitiated(cancellation: @escaping () -> Void) { + viewModel.state = .downloading(.init( + cancel: cancellation, + expectedLength: nil, + progress: 0)) + + if !hasUnobtrusiveTarget { + standard.showDownloadInitiated(cancellation: cancellation) + } + } + + func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { + guard case let .downloading(downloading) = viewModel.state else { + return + } + + viewModel.state = .downloading(.init( + cancel: downloading.cancel, + expectedLength: expectedContentLength, + progress: 0)) + + if !hasUnobtrusiveTarget { + standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength) + } + } + + func showDownloadDidReceiveData(ofLength length: UInt64) { + guard case let .downloading(downloading) = viewModel.state else { + return + } + + viewModel.state = .downloading(.init( + cancel: downloading.cancel, + expectedLength: downloading.expectedLength, + progress: downloading.progress + length)) + + if !hasUnobtrusiveTarget { + standard.showDownloadDidReceiveData(ofLength: length) + } + } + + func showDownloadDidStartExtractingUpdate() { + viewModel.state = .extracting(.init(progress: 0)) + + if !hasUnobtrusiveTarget { + standard.showDownloadDidStartExtractingUpdate() + } + } + + func showExtractionReceivedProgress(_ progress: Double) { + viewModel.state = .extracting(.init(progress: progress)) + + if !hasUnobtrusiveTarget { + standard.showExtractionReceivedProgress(progress) + } + } + + func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { + if !hasUnobtrusiveTarget { + standard.showReady(toInstallAndRelaunch: reply) + } else { + reply(.install) + } + } + + func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { + viewModel.state = .installing(.init( + retryTerminatingApplication: retryTerminatingApplication, + dismiss: { [weak viewModel] in + viewModel?.state = .idle + } + )) + + if !hasUnobtrusiveTarget { + standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication) + } + } + + func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { + standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement) + viewModel.state = .idle + } + + func showUpdateInFocus() { + if !hasUnobtrusiveTarget { + standard.showUpdateInFocus() + } + } + + func dismissUpdateInstallation() { + viewModel.state = .idle + standard.dismissUpdateInstallation() + } + + // MARK: No-Window Fallback + + /// True if there is a target that can render our unobtrusive update checker. + var hasUnobtrusiveTarget: Bool { + NSApp.windows.contains { window in + (window is TerminalWindow || window is QuickTerminalWindow) && + window.isVisible + } + } +} diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift new file mode 100644 index 000000000..29d1669e1 --- /dev/null +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -0,0 +1,81 @@ +import SwiftUI + +/// A pill-shaped button that displays update status and provides access to update actions. +struct UpdatePill: View { + /// The update view model that provides the current state and information + @ObservedObject var model: UpdateViewModel + + /// Whether the update popover is currently visible + @State private var showPopover = false + + /// Task for auto-dismissing the "No Updates" state + @State private var resetTask: Task? + + /// The font used for the pill text + private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium) + + var body: some View { + if !model.state.isIdle { + pillButton + .popover(isPresented: $showPopover, arrowEdge: .bottom) { + UpdatePopoverView(model: model) + } + .transition(.opacity.combined(with: .scale(scale: 0.95))) + .onChange(of: model.state) { newState in + resetTask?.cancel() + if case .notFound(let notFound) = newState { + resetTask = Task { [weak model] in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled, case .notFound? = model?.state else { return } + model?.state = .idle + notFound.acknowledgement() + } + } else { + resetTask = nil + } + } + } + } + + /// The pill-shaped button view that displays the update badge and text + @ViewBuilder + private var pillButton: some View { + Button(action: { + if case .notFound(let notFound) = model.state { + model.state = .idle + notFound.acknowledgement() + } else { + showPopover.toggle() + } + }) { + HStack(spacing: 6) { + UpdateBadge(model: model) + .frame(width: 14, height: 14) + + Text(model.text) + .font(Font(textFont)) + .lineLimit(1) + .truncationMode(.tail) + .frame(width: textWidth) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(model.backgroundColor) + ) + .foregroundColor(model.foregroundColor) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .help(model.text) + .accessibilityLabel(model.text) + } + + /// Calculated width for the text to prevent resizing during progress updates + private var textWidth: CGFloat? { + let attributes: [NSAttributedString.Key: Any] = [.font: textFont] + let size = (model.maxWidthText as NSString).size(withAttributes: attributes) + return size.width + } +} diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift new file mode 100644 index 000000000..87d76f801 --- /dev/null +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -0,0 +1,387 @@ +import SwiftUI +import Sparkle + +/// A popover view that displays detailed update information and action buttons. +/// +/// The view adapts its content based on the current update state, showing appropriate +/// UI for checking, downloading, installing, or handling errors. +struct UpdatePopoverView: View { + /// The update view model that provides the current state and information + @ObservedObject var model: UpdateViewModel + + /// Environment value for dismissing the popover + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + switch model.state { + case .idle: + // Shouldn't happen in a well-formed view stack. Higher levels + // should not call the popover for idles. + EmptyView() + + case .permissionRequest(let request): + PermissionRequestView(request: request, dismiss: dismiss) + + case .checking(let checking): + CheckingView(checking: checking, dismiss: dismiss) + + case .updateAvailable(let update): + UpdateAvailableView(update: update, dismiss: dismiss) + + case .downloading(let download): + DownloadingView(download: download, dismiss: dismiss) + + case .extracting(let extracting): + ExtractingView(extracting: extracting) + + case .installing(let installing): + // This is only required when `installing.isAutoUpdate == true`, + // but we keep it anyway, just in case something unexpected + // happens during installing + InstallingView(installing: installing, dismiss: dismiss) + + case .notFound(let notFound): + NotFoundView(notFound: notFound, dismiss: dismiss) + + case .error(let error): + UpdateErrorView(error: error, dismiss: dismiss) + } + } + .frame(width: 300) + } +} + +fileprivate struct PermissionRequestView: View { + let request: UpdateState.PermissionRequest + let dismiss: DismissAction + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Enable automatic updates?") + .font(.system(size: 13, weight: .semibold)) + + Text("Ghostty can automatically check for updates in the background.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 8) { + Button("Not Now") { + request.reply(SUUpdatePermissionResponse( + automaticUpdateChecks: false, + sendSystemProfile: false)) + dismiss() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Allow") { + request.reply(SUUpdatePermissionResponse( + automaticUpdateChecks: true, + sendSystemProfile: false)) + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + } + } + .padding(16) + } +} + +fileprivate struct CheckingView: View { + let checking: UpdateState.Checking + let dismiss: DismissAction + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Checking for updates…") + .font(.system(size: 13)) + } + + HStack { + Spacer() + Button("Cancel") { + checking.cancel() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + } + } + .padding(16) + } +} + +fileprivate struct UpdateAvailableView: View { + let update: UpdateState.UpdateAvailable + let dismiss: DismissAction + + private let labelWidth: CGFloat = 60 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text("Update Available") + .font(.system(size: 13, weight: .semibold)) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text("Version:") + .foregroundColor(.secondary) + .frame(width: labelWidth, alignment: .trailing) + Text(update.appcastItem.displayVersionString) + } + .font(.system(size: 11)) + + if update.appcastItem.contentLength > 0 { + HStack(spacing: 6) { + Text("Size:") + .foregroundColor(.secondary) + .frame(width: labelWidth, alignment: .trailing) + Text(ByteCountFormatter.string(fromByteCount: Int64(update.appcastItem.contentLength), countStyle: .file)) + } + .font(.system(size: 11)) + } + + if let date = update.appcastItem.date { + HStack(spacing: 6) { + Text("Released:") + .foregroundColor(.secondary) + .frame(width: labelWidth, alignment: .trailing) + Text(date.formatted(date: .abbreviated, time: .omitted)) + } + .font(.system(size: 11)) + } + } + .textSelection(.enabled) + } + + HStack(spacing: 8) { + Button("Skip") { + update.reply(.skip) + dismiss() + } + .controlSize(.small) + + Button("Later") { + update.reply(.dismiss) + dismiss() + } + .controlSize(.small) + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Install and Relaunch") { + update.reply(.install) + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(16) + + if let notes = update.releaseNotes { + Divider() + + Link(destination: notes.url) { + HStack { + Image(systemName: "doc.text") + .font(.system(size: 11)) + Text(notes.label) + .font(.system(size: 11, weight: .medium)) + Spacer() + Image(systemName: "arrow.up.right") + .font(.system(size: 10)) + } + .foregroundColor(.primary) + .padding(12) + .frame(maxWidth: .infinity) + .background(Color(nsColor: .controlBackgroundColor)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } +} + +fileprivate struct DownloadingView: View { + let download: UpdateState.Downloading + let dismiss: DismissAction + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Downloading Update") + .font(.system(size: 13, weight: .semibold)) + + if let expectedLength = download.expectedLength, expectedLength > 0 { + let progress = min(1, max(0, Double(download.progress) / Double(expectedLength))) + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: progress) + Text(String(format: "%.0f%%", progress * 100)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } else { + ProgressView() + .controlSize(.small) + } + } + + HStack { + Spacer() + Button("Cancel") { + download.cancel() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + } + } + .padding(16) + } +} + +fileprivate struct ExtractingView: View { + let extracting: UpdateState.Extracting + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Preparing Update") + .font(.system(size: 13, weight: .semibold)) + + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: min(1, max(0, extracting.progress)), total: 1.0) + Text(String(format: "%.0f%%", min(1, max(0, extracting.progress)) * 100)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + .padding(16) + } +} + +fileprivate struct InstallingView: View { + let installing: UpdateState.Installing + let dismiss: DismissAction + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Restart Required") + .font(.system(size: 13, weight: .semibold)) + + Text("The update is ready. Please restart the application to complete the installation.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Button("Restart Later") { + installing.dismiss() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + + Spacer() + + Button("Restart Now") { + installing.retryTerminatingApplication() + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(16) + } +} + +fileprivate struct NotFoundView: View { + let notFound: UpdateState.NotFound + let dismiss: DismissAction + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("No Updates Found") + .font(.system(size: 13, weight: .semibold)) + + Text("You're already running the latest version.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + Button("OK") { + notFound.acknowledgement() + dismiss() + } + .keyboardShortcut(.defaultAction) + .controlSize(.small) + } + } + .padding(16) + } +} + +fileprivate struct UpdateErrorView: View { + let error: UpdateState.Error + let dismiss: DismissAction + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.system(size: 13)) + Text("Update Failed") + .font(.system(size: 13, weight: .semibold)) + } + + Text(error.error.localizedDescription) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 8) { + Button("OK") { + error.dismiss() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + + Spacer() + + Button("Retry") { + error.retry() + dismiss() + } + .keyboardShortcut(.defaultAction) + .controlSize(.small) + } + } + .padding(16) + } +} diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift new file mode 100644 index 000000000..bf168d9fc --- /dev/null +++ b/macos/Sources/Features/Update/UpdateSimulator.swift @@ -0,0 +1,301 @@ +import Foundation +import Sparkle + +/// Simulates various update scenarios for testing the update UI. +/// +/// The expected usage is by overriding the `checkForUpdates` function in AppDelegate and +/// calling one of these instead. This will allow us to test the update flows without having to use +/// real updates. +enum UpdateSimulator { + /// Complete successful update flow: checking → available → download → extract → ready → install → idle + case happyPath + + /// No updates available: checking (2s) → "No Updates Available" (3s) → idle + case notFound + + /// Error during check: checking (2s) → error with retry callback + case error + + /// Slower download for testing progress UI: checking → available → download (20 steps, ~10s) → extract → install + case slowDownload + + /// Initial permission request flow: shows permission dialog → proceeds with happy path if accepted + case permissionRequest + + /// User cancels during download: checking → available → download (5 steps) → cancels → idle + case cancelDuringDownload + + /// User cancels while checking: checking (1s) → cancels → idle + case cancelDuringChecking + + /// Shows the installing state with restart button: installing (stays until dismissed) + case installing + + /// Simulates auto-update flow: goes directly to installing state without showing intermediate UI + case autoUpdate + + func simulate(with viewModel: UpdateViewModel) { + switch self { + case .happyPath: + simulateHappyPath(viewModel) + case .notFound: + simulateNotFound(viewModel) + case .error: + simulateError(viewModel) + case .slowDownload: + simulateSlowDownload(viewModel) + case .permissionRequest: + simulatePermissionRequest(viewModel) + case .cancelDuringDownload: + simulateCancelDuringDownload(viewModel) + case .cancelDuringChecking: + simulateCancelDuringChecking(viewModel) + case .installing: + simulateInstalling(viewModel) + case .autoUpdate: + simulateAutoUpdate(viewModel) + } + } + + private func simulateHappyPath(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .updateAvailable(.init( + appcastItem: SUAppcastItem.empty(), + reply: { choice in + if choice == .install { + simulateDownload(viewModel) + } else { + viewModel.state = .idle + } + } + )) + } + } + + private func simulateNotFound(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .notFound(.init(acknowledgement: { + // Acknowledgement called when dismissed + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + viewModel.state = .idle + } + } + } + + private func simulateError(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .error(.init( + error: NSError(domain: "UpdateError", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to check for updates" + ]), + retry: { + simulateHappyPath(viewModel) + }, + dismiss: { + viewModel.state = .idle + } + )) + } + } + + private func simulateSlowDownload(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .updateAvailable(.init( + appcastItem: SUAppcastItem.empty(), + reply: { choice in + if choice == .install { + simulateSlowDownloadProgress(viewModel) + } else { + viewModel.state = .idle + } + } + )) + } + } + + private func simulateSlowDownloadProgress(_ viewModel: UpdateViewModel) { + let download = UpdateState.Downloading( + cancel: { + viewModel.state = .idle + }, + expectedLength: nil, + progress: 0 + ) + viewModel.state = .downloading(download) + + for i in 1...20 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.5) { + let updatedDownload = UpdateState.Downloading( + cancel: download.cancel, + expectedLength: 2000, + progress: UInt64(i * 100) + ) + viewModel.state = .downloading(updatedDownload) + + if i == 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + simulateExtract(viewModel) + } + } + } + } + } + + private func simulatePermissionRequest(_ viewModel: UpdateViewModel) { + let request = SPUUpdatePermissionRequest(systemProfile: []) + viewModel.state = .permissionRequest(.init( + request: request, + reply: { response in + if response.automaticUpdateChecks { + simulateHappyPath(viewModel) + } else { + viewModel.state = .idle + } + } + )) + } + + private func simulateCancelDuringDownload(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .updateAvailable(.init( + appcastItem: SUAppcastItem.empty(), + reply: { choice in + if choice == .install { + simulateDownloadThenCancel(viewModel) + } else { + viewModel.state = .idle + } + } + )) + } + } + + private func simulateDownloadThenCancel(_ viewModel: UpdateViewModel) { + let download = UpdateState.Downloading( + cancel: { + viewModel.state = .idle + }, + expectedLength: nil, + progress: 0 + ) + viewModel.state = .downloading(download) + + for i in 1...5 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { + let updatedDownload = UpdateState.Downloading( + cancel: download.cancel, + expectedLength: 1000, + progress: UInt64(i * 100) + ) + viewModel.state = .downloading(updatedDownload) + + if i == 5 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + viewModel.state = .idle + } + } + } + } + } + + private func simulateCancelDuringChecking(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + viewModel.state = .idle + } + } + + private func simulateDownload(_ viewModel: UpdateViewModel) { + let download = UpdateState.Downloading( + cancel: { + viewModel.state = .idle + }, + expectedLength: nil, + progress: 0 + ) + viewModel.state = .downloading(download) + + for i in 1...10 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { + let updatedDownload = UpdateState.Downloading( + cancel: download.cancel, + expectedLength: 1000, + progress: UInt64(i * 100) + ) + viewModel.state = .downloading(updatedDownload) + + if i == 10 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + simulateExtract(viewModel) + } + } + } + } + } + + private func simulateExtract(_ viewModel: UpdateViewModel) { + viewModel.state = .extracting(.init(progress: 0.0)) + + for j in 1...5 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { + viewModel.state = .extracting(.init(progress: Double(j) / 5.0)) + + if j == 5 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + simulateInstalling(viewModel) + } + } + } + } + } + + private func simulateInstalling(_ viewModel: UpdateViewModel) { + viewModel.state = .installing(.init( + retryTerminatingApplication: { + print("Restart button clicked in simulator - resetting to idle") + viewModel.state = .idle + }, + dismiss: { + viewModel.state = .idle + } + )) + } + + private func simulateAutoUpdate(_ viewModel: UpdateViewModel) { + viewModel.state = .installing(.init( + isAutoUpdate: true, + retryTerminatingApplication: { + print("Restart button clicked in simulator - resetting to idle") + viewModel.state = .idle + }, + dismiss: { + viewModel.state = .idle + } + )) + } +} diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift new file mode 100644 index 000000000..1f9304616 --- /dev/null +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -0,0 +1,372 @@ +import Foundation +import SwiftUI +import Sparkle + +class UpdateViewModel: ObservableObject { + @Published var state: UpdateState = .idle + + /// The text to display for the current update state. + /// Returns an empty string for idle state, progress percentages for downloading/extracting, + /// or descriptive text for other states. + var text: String { + switch state { + case .idle: + return "" + case .permissionRequest: + return "Enable Automatic Updates?" + case .checking: + return "Checking for Updates…" + case .updateAvailable(let update): + let version = update.appcastItem.displayVersionString + if !version.isEmpty { + return "Update Available: \(version)" + } + return "Update Available" + case .downloading(let download): + if let expectedLength = download.expectedLength, expectedLength > 0 { + let progress = Double(download.progress) / Double(expectedLength) + return String(format: "Downloading: %.0f%%", progress * 100) + } + return "Downloading…" + case .extracting(let extracting): + return String(format: "Preparing: %.0f%%", extracting.progress * 100) + case .installing(let install): + return install.isAutoUpdate ? "Restart to Complete Update" : "Installing…" + case .notFound: + return "No Updates Available" + case .error(let err): + return err.error.localizedDescription + } + } + + /// The maximum width text for states that show progress. + /// Used to prevent the pill from resizing as percentages change. + var maxWidthText: String { + switch state { + case .downloading: + return "Downloading: 100%" + case .extracting: + return "Preparing: 100%" + default: + return text + } + } + + /// The SF Symbol icon name for the current update state. + var iconName: String? { + switch state { + case .idle: + return nil + case .permissionRequest: + return "questionmark.circle" + case .checking: + return "arrow.triangle.2.circlepath" + case .updateAvailable: + return "shippingbox.fill" + case .downloading: + return "arrow.down.circle" + case .extracting: + return "shippingbox" + case .installing: + return "power.circle" + case .notFound: + return "info.circle" + case .error: + return "exclamationmark.triangle.fill" + } + } + + /// A longer description for the current update state. + /// Used in contexts like the command palette where more detail is helpful. + var description: String { + switch state { + case .idle: + return "" + case .permissionRequest: + return "Configure automatic update preferences" + case .checking: + return "Please wait while we check for available updates" + case .updateAvailable(let update): + return update.releaseNotes?.label ?? "Download and install the latest version" + case .downloading: + return "Downloading the update package" + case .extracting: + return "Extracting and preparing the update" + case let .installing(install): + return install.isAutoUpdate ? "Restart to Complete Update" : "Installing update and preparing to restart" + case .notFound: + return "You are running the latest version" + case .error: + return "An error occurred during the update process" + } + } + + /// A badge to display for the current update state. + /// Returns version numbers, progress percentages, or nil. + var badge: String? { + switch state { + case .updateAvailable(let update): + let version = update.appcastItem.displayVersionString + return version.isEmpty ? nil : version + case .downloading(let download): + if let expectedLength = download.expectedLength, expectedLength > 0 { + let percentage = Double(download.progress) / Double(expectedLength) * 100 + return String(format: "%.0f%%", percentage) + } + return nil + case .extracting(let extracting): + return String(format: "%.0f%%", extracting.progress * 100) + default: + return nil + } + } + + /// The color to apply to the icon for the current update state. + var iconColor: Color { + switch state { + case .idle: + return .secondary + case .permissionRequest: + return .white + case .checking: + return .secondary + case .updateAvailable: + return .accentColor + case .downloading, .extracting, .installing: + return .secondary + case .notFound: + return .secondary + case .error: + return .orange + } + } + + /// The background color for the update pill. + var backgroundColor: Color { + switch state { + case .permissionRequest: + return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue) + case .updateAvailable: + return .accentColor + case .notFound: + return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue) + case .error: + return .orange.opacity(0.2) + default: + return Color(nsColor: .controlBackgroundColor) + } + } + + /// The foreground (text) color for the update pill. + var foregroundColor: Color { + switch state { + case .permissionRequest: + return .white + case .updateAvailable: + return .white + case .notFound: + return .white + case .error: + return .orange + default: + return .primary + } + } +} + +enum UpdateState: Equatable { + case idle + case permissionRequest(PermissionRequest) + case checking(Checking) + case updateAvailable(UpdateAvailable) + case notFound(NotFound) + case error(Error) + case downloading(Downloading) + case extracting(Extracting) + case installing(Installing) + + var isIdle: Bool { + if case .idle = self { return true } + return false + } + + /// This is true if we're in a state that can be force installed. + var isInstallable: Bool { + switch (self) { + case .checking, + .updateAvailable, + .downloading, + .extracting, + .installing: + return true + + default: + return false + } + } + + func cancel() { + switch self { + case .checking(let checking): + checking.cancel() + case .updateAvailable(let available): + available.reply(.dismiss) + case .downloading(let downloading): + downloading.cancel() + case .notFound(let notFound): + notFound.acknowledgement() + case .error(let err): + err.dismiss() + default: + break + } + } + + /// Confirms or accepts the current update state. + /// - For available updates: begins installation + /// - For ready-to-install: proceeds with installation + func confirm() { + switch self { + case .updateAvailable(let available): + available.reply(.install) + default: + break + } + } + + static func == (lhs: UpdateState, rhs: UpdateState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle): + return true + case (.permissionRequest, .permissionRequest): + return true + case (.checking, .checking): + return true + case (.updateAvailable(let lUpdate), .updateAvailable(let rUpdate)): + return lUpdate.appcastItem.displayVersionString == rUpdate.appcastItem.displayVersionString + case (.notFound, .notFound): + return true + case (.error(let lErr), .error(let rErr)): + return lErr.error.localizedDescription == rErr.error.localizedDescription + case (.downloading(let lDown), .downloading(let rDown)): + return lDown.progress == rDown.progress && lDown.expectedLength == rDown.expectedLength + case (.extracting(let lExt), .extracting(let rExt)): + return lExt.progress == rExt.progress + case (.installing(let lInstall), .installing(let rInstall)): + return lInstall.isAutoUpdate == rInstall.isAutoUpdate + default: + return false + } + } + + struct NotFound { + let acknowledgement: () -> Void + } + + struct PermissionRequest { + let request: SPUUpdatePermissionRequest + let reply: @Sendable (SUUpdatePermissionResponse) -> Void + } + + struct Checking { + let cancel: () -> Void + } + + struct UpdateAvailable { + let appcastItem: SUAppcastItem + let reply: @Sendable (SPUUserUpdateChoice) -> Void + + var releaseNotes: ReleaseNotes? { + let currentCommit = Bundle.main.infoDictionary?["GhosttyCommit"] as? String + return ReleaseNotes(displayVersionString: appcastItem.displayVersionString, currentCommit: currentCommit) + } + } + + enum ReleaseNotes { + case commit(URL) + case compareTip(URL) + case tagged(URL) + + init?(displayVersionString: String, currentCommit: String?) { + let version = displayVersionString + + // Check for semantic version (x.y.z) + if let semver = Self.extractSemanticVersion(from: version) { + let slug = semver.replacingOccurrences(of: ".", with: "-") + if let url = URL(string: "https://ghostty.org/docs/install/release-notes/\(slug)") { + self = .tagged(url) + return + } + } + + // Fall back to git hash detection + guard let newHash = Self.extractGitHash(from: version) else { + return nil + } + + if let currentHash = currentCommit, !currentHash.isEmpty, + let url = URL(string: "https://github.com/ghostty-org/ghostty/compare/\(currentHash)...\(newHash)") { + self = .compareTip(url) + } else if let url = URL(string: "https://github.com/ghostty-org/ghostty/commit/\(newHash)") { + self = .commit(url) + } else { + return nil + } + } + + private static func extractSemanticVersion(from version: String) -> String? { + let pattern = #"^\d+\.\d+\.\d+$"# + if version.range(of: pattern, options: .regularExpression) != nil { + return version + } + return nil + } + + private static func extractGitHash(from version: String) -> String? { + let pattern = #"[0-9a-f]{7,40}"# + if let range = version.range(of: pattern, options: .regularExpression) { + return String(version[range]) + } + return nil + } + + var url: URL { + switch self { + case .commit(let url): return url + case .compareTip(let url): return url + case .tagged(let url): return url + } + } + + var label: String { + switch (self) { + case .commit: return "View GitHub Commit" + case .compareTip: return "Changes Since This Tip Release" + case .tagged: return "View Release Notes" + } + } + } + + struct Error { + let error: any Swift.Error + let retry: () -> Void + let dismiss: () -> Void + } + + struct Downloading { + let cancel: () -> Void + let expectedLength: UInt64? + let progress: UInt64 + } + + struct Extracting { + let progress: Double + } + + struct Installing { + /// True if this state is triggered by ``Ghostty/UpdateDriver/updater(_:willInstallUpdateOnQuit:immediateInstallationBlock:)`` + var isAutoUpdate = false + let retryTerminatingApplication: () -> Void + let dismiss: () -> Void + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 37b1a362d..9eb7a8e46 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -45,11 +45,14 @@ extension Ghostty.Action { enum Kind { case unknown case text + case html init(_ c: ghostty_action_open_url_kind_e) { switch c { case GHOSTTY_ACTION_OPEN_URL_KIND_TEXT: self = .text + case GHOSTTY_ACTION_OPEN_URL_KIND_HTML: + self = .html default: self = .unknown } @@ -100,6 +103,44 @@ extension Ghostty.Action { let state: State let progress: UInt8? } + + struct Scrollbar { + let total: UInt64 + let offset: UInt64 + let len: UInt64 + + init(c: ghostty_action_scrollbar_s) { + total = c.total + offset = c.offset + 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 + } + } + } + + enum PromptTitle { + case surface + case tab + + init(_ c: ghostty_action_prompt_title_e) { + switch c { + case GHOSTTY_PROMPT_TITLE_TAB: + self = .tab + default: + self = .surface + } + } + } } // 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 bdc64e9e1..2cd0a362a 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -61,7 +61,8 @@ extension Ghostty { action_cb: { app, target, action in App.action(app!, target: target, action: action) }, read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) }, confirm_read_clipboard_cb: { userdata, str, state, request in App.confirmReadClipboard(userdata, string: str, state: state, request: request ) }, - write_clipboard_cb: { userdata, str, loc, confirm in App.writeClipboard(userdata, string: str, location: loc, confirm: confirm) }, + write_clipboard_cb: { userdata, loc, content, len, confirm in + App.writeClipboard(userdata, location: loc, content: content, len: len, confirm: confirm) }, close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) } ) @@ -149,10 +150,7 @@ extension Ghostty { } ghostty_app_update_config(app, newConfig.config!) - - // We can only set our config after updating it so that we don't free - // memory that may still be in use - self.config = newConfig + /// applied config will be updated in ``Self.configChange(_:target:v:)`` } func reloadConfig(surface: ghostty_surface_t, soft: Bool = false) { @@ -182,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)") } } @@ -212,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)") } } @@ -240,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)") } } @@ -279,8 +277,9 @@ extension Ghostty { static func writeClipboard( _ userdata: UnsafeMutableRawPointer?, - string: UnsafePointer?, location: ghostty_clipboard_e, + content: UnsafePointer?, + len: Int, confirm: Bool ) {} @@ -367,23 +366,53 @@ extension Ghostty { } } - static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?, location: ghostty_clipboard_e, confirm: Bool) { + static func writeClipboard( + _ userdata: UnsafeMutableRawPointer?, + location: ghostty_clipboard_e, + content: UnsafePointer?, + len: Int, + confirm: Bool + ) { let surface = self.surfaceUserdata(from: userdata) - - guard let pasteboard = NSPasteboard.ghostty(location) else { return } - guard let valueStr = String(cString: string!, encoding: .utf8) else { return } + guard let content = content, len > 0 else { return } + + // Convert the C array to Swift array + let contentArray = (0...FocusDirection = splitDirection.toSplitTreeFocusDirection() + guard controller.surfaceTree.focusTarget(for: focusDirection, from: targetNode) != nil else { + return false + } + + // We have a valid target, post the notification to perform the navigation NotificationCenter.default.post( name: Notification.ghosttyFocusSplit, object: surfaceView, userInfo: [ - Notification.SplitDirectionKey: SplitFocusDirection.from(direction: direction) as Any, + Notification.SplitDirectionKey: splitDirection as Any, ] ) + return true + default: assertionFailure() + return false } + } + private static func gotoWindow( + _ app: ghostty_app_t, + target: ghostty_target_s, + direction: ghostty_action_goto_window_e + ) -> Bool { + // Collect candidate windows: visible terminal windows that are either + // standalone or the currently selected tab in their tab group. This + // treats each native tab group as a single "window" for navigation + // purposes, since goto_tab handles per-tab navigation. + let candidates: [NSWindow] = NSApplication.shared.windows.filter { window in + guard window.windowController is BaseTerminalController else { return false } + guard window.isVisible, !window.isMiniaturized else { return false } + // For native tabs, only include the selected tab in each group + if let group = window.tabGroup, group.selectedWindow !== window { + return false + } return true + } + + // Need at least two windows to navigate between + guard candidates.count > 1 else { return false } + + // Find starting index from the current key/main window + let startIndex = candidates.firstIndex(where: { $0.isKeyWindow }) + ?? candidates.firstIndex(where: { $0.isMainWindow }) + ?? 0 + + let step: Int + switch direction { + case GHOSTTY_GOTO_WINDOW_NEXT: + step = 1 + case GHOSTTY_GOTO_WINDOW_PREVIOUS: + step = -1 + default: + return false + } + + // Iterate with wrap-around until we find a valid window or return to start + let count = candidates.count + var index = (startIndex + step + count) % count + + while index != startIndex { + let candidate = candidates[index] + if candidate.isVisible, !candidate.isMiniaturized { + candidate.makeKeyAndOrderFront(nil) + // Also focus the terminal surface within the window + if let controller = candidate.windowController as? BaseTerminalController, + let surface = controller.focusedSurface { + Ghostty.moveFocus(to: surface) + } + return true + } + index = (index + step + count) % count + } + + return false } private static func resizeSplit( @@ -1276,22 +1439,50 @@ extension Ghostty { private static func promptTitle( _ app: ghostty_app_t, - target: ghostty_target_s) -> Bool { - switch (target.tag) { - case GHOSTTY_TARGET_APP: - Ghostty.logger.warning("set title prompt does nothing with an app target") - return false + target: ghostty_target_s, + v: ghostty_action_prompt_title_e) -> Bool { + let promptTitle = Action.PromptTitle(v) + switch promptTitle { + case .surface: + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set title prompt does nothing with an app target") + return false - case GHOSTTY_TARGET_SURFACE: - guard let surface = target.target.surface else { return false } - guard let surfaceView = self.surfaceView(from: surface) else { return false } - surfaceView.promptTitle() + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + surfaceView.promptTitle() + return true - default: - assertionFailure() + default: + assertionFailure() + return false + } + + case .tab: + switch (target.tag) { + case GHOSTTY_TARGET_APP: + guard let window = NSApp.mainWindow ?? NSApp.keyWindow, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + guard let window = surfaceView.window, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true + + default: + assertionFailure() + return false + } } - - return true } private static func pwdChanged( @@ -1559,6 +1750,127 @@ extension Ghostty { } } + private static func scrollbar( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_scrollbar_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("scrollbar 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 scrollbar = Ghostty.Action.Scrollbar(c: v) + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: surfaceView, + userInfo: [ + SwiftUI.Notification.Name.ScrollbarKey: scrollbar + ] + ) + + default: + assertionFailure() + } + } + + 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 05a3be2cd..47826a104 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 @@ -314,17 +314,14 @@ extension Ghostty { var macosCustomIcon: String { #if os(macOS) - let homeDirURL = FileManager.default.homeDirectoryForCurrentUser - let ghosttyConfigIconPath = homeDirURL.appendingPathComponent( - ".config/ghostty/Ghostty.icns", - conformingTo: .fileURL).path() - let defaultValue = ghosttyConfigIconPath + let defaultValue = NSString("~/.config/ghostty/Ghostty.icns").expandingTildeInPath 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 } - return String(cString: ptr) + guard let path = NSString(utf8String: ptr) else { return defaultValue } + return path.expandingTildeInPath #else return "" #endif @@ -335,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 @@ -345,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) } @@ -353,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) } @@ -363,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 @@ -373,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) @@ -401,23 +398,23 @@ 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; } - var backgroundBlurRadius: Int { - guard let config = self.config else { return 1 } - var v: Int = 0 + var backgroundBlur: BackgroundBlur { + guard let config = self.config else { return .disabled } + var v: Int16 = 0 let key = "background-blur" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) - return v; + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) + return BackgroundBlur(fromCValue: v) } var unfocusedSplitOpacity: Double { 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 } @@ -426,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( @@ -447,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) } @@ -463,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 @@ -473,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 @@ -483,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 } @@ -491,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 } @@ -499,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 @@ -509,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 @@ -518,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 @@ -529,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 @@ -539,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; } @@ -547,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) } @@ -555,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) @@ -566,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 @@ -576,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 } @@ -584,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 } @@ -592,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 } @@ -601,11 +598,22 @@ 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 } + + var scrollbar: Scrollbar { + let defaultValue = Scrollbar.system + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = nil + let key = "scrollbar" + 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 + } } } @@ -618,6 +626,60 @@ extension Ghostty.Config { case download } + /// Background blur configuration that maps from the C API values. + /// Positive values represent blur radius, special negative values + /// represent macOS-specific glass effects. + enum BackgroundBlur: Equatable { + case disabled + case radius(Int) + case macosGlassRegular + case macosGlassClear + + init(fromCValue value: Int16) { + switch value { + case 0: + self = .disabled + case -1: + self = .macosGlassRegular + case -2: + self = .macosGlassClear + default: + self = .radius(Int(value)) + } + } + + var isEnabled: Bool { + switch self { + case .disabled: + return false + default: + return true + } + } + + /// Returns true if this is a macOS glass style (regular or clear). + var isGlassStyle: Bool { + switch self { + case .macosGlassRegular, .macosGlassClear: + return true + default: + return false + } + } + + /// Returns the blur radius if applicable, nil for glass effects. + var radius: Int? { + switch self { + case .disabled: + return nil + case .radius(let r): + return r + case .macosGlassRegular, .macosGlassClear: + return nil + } + } + } + struct BellFeatures: OptionSet { let rawValue: CUnsignedInt @@ -627,7 +689,7 @@ extension Ghostty.Config { static let title = BellFeatures(rawValue: 1 << 3) static let border = BellFeatures(rawValue: 1 << 4) } - + enum MacDockDropBehavior: String { case new_tab = "new-tab" case new_window = "new-window" @@ -644,6 +706,11 @@ extension Ghostty.Config { case ask } + enum Scrollbar: String { + case system + case never + } + enum ResizeOverlay : String { case always case never diff --git a/macos/Sources/Ghostty/InspectorView.swift b/macos/Sources/Ghostty/InspectorView.swift index 8008e49c2..2a004ac76 100644 --- a/macos/Sources/Ghostty/InspectorView.swift +++ b/macos/Sources/Ghostty/InspectorView.swift @@ -32,6 +32,9 @@ extension Ghostty { InspectorViewRepresentable(surfaceView: surfaceView) .focused($inspectorFocus) .focusedValue(\.ghosttySurfaceView, surfaceView) + }, onEqualize: { + guard let surface = surfaceView.surface else { return } + ghostty.splitEqualize(surface: surface) }) } } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 85040d390..b834ea31f 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -56,7 +56,7 @@ extension Ghostty { case app case zig_run } - + /// Returns the mechanism that launched the app. This is based on an env var so /// its up to the env var being set in the correct circumstance. static var launchSource: LaunchSource { @@ -65,7 +65,7 @@ extension Ghostty { // source. If its unset we assume we're in a CLI environment. return .cli } - + // If the env var is set but its unknown then we default back to the app. return LaunchSource(rawValue: envValue) ?? .app } @@ -76,17 +76,17 @@ extension Ghostty { extension Ghostty { class AllocatedString { private let cString: ghostty_string_s - + init(_ c: ghostty_string_s) { self.cString = c } - + var string: String { guard let ptr = cString.ptr else { return "" } let data = Data(bytes: ptr, count: Int(cString.len)) return String(data: data, encoding: .utf8) ?? "" } - + deinit { ghostty_string_free(cString) } @@ -223,7 +223,38 @@ extension Ghostty { } } } +} +#if canImport(AppKit) +// MARK: SplitFocusDirection Extensions + +extension Ghostty.SplitFocusDirection { + /// Convert to a SplitTree.FocusDirection for the given ViewType. + func toSplitTreeFocusDirection() -> SplitTree.FocusDirection { + switch self { + case .previous: + return .previous + + case .next: + return .next + + case .up: + return .spatial(.up) + + case .down: + return .spatial(.down) + + case .left: + return .spatial(.left) + + case .right: + return .spatial(.right) + } + } +} +#endif + +extension Ghostty { /// The type of a clipboard request enum ClipboardRequest { /// A direct paste of clipboard contents @@ -268,9 +299,26 @@ extension Ghostty { } } } + + struct ClipboardContent { + let mime: String + let data: String + + static func from(content: ghostty_clipboard_content_s) -> ClipboardContent? { + guard let mimePtr = content.mime, + let dataPtr = content.data else { + return nil + } + + return ClipboardContent( + mime: String(cString: mimePtr), + data: String(cString: dataPtr) + ) + } + } /// macos-icon - enum MacOSIcon: String { + enum MacOSIcon: String, Sendable { case official case blueprint case chalkboard @@ -332,6 +380,9 @@ extension Notification.Name { /// Close other tabs static let ghosttyCloseOtherTabs = Notification.Name("com.mitchellh.ghostty.closeOtherTabs") + /// Close tabs to the right of the focused tab + static let ghosttyCloseTabsOnTheRight = Notification.Name("com.mitchellh.ghostty.closeTabsOnTheRight") + /// Close window static let ghosttyCloseWindow = Notification.Name("com.mitchellh.ghostty.closeWindow") @@ -340,10 +391,21 @@ extension Notification.Name { /// Ring the bell static let ghosttyBellDidRing = Notification.Name("com.mitchellh.ghostty.ghosttyBellDidRing") + + /// Readonly mode changed + static let ghosttyDidChangeReadonly = Notification.Name("com.mitchellh.ghostty.didChangeReadonly") + static let ReadonlyKey = ghosttyDidChangeReadonly.rawValue + ".readonly" static let ghosttyCommandPaletteDidToggle = Notification.Name("com.mitchellh.ghostty.commandPaletteDidToggle") /// Toggle maximize of current window static let ghosttyMaximizeDidToggle = Notification.Name("com.mitchellh.ghostty.maximizeDidToggle") + + /// 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 new file mode 100644 index 000000000..157136136 --- /dev/null +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -0,0 +1,390 @@ +import SwiftUI +import Combine + +/// Wraps a Ghostty surface view in an NSScrollView to provide native macOS scrollbar support. +/// +/// ## Coordinate System +/// AppKit uses a +Y-up coordinate system (origin at bottom-left), while terminals conceptually +/// use +Y-down (row 0 at top). This class handles the inversion when converting between row +/// offsets and pixel positions. +/// +/// ## Architecture +/// - `scrollView`: The outermost NSScrollView that manages scrollbar rendering and behavior +/// - `documentView`: A blank NSView whose height represents total scrollback (in pixels) +/// - `surfaceView`: The actual Ghostty renderer, positioned to fill the visible rect +class SurfaceScrollView: NSView { + private let scrollView: NSScrollView + private let documentView: NSView + private let surfaceView: Ghostty.SurfaceView + private var observers: [NSObjectProtocol] = [] + private var cancellables: Set = [] + private var isLiveScrolling = false + + /// The last row position sent via scroll_to_row action. Used to avoid + /// sending redundant actions when the user drags the scrollbar but stays + /// on the same row. + private var lastSentRow: Int? + + init(contentSize: CGSize, surfaceView: Ghostty.SurfaceView) { + self.surfaceView = surfaceView + // The scroll view is our outermost view that controls all our scrollbar + // rendering and behavior. + scrollView = NSScrollView() + scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = false + scrollView.usesPredominantAxisScrolling = true + // Always use the overlay style. See mouseMoved for how we make + // it usable without a scroll wheel or gestures. + scrollView.scrollerStyle = .overlay + // hide default background to show blur effect properly + scrollView.drawsBackground = false + // don't let the content view clip its subviews, to enable the + // surface to draw the background behind non-overlay scrollers + // (we currently only use overlay scrollers, but might as well + // configure the views correctly in case we change our mind) + scrollView.contentView.clipsToBounds = false + + // The document view is what the scrollview is actually going + // to be directly scrolling. We set it up to a "blank" NSView + // with the desired content size. + documentView = NSView(frame: NSRect(origin: .zero, size: contentSize)) + scrollView.documentView = documentView + + // The document view contains our actual surface as a child. + // We synchronize the scrolling of the document with this surface + // so that our primary Ghostty renderer only needs to render the viewport. + documentView.addSubview(surfaceView) + + super.init(frame: .zero) + + // Our scroll view is our only view + addSubview(scrollView) + + // Apply initial scrollbar settings + synchronizeAppearance() + + // We listen for scroll events through bounds notifications on our NSClipView. + // This is based on: https://christiantietze.de/posts/2018/07/synchronize-nsscrollview/ + scrollView.contentView.postsBoundsChangedNotifications = true + observers.append(NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] notification in + self?.handleScrollChange(notification) + }) + + // Listen for scrollbar updates from Ghostty + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidUpdateScrollbar, + object: surfaceView, + queue: .main + ) { [weak self] notification in + self?.handleScrollbarUpdate(notification) + }) + + // Listen for live scroll events + observers.append(NotificationCenter.default.addObserver( + forName: NSScrollView.willStartLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + self?.isLiveScrolling = true + }) + + observers.append(NotificationCenter.default.addObserver( + forName: NSScrollView.didEndLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + self?.isLiveScrolling = false + }) + + observers.append(NotificationCenter.default.addObserver( + forName: NSScrollView.didLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + self?.handleLiveScroll() + }) + + observers.append(NotificationCenter.default.addObserver( + forName: NSScroller.preferredScrollerStyleDidChangeNotification, + object: nil, + // Since this observer is used to immediately override the event + // that produced the notification, we let it run synchronously on + // the posting thread. + queue: nil + ) { [weak self] _ in + self?.handleScrollerStyleChange() + }) + + // Listen for frame change events. See the docstring for + // handleFrameChange for why this is necessary. + observers.append(NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: nil, + // Since this observer is used to immediately override the event + // that produced the notification, we let it run synchronously on + // the posting thread. + queue: nil + ) { [weak self] notification in + self?.handleFrameChange(notification) + }) + + // Listen for derived config changes to update scrollbar settings live + surfaceView.$derivedConfig + .sink { [weak self] _ in + DispatchQueue.main.async { [weak self] in + self?.handleConfigChange() + } + } + .store(in: &cancellables) + surfaceView.$pointerStyle + .receive(on: DispatchQueue.main) + .sink { [weak self] newStyle in + self?.scrollView.documentCursor = newStyle.cursor + } + .store(in: &cancellables) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) not implemented") + } + + deinit { + observers.forEach { NotificationCenter.default.removeObserver($0) } + } + + // The entire bounds is a safe area, so we override any default + // insets. This is necessary for the content view to match the + // surface view if we have the "hidden" titlebar style. + override var safeAreaInsets: NSEdgeInsets { return NSEdgeInsetsZero } + + override func layout() { + super.layout() + + // Fill entire bounds with scroll view + scrollView.frame = bounds + surfaceView.frame.size = scrollView.bounds.size + + // We only set the width of the documentView here, as the height depends + // on the scrollbar state and is updated in synchronizeScrollView + documentView.frame.size.width = scrollView.bounds.width + + // When our scrollview changes make sure our scroller and surface views are synchronized + synchronizeScrollView() + synchronizeSurfaceView() + synchronizeCoreSurface() + } + + // MARK: Scrolling + + private func synchronizeAppearance() { + let scrollbarConfig = surfaceView.derivedConfig.scrollbar + scrollView.hasVerticalScroller = scrollbarConfig != .never + 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) + updateTrackingAreas() + } + + /// Positions the surface view to fill the currently visible rectangle. + /// + /// This is called whenever the scroll position changes. The surface view (which does the + /// actual terminal rendering) always fills exactly the visible portion of the document view, + /// so the renderer only needs to render what's currently on screen. + private func synchronizeSurfaceView() { + let visibleRect = scrollView.contentView.documentVisibleRect + surfaceView.frame.origin = visibleRect.origin + } + + /// Inform the actual pty of our size change. This doesn't change the actual view + /// frame because we do want to render the whole thing, but it will prevent our + /// rows/cols from going into the non-content area. + private func synchronizeCoreSurface() { + // Only update the pty if we have a valid (non-zero) content size. The content size + // can be zero when this is added early to a view, or to an invisible hierarchy. + // Practically, this happened in the quick terminal. + let width = scrollView.contentSize.width + let height = surfaceView.frame.height + if width > 0 && height > 0 { + surfaceView.sizeDidChange(CGSize(width: width, height: height)) + } + } + + /// Sizes the document view and scrolls the content view according to the scrollbar state + private func synchronizeScrollView() { + // Update the document height to give our scroller the correct proportions + documentView.frame.size.height = documentHeight() + + // Only update our actual scroll position if we're not actively scrolling. + if !isLiveScrolling { + // Convert row units to pixels using cell height, ignore zero height. + let cellHeight = surfaceView.cellSize.height + if cellHeight > 0, let scrollbar = surfaceView.scrollbar { + // Invert coordinate system: terminal offset is from top, AppKit position from bottom + let offsetY = + CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight + scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY)) + + // Track the current row position to avoid redundant movements when we + // move the scrollbar. + lastSentRow = Int(scrollbar.offset) + } + } + + // Always update our scrolled view with the latest dimensions + scrollView.reflectScrolledClipView(scrollView.contentView) + } + + // MARK: Notifications + + /// Handles bounds changes in the scroll view's clip view, keeping the surface view synchronized. + private func handleScrollChange(_ notification: Notification) { + synchronizeSurfaceView() + } + + /// Handles scrollbar style changes + private func handleScrollerStyleChange() { + scrollView.scrollerStyle = .overlay + synchronizeCoreSurface() + } + + /// Handles config changes + private func handleConfigChange() { + synchronizeAppearance() + synchronizeCoreSurface() + } + + /// Handles live scroll events (user actively dragging the scrollbar). + /// + /// Converts the current scroll position to a row number and sends a `scroll_to_row` action + /// to the terminal core. Only sends actions when the row changes to avoid IPC spam. + private func handleLiveScroll() { + // If our cell height is currently zero then we avoid a div by zero below + // and just don't scroll (there's no where to scroll anyways). This can + // happen with a tiny terminal. + let cellHeight = surfaceView.cellSize.height + guard cellHeight > 0 else { return } + + // AppKit views are +Y going up, so we calculate from the bottom + let visibleRect = scrollView.contentView.documentVisibleRect + let documentHeight = documentView.frame.height + let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height + let row = Int(scrollOffset / cellHeight) + + // Only send action if the row changed to avoid action spam + guard row != lastSentRow else { return } + lastSentRow = row + + // Use the keybinding action to scroll. + _ = surfaceView.surfaceModel?.perform(action: "scroll_to_row:\(row)") + } + + /// Handles scrollbar state updates from the terminal core. + /// + /// Updates the document view size to reflect total scrollback and adjusts scroll position + /// to match the terminal's viewport. During live scrolling, updates document size but skips + /// programmatic position changes to avoid fighting the user's drag. + /// + /// ## Scrollbar State + /// The scrollbar struct contains: + /// - `total`: Total rows in scrollback + active area + /// - `offset`: First visible row (0 = top of history) + /// - `len`: Number of visible rows (viewport height) + private func handleScrollbarUpdate(_ notification: Notification) { + guard let scrollbar = notification.userInfo?[SwiftUI.Notification.Name.ScrollbarKey] as? Ghostty.Action.Scrollbar else { + return + } + surfaceView.scrollbar = scrollbar + synchronizeScrollView() + } + + /// Handles a change in the frame of NSScrollPocket styling overlays + /// + /// NSScrollView instances are set up with a subview hierarchy which, as far + /// as I can tell, is intended to add a blur effect to any part of a scroll + /// view that lies under the titlebar, presumably to complement a titlebar + /// using liquid glass transparency. This doesn't work correctly with our + /// hidden titlebar style, which does have a titlebar container, albeit + /// hidden. The styling overlays don't care and size themselves to this + /// container, creating a blurry, transparent field that clips the top of + /// the surface view. + /// + /// With other titlebar styles, these views always have zero frame size, + /// presumably because there is no overlap between the scroll view and the + /// titlebar container. + /// + /// In native fullscreen, the titlebar detaches from the window and these + /// views seem to work a bit differently, taking non-zero sizes for all + /// styles without creating any problems. + /// + /// To handle this in a way that minimizes the difference between how the + /// hidden titlebar and other window styles behave, we do as follows: If we + /// have the hidden titlebar style and we're not fullscreen, we listen to + /// frame changes on NSScrollPocket-related objects in scrollView.subviews, + /// and reset their frame to zero. + /// + /// See also https://developer.apple.com/forums/thread/798392. + private func handleFrameChange(_ notification: Notification) { + guard let window = window as? HiddenTitlebarTerminalWindow else { return } + guard !window.styleMask.contains(.fullScreen) else { return } + guard let view = notification.object as? NSView else { return } + guard view.className.contains("NSScrollPocket") else { return } + guard scrollView.subviews.contains(view) else { return } + // These guards to avoid an infinite loop don't actually seem necessary. + // The number of times we reach this point during any given event (e.g., + // creating a split) is the same either way. We keep them anyway out of + // an abundance of caution. + view.postsFrameChangedNotifications = false + view.frame = NSRect(x: 0, y: 0, width: 0, height: 0) + view.postsFrameChangedNotifications = true + } + + // MARK: Calculations + + /// Calculate the appropriate document view height given a scrollbar state + private func documentHeight() -> CGFloat { + let contentHeight = scrollView.contentSize.height + let cellHeight = surfaceView.cellSize.height + if cellHeight > 0, let scrollbar = surfaceView.scrollbar { + // The document view must have the same vertical padding around the + // scrollback grid as the content view has around the terminal grid + // otherwise the content view loses alignment with the surface. + let documentGridHeight = CGFloat(scrollbar.total) * cellHeight + let padding = contentHeight - (CGFloat(scrollbar.len) * cellHeight) + return documentGridHeight + padding + } + return contentHeight + } + + // MARK: Mouse events + + override func mouseMoved(with: NSEvent) { + // When the OS preferred style is .legacy, the user should be able to + // click and drag the scroller without using scroll wheels or gestures, + // so we flash it when the mouse is moved over the scrollbar area. + guard NSScroller.preferredScrollerStyle == .legacy else { return } + scrollView.flashScrollers() + } + + override func updateTrackingAreas() { + // To update our tracking area we just recreate it all. + trackingAreas.forEach { removeTrackingArea($0) } + + super.updateTrackingAreas() + + // Our tracking area is the scroller frame + guard let scroller = scrollView.verticalScroller else { return } + addTrackingArea(NSTrackingArea( + rect: convert(scroller.bounds, from: scroller), + options: [ + .mouseMoved, + .activeInKeyWindow, + ], + owner: self, + userInfo: nil)) + } +} diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index aca17c0fc..82232dd89 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -76,7 +76,6 @@ extension Ghostty { .focusedValue(\.ghosttySurfaceView, surfaceView) .focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize) #if canImport(AppKit) - .backport.pointerStyle(surfaceView.pointerStyle) .onReceive(pubBecomeKey) { notification in guard let window = notification.object as? NSWindow else { return } guard let surfaceWindow = surfaceView.window else { return } @@ -117,6 +116,13 @@ extension Ghostty { } #if canImport(AppKit) + // Readonly indicator badge + if surfaceView.readonly { + ReadonlyBadge { + surfaceView.toggleReadonly(nil) + } + } + // If we are in the middle of a key sequence, then we show a visual element. We only // support this on macOS currently although in theory we can support mobile with keyboards! if !surfaceView.keySequence.isEmpty { @@ -198,7 +204,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) @@ -383,13 +398,205 @@ 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. - /// - /// We just wrap an AppKit NSView here at the moment so that we can behave as low level as possible - /// since that is what the Metal renderer in Ghostty expects. In the future, it may make more sense to - /// wrap an MTKView and use that, but for legacy reasons we didn't do that to begin with. struct SurfaceRepresentable: OSViewRepresentable { /// The view to render for the terminal surface. let view: SurfaceView @@ -404,16 +611,26 @@ extension Ghostty { /// The best approach is to wrap this view in a GeometryReader and pass in the geo.size. let size: CGSize + #if canImport(AppKit) + func makeOSView(context: Context) -> SurfaceScrollView { + // On macOS, wrap the surface view in a scroll view + return SurfaceScrollView(contentSize: size, surfaceView: view) + } + + func updateOSView(_ scrollView: SurfaceScrollView, context: Context) { + // Nothing to do: SwiftUI automatically updates the frame size, and + // SurfaceScrollView handles the rest in response to that + } + #else func makeOSView(context: Context) -> SurfaceView { - // We need the view as part of the state to be created previously because - // the view is sent to the Ghostty API so that it can manipulate it - // directly since we draw on a render thread. - return view; + // On iOS, return the surface view directly + return view } func updateOSView(_ view: SurfaceView, context: Context) { view.sizeDidChange(size) } + #endif } /// The configuration for a surface. For any configuration not set, defaults will be chosen from @@ -547,6 +764,96 @@ extension Ghostty { } } + // MARK: Readonly Badge + + /// A badge overlay that indicates a surface is in readonly mode. + /// Positioned in the top-right corner and styled to be noticeable but unobtrusive. + struct ReadonlyBadge: View { + let onDisable: () -> Void + + @State private var showingPopover = false + + private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8) + + var body: some View { + VStack { + HStack { + Spacer() + + HStack(spacing: 5) { + Image(systemName: "eye.fill") + .font(.system(size: 12)) + Text("Read-only") + .font(.system(size: 12, weight: .medium)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(badgeBackground) + .foregroundStyle(badgeColor) + .onTapGesture { + showingPopover = true + } + .backport.pointerStyle(.link) + .popover(isPresented: $showingPopover, arrowEdge: .bottom) { + ReadonlyPopoverView(onDisable: onDisable, isPresented: $showingPopover) + } + } + .padding(8) + + Spacer() + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Read-only terminal") + } + + private var badgeBackground: some View { + RoundedRectangle(cornerRadius: 6) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.orange.opacity(0.6), lineWidth: 1.5) + ) + } + } + + struct ReadonlyPopoverView: View { + let onDisable: () -> Void + @Binding var isPresented: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "eye.fill") + .foregroundColor(.orange) + .font(.system(size: 13)) + Text("Read-Only Mode") + .font(.system(size: 13, weight: .semibold)) + } + + Text("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + + Button("Disable") { + onDisable() + isPresented = false + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(16) + .frame(width: 280) + } + } + #if canImport(AppKit) /// When changing the split state, or going full screen (native or non), the terminal view /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't @@ -653,3 +960,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 2b3fd261c..d26545ebc 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 @@ -73,7 +111,7 @@ extension Ghostty { @Published var surfaceSize: ghostty_surface_size_s? = nil // Whether the pointer should be visible or not - @Published private(set) var pointerStyle: BackportPointerStyle = .default + @Published private(set) var pointerStyle: CursorStyle = .horizontalText /// The configuration derived from the Ghostty config so we don't need to rely on references. @Published private(set) var derivedConfig: DerivedConfig @@ -85,10 +123,21 @@ extension Ghostty { /// True when the bell is active. This is set inactive on focus or event. @Published private(set) var bell: Bool = false + /// True when the surface is in readonly mode. + @Published private(set) var readonly: Bool = false + // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? = nil + // A content size received through sizeDidChange that may in some cases + // be different from the frame size. + private var contentSizeBacking: NSSize? + private var contentSize: NSSize { + get { return contentSizeBacking ?? frame.size } + set { contentSizeBacking = newValue } + } + // Set whether the surface is currently on a password input or not. This is // detected with the set_password_input_cb on the Ghostty state. var passwordInput: Bool = false { @@ -144,6 +193,9 @@ extension Ghostty { var surface: ghostty_surface_t? { surfaceModel?.unsafeCValue } + /// Current scrollbar state, cached here for persistence across rebuilds + /// of the SwiftUI view hierarchy, for example when changing splits + var scrollbar: Ghostty.Action.Scrollbar? // Notification identifiers associated with this surface var notificationIdentifiers: Set = [] @@ -284,6 +336,11 @@ extension Ghostty { selector: #selector(ghosttyBellDidRing(_:)), name: .ghosttyBellDidRing, object: self) + center.addObserver( + self, + selector: #selector(ghosttyDidChangeReadonly(_:)), + name: .ghosttyDidChangeReadonly, + object: self) center.addObserver( self, selector: #selector(windowDidChangeScreen), @@ -320,26 +377,6 @@ extension Ghostty { // Setup our tracking area so we get mouse moved events updateTrackingAreas() - // Observe our appearance so we can report the correct value to libghostty. - // This is the best way I know of to get appearance change notifications. - self.appearanceObserver = observe(\.effectiveAppearance, options: [.new, .initial]) { view, change in - guard let appearance = change.newValue else { return } - guard let surface = view.surface else { return } - let scheme: ghostty_color_scheme_e - switch (appearance.name) { - case .aqua, .vibrantLight: - scheme = GHOSTTY_COLOR_SCHEME_LIGHT - - case .darkAqua, .vibrantDark: - scheme = GHOSTTY_COLOR_SCHEME_DARK - - default: - return - } - - ghostty_surface_set_color_scheme(surface, scheme) - } - // The UTTypes that can be dragged onto this view. registerForDraggedTypes(Array(Self.dropTypes)) } @@ -410,6 +447,8 @@ extension Ghostty { // The size represents our final size we're going for. let scaledSize = self.convertToBacking(size) setSurfaceSize(width: UInt32(scaledSize.width), height: UInt32(scaledSize.height)) + // Store this size so we can reuse it when backing properties change + contentSize = size } private func setSurfaceSize(width: UInt32, height: UInt32) { @@ -464,16 +503,16 @@ extension Ghostty { pointerStyle = .resizeLeftRight case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: - pointerStyle = .default + pointerStyle = .verticalText - // These are not yet supported. We should support them by constructing a - // PointerStyle from an NSCursor. case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: - fallthrough + pointerStyle = .contextMenu + case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: - fallthrough + pointerStyle = .crosshair + case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: - pointerStyle = .default + pointerStyle = .operationNotAllowed default: // We ignore unknown shapes. @@ -672,6 +711,11 @@ extension Ghostty { bell = true } + @objc private func ghosttyDidChangeReadonly(_ notification: SwiftUI.Notification) { + guard let value = notification.userInfo?[SwiftUI.Notification.Name.ReadonlyKey] as? Bool else { return } + readonly = value + } + @objc private func windowDidChangeScreen(notification: SwiftUI.Notification) { guard let window = self.window else { return } guard let object = notification.object as? NSWindow, window == object else { return } @@ -764,7 +808,8 @@ extension Ghostty { ghostty_surface_set_content_scale(surface, xScale, yScale) // When our scale factor changes, so does our fb size so we send that too - setSurfaceSize(width: UInt32(fbFrame.size.width), height: UInt32(fbFrame.size.height)) + let scaledSize = self.convertToBacking(contentSize) + setSurfaceSize(width: UInt32(scaledSize.width), height: UInt32(scaledSize.height)) } override func mouseDown(with event: NSEvent) { @@ -889,6 +934,7 @@ extension Ghostty { // Handle focus-follows-mouse if let window, let controller = window.windowController as? BaseTerminalController, + !controller.commandPaletteIsShowing, (window.isKeyWindow && !self.focused && controller.focusFollowsMouse) @@ -1383,9 +1429,13 @@ extension Ghostty { item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise") item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "scope") + item = menu.addItem(withTitle: "Terminal Read-only", action: #selector(toggleReadonly(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "eye.fill") + item.state = readonly ? .on : .off menu.addItem(.separator()) - item = menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "pencil.line") + item = menu.addItem(withTitle: "Change Terminal Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") return menu } @@ -1395,7 +1445,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)") } } @@ -1403,7 +1453,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)") } } @@ -1412,7 +1462,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)") } } @@ -1420,7 +1470,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)") } } @@ -1428,7 +1478,47 @@ 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)") + } + } + + @IBAction func toggleReadonly(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "toggle_readonly" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1456,7 +1546,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)") } } @@ -1464,7 +1554,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)") } } @@ -1532,6 +1622,7 @@ extension Ghostty { let macosWindowShadow: Bool let windowTitleFontFamily: String? let windowAppearance: NSAppearance? + let scrollbar: Ghostty.Config.Scrollbar init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) @@ -1539,6 +1630,7 @@ extension Ghostty { self.macosWindowShadow = true self.windowTitleFontFamily = nil self.windowAppearance = nil + self.scrollbar = .system } init(_ config: Ghostty.Config) { @@ -1547,6 +1639,7 @@ extension Ghostty { self.macosWindowShadow = config.macosWindowShadow self.windowTitleFontFamily = config.windowTitleFontFamily self.windowAppearance = .init(ghosttyConfig: config) + self.scrollbar = config.scrollbar } } @@ -1722,13 +1815,22 @@ 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, + // width should be 0 so that dictation indicator + // can start in the right place let viewRect = NSMakeRect( x, frame.size.height - y, - max(width, cellSize.width), + width, max(height, cellSize.height)) // Convert the point to the window coordinates @@ -1893,6 +1995,13 @@ 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 + + case #selector(toggleReadonly): + item.state = readonly ? .on : .off + return true default: return true diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 29364d4a5..568a93314 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -40,6 +40,12 @@ 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 + + /// True when the surface is in readonly mode. + @Published private(set) var readonly: Bool = false // 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/Cursor.swift b/macos/Sources/Helpers/Cursor.swift index fe4a148b5..f749386da 100644 --- a/macos/Sources/Helpers/Cursor.swift +++ b/macos/Sources/Helpers/Cursor.swift @@ -1,6 +1,7 @@ import Cocoa +import SwiftUI -/// This helps manage the stateful nature of NSCursor hiding and unhiding. +/// This helps manage the stateful nature of NSCursor hiding and unhiding. class Cursor { private static var counter: UInt = 0 @@ -19,7 +20,7 @@ class Cursor { // won't go negative. NSCursor.unhide() - if (counter > 0) { + if counter > 0 { counter -= 1 return true } @@ -29,10 +30,89 @@ class Cursor { static func unhideCompletely() -> UInt { let counter = self.counter - for _ in 0.. UInt? { + if let identifier = item.identifier, + let existing = items.first(where: { $0.identifier == identifier }) { + removeItem(existing) + } + + guard let idx = items.firstIndex(where: { $0.action == action }) else { + return nil + } + + let insertionIndex = idx + 1 + insertItem(item, at: insertionIndex) + return UInt(insertionIndex) + } + + /// Removes all menu items whose identifier is in the given set. + /// + /// - Parameter identifiers: The set of identifiers to match for removal. + func removeItems(withIdentifiers identifiers: Set) { + for (index, item) in items.enumerated().reversed() { + guard let identifier = item.identifier else { continue } + if identifiers.contains(identifier) { + removeItem(at: index) + } + } + } +} diff --git a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift index 11815fbc8..a036f02b4 100644 --- a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift @@ -1,5 +1,30 @@ import AppKit import GhosttyKit +import UniformTypeIdentifiers + +extension NSPasteboard.PasteboardType { + /// Initialize a pasteboard type from a MIME type string + init?(mimeType: String) { + // Explicit mappings for common MIME types + switch mimeType { + case "text/plain": + self = .string + return + default: + break + } + + // Try to get UTType from MIME type + guard let utType = UTType(mimeType: mimeType) else { + // Fallback: use the MIME type directly as identifier + self.init(mimeType) + return + } + + // Use the UTType's identifier + self.init(utType.identifier) + } +} extension NSPasteboard { /// The pasteboard to used for Ghostty selection. diff --git a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift index f46106004..a8eb7b876 100644 --- a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift @@ -5,6 +5,13 @@ extension NSScreen { var displayID: UInt32? { deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32 } + + /// The stable UUID for this display, suitable for tracking across reconnects and NSScreen garbage collection. + var displayUUID: UUID? { + guard let displayID = displayID else { return nil } + guard let cfuuid = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue() else { return nil } + return UUID(cfuuid) + } // Returns true if the given screen has a visible dock. This isn't // point-in-time visible, this is true if the dock is always visible 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/macos/Sources/Helpers/Extensions/UUID+Extension.swift b/macos/Sources/Helpers/Extensions/UUID+Extension.swift new file mode 100644 index 000000000..e536353c5 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/UUID+Extension.swift @@ -0,0 +1,9 @@ +import Foundation + +extension UUID { + /// Initialize a UUID from a CFUUID. + init?(_ cfuuid: CFUUID) { + guard let uuidString = CFUUIDCreateString(nil, cfuuid) as String? else { return nil } + self.init(uuidString: uuidString) + } +} diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 6c70e8cf7..78c967661 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -2,7 +2,7 @@ import Cocoa import GhosttyKit /// The fullscreen modes we support define how the fullscreen behaves. -enum FullscreenMode { +enum FullscreenMode: String, Codable { case native case nonNative case nonNativeVisibleMenu @@ -31,6 +31,7 @@ enum FullscreenMode { /// Protocol that must be implemented by all fullscreen styles. protocol FullscreenStyle { var delegate: FullscreenDelegate? { get set } + var fullscreenMode: FullscreenMode { get } var isFullscreen: Bool { get } var supportsTabs: Bool { get } init?(_ window: NSWindow) @@ -87,6 +88,7 @@ class FullscreenBase { /// macOS native fullscreen. This is the typical behavior you get by pressing the green fullscreen /// button on regular titlebars. class NativeFullscreen: FullscreenBase, FullscreenStyle { + var fullscreenMode: FullscreenMode { .native } var isFullscreen: Bool { window.styleMask.contains(.fullScreen) } var supportsTabs: Bool { true } @@ -127,6 +129,8 @@ class NativeFullscreen: FullscreenBase, FullscreenStyle { } class NonNativeFullscreen: FullscreenBase, FullscreenStyle { + var fullscreenMode: FullscreenMode { .nonNative } + // Non-native fullscreen never supports tabs because tabs require // the "titled" style and we don't have it for non-native fullscreen. var supportsTabs: Bool { false } @@ -439,10 +443,12 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { } class NonNativeFullscreenVisibleMenu: NonNativeFullscreen { + override var fullscreenMode: FullscreenMode { .nonNativeVisibleMenu } override var properties: Properties { Properties(hideMenu: false) } } class NonNativeFullscreenPaddedNotch: NonNativeFullscreen { + override var fullscreenMode: FullscreenMode { .nonNativePaddedNotch } override var properties: Properties { Properties(paddedNotch: true) } } diff --git a/macos/Sources/Helpers/NonDraggableHostingView.swift b/macos/Sources/Helpers/NonDraggableHostingView.swift new file mode 100644 index 000000000..26238182f --- /dev/null +++ b/macos/Sources/Helpers/NonDraggableHostingView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +/// An NSHostingView subclass that prevents window dragging when clicking on the view. +/// +/// By default, NSHostingViews in the titlebar allow the window to be dragged when +/// clicked. This subclass overrides `mouseDownCanMoveWindow` to return false, +/// preventing the window from being dragged when the user clicks on this view. +/// +/// This is useful for titlebar accessories that contain interactive elements +/// (buttons, links, etc.) where you don't want accidental window dragging. +class NonDraggableHostingView: NSHostingView { + override var mouseDownCanMoveWindow: Bool { false } +} diff --git a/macos/Tests/NSPasteboardTests.swift b/macos/Tests/NSPasteboardTests.swift new file mode 100644 index 000000000..d956ce733 --- /dev/null +++ b/macos/Tests/NSPasteboardTests.swift @@ -0,0 +1,33 @@ +// +// NSPasteboardTests.swift +// GhosttyTests +// +// Tests for NSPasteboard.PasteboardType MIME type conversion. +// + +import Testing +import AppKit +@testable import Ghostty + +struct NSPasteboardTypeExtensionTests { + /// Test text/plain MIME type converts to .string + @Test func testTextPlainMimeType() async throws { + let pasteboardType = NSPasteboard.PasteboardType(mimeType: "text/plain") + #expect(pasteboardType != nil) + #expect(pasteboardType == .string) + } + + /// Test text/html MIME type converts to .html + @Test func testTextHtmlMimeType() async throws { + let pasteboardType = NSPasteboard.PasteboardType(mimeType: "text/html") + #expect(pasteboardType != nil) + #expect(pasteboardType == .html) + } + + /// Test image/png MIME type + @Test func testImagePngMimeType() async throws { + let pasteboardType = NSPasteboard.PasteboardType(mimeType: "image/png") + #expect(pasteboardType != nil) + #expect(pasteboardType == .png) + } +} diff --git a/macos/Tests/Update/ReleaseNotesTests.swift b/macos/Tests/Update/ReleaseNotesTests.swift new file mode 100644 index 000000000..b029fa6bc --- /dev/null +++ b/macos/Tests/Update/ReleaseNotesTests.swift @@ -0,0 +1,130 @@ +import Testing +import Foundation +@testable import Ghostty + +struct ReleaseNotesTests { + /// Test tagged release (semantic version) + @Test func testTaggedRelease() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "1.2.3", + currentCommit: nil + ) + + #expect(notes != nil) + if case .tagged(let url) = notes { + #expect(url.absoluteString == "https://ghostty.org/docs/install/release-notes/1-2-3") + #expect(notes?.label == "View Release Notes") + } else { + Issue.record("Expected tagged case") + } + } + + /// Test tip release comparison with current commit + @Test func testTipReleaseComparison() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "tip-abc1234", + currentCommit: "def5678" + ) + + #expect(notes != nil) + if case .compareTip(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234") + #expect(notes?.label == "Changes Since This Tip Release") + } else { + Issue.record("Expected compareTip case") + } + } + + /// Test tip release without current commit + @Test func testTipReleaseWithoutCurrentCommit() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "tip-abc1234", + currentCommit: nil + ) + + #expect(notes != nil) + if case .commit(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234") + #expect(notes?.label == "View GitHub Commit") + } else { + Issue.record("Expected commit case") + } + } + + /// Test tip release with empty current commit + @Test func testTipReleaseWithEmptyCurrentCommit() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "tip-abc1234", + currentCommit: "" + ) + + #expect(notes != nil) + if case .commit(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234") + } else { + Issue.record("Expected commit case") + } + } + + /// Test version with full 40-character hash + @Test func testFullGitHash() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "tip-1234567890abcdef1234567890abcdef12345678", + currentCommit: nil + ) + + #expect(notes != nil) + if case .commit(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/1234567890abcdef1234567890abcdef12345678") + } else { + Issue.record("Expected commit case") + } + } + + /// Test version with no recognizable pattern + @Test func testInvalidVersion() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "unknown-version", + currentCommit: nil + ) + + #expect(notes == nil) + } + + /// Test semantic version with prerelease suffix should not match + @Test func testSemanticVersionWithSuffix() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "1.2.3-beta", + currentCommit: nil + ) + + // Should not match semantic version pattern, falls back to hash detection + #expect(notes == nil) + } + + /// Test semantic version with 4 components should not match + @Test func testSemanticVersionFourComponents() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "1.2.3.4", + currentCommit: nil + ) + + // Should not match pattern + #expect(notes == nil) + } + + /// Test version string with git hash embedded + @Test func testVersionWithEmbeddedHash() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "v2024.01.15-abc1234", + currentCommit: "def5678" + ) + + #expect(notes != nil) + if case .compareTip(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234") + } else { + Issue.record("Expected compareTip case") + } + } +} diff --git a/macos/Tests/Update/UpdateStateTests.swift b/macos/Tests/Update/UpdateStateTests.swift new file mode 100644 index 000000000..354d371c5 --- /dev/null +++ b/macos/Tests/Update/UpdateStateTests.swift @@ -0,0 +1,112 @@ +import Testing +import Foundation +import Sparkle +@testable import Ghostty + +struct UpdateStateTests { + // MARK: - Equatable Tests + + @Test func testIdleEquality() { + let state1: UpdateState = .idle + let state2: UpdateState = .idle + #expect(state1 == state2) + } + + @Test func testCheckingEquality() { + let state1: UpdateState = .checking(.init(cancel: {})) + let state2: UpdateState = .checking(.init(cancel: {})) + #expect(state1 == state2) + } + + @Test func testNotFoundEquality() { + let state1: UpdateState = .notFound(.init(acknowledgement: {})) + let state2: UpdateState = .notFound(.init(acknowledgement: {})) + #expect(state1 == state2) + } + + @Test func testInstallingEquality() { + let state1: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) + let state2: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) + #expect(state1 == state2) + let state3: UpdateState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) + #expect(state3 != state2) + } + + @Test func testPermissionRequestEquality() { + let request1 = SPUUpdatePermissionRequest(systemProfile: []) + let request2 = SPUUpdatePermissionRequest(systemProfile: []) + let state1: UpdateState = .permissionRequest(.init(request: request1, reply: { _ in })) + let state2: UpdateState = .permissionRequest(.init(request: request2, reply: { _ in })) + #expect(state1 == state2) + } + + @Test func testDownloadingEqualityWithSameProgress() { + let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + #expect(state1 == state2) + } + + @Test func testDownloadingInequalityWithDifferentProgress() { + let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 600)) + #expect(state1 != state2) + } + + @Test func testDownloadingInequalityWithDifferentExpectedLength() { + let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 2000, progress: 500)) + #expect(state1 != state2) + } + + @Test func testDownloadingEqualityWithNilExpectedLength() { + let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500)) + let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500)) + #expect(state1 == state2) + } + + @Test func testExtractingEqualityWithSameProgress() { + let state1: UpdateState = .extracting(.init(progress: 0.5)) + let state2: UpdateState = .extracting(.init(progress: 0.5)) + #expect(state1 == state2) + } + + @Test func testExtractingInequalityWithDifferentProgress() { + let state1: UpdateState = .extracting(.init(progress: 0.5)) + let state2: UpdateState = .extracting(.init(progress: 0.6)) + #expect(state1 != state2) + } + + @Test func testErrorEqualityWithSameDescription() { + let error1 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error message"]) + let error2 = NSError(domain: "Test", code: 2, userInfo: [NSLocalizedDescriptionKey: "Error message"]) + let state1: UpdateState = .error(.init(error: error1, retry: {}, dismiss: {})) + let state2: UpdateState = .error(.init(error: error2, retry: {}, dismiss: {})) + #expect(state1 == state2) + } + + @Test func testErrorInequalityWithDifferentDescription() { + let error1 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error 1"]) + let error2 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error 2"]) + let state1: UpdateState = .error(.init(error: error1, retry: {}, dismiss: {})) + let state2: UpdateState = .error(.init(error: error2, retry: {}, dismiss: {})) + #expect(state1 != state2) + } + + @Test func testDifferentStatesAreNotEqual() { + let state1: UpdateState = .idle + let state2: UpdateState = .checking(.init(cancel: {})) + #expect(state1 != state2) + } + + // MARK: - isIdle Tests + + @Test func testIsIdleTrue() { + let state: UpdateState = .idle + #expect(state.isIdle == true) + } + + @Test func testIsIdleFalse() { + let state: UpdateState = .checking(.init(cancel: {})) + #expect(state.isIdle == false) + } +} diff --git a/macos/Tests/Update/UpdateViewModelTests.swift b/macos/Tests/Update/UpdateViewModelTests.swift new file mode 100644 index 000000000..529c2bc52 --- /dev/null +++ b/macos/Tests/Update/UpdateViewModelTests.swift @@ -0,0 +1,93 @@ +import Testing +import Foundation +import SwiftUI +import Sparkle +@testable import Ghostty + +struct UpdateViewModelTests { + // MARK: - Text Formatting Tests + + @Test func testIdleText() { + let viewModel = UpdateViewModel() + viewModel.state = .idle + #expect(viewModel.text == "") + } + + @Test func testPermissionRequestText() { + let viewModel = UpdateViewModel() + let request = SPUUpdatePermissionRequest(systemProfile: []) + viewModel.state = .permissionRequest(.init(request: request, reply: { _ in })) + #expect(viewModel.text == "Enable Automatic Updates?") + } + + @Test func testCheckingText() { + let viewModel = UpdateViewModel() + viewModel.state = .checking(.init(cancel: {})) + #expect(viewModel.text == "Checking for Updates…") + } + + @Test func testDownloadingTextWithKnownLength() { + let viewModel = UpdateViewModel() + viewModel.state = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + #expect(viewModel.text == "Downloading: 50%") + } + + @Test func testDownloadingTextWithUnknownLength() { + let viewModel = UpdateViewModel() + viewModel.state = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500)) + #expect(viewModel.text == "Downloading…") + } + + @Test func testDownloadingTextWithZeroExpectedLength() { + let viewModel = UpdateViewModel() + viewModel.state = .downloading(.init(cancel: {}, expectedLength: 0, progress: 500)) + #expect(viewModel.text == "Downloading…") + } + + @Test func testExtractingText() { + let viewModel = UpdateViewModel() + viewModel.state = .extracting(.init(progress: 0.75)) + #expect(viewModel.text == "Preparing: 75%") + } + + @Test func testInstallingText() { + let viewModel = UpdateViewModel() + viewModel.state = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) + #expect(viewModel.text == "Installing…") + viewModel.state = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) + #expect(viewModel.text == "Restart to Complete Update") + } + + @Test func testNotFoundText() { + let viewModel = UpdateViewModel() + viewModel.state = .notFound(.init(acknowledgement: {})) + #expect(viewModel.text == "No Updates Available") + } + + @Test func testErrorText() { + let viewModel = UpdateViewModel() + let error = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Network error"]) + viewModel.state = .error(.init(error: error, retry: {}, dismiss: {})) + #expect(viewModel.text == "Network error") + } + + // MARK: - Max Width Text Tests + + @Test func testMaxWidthTextForDownloading() { + let viewModel = UpdateViewModel() + viewModel.state = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 50)) + #expect(viewModel.maxWidthText == "Downloading: 100%") + } + + @Test func testMaxWidthTextForExtracting() { + let viewModel = UpdateViewModel() + viewModel.state = .extracting(.init(progress: 0.5)) + #expect(viewModel.maxWidthText == "Preparing: 100%") + } + + @Test func testMaxWidthTextForNonProgressState() { + let viewModel = UpdateViewModel() + viewModel.state = .checking(.init(cancel: {})) + #expect(viewModel.maxWidthText == viewModel.text) + } +} diff --git a/nix/build-support/check-zig-cache.sh b/nix/build-support/check-zig-cache.sh index e92a27b6f..9a3927846 100755 --- a/nix/build-support/check-zig-cache.sh +++ b/nix/build-support/check-zig-cache.sh @@ -79,7 +79,7 @@ elif [ "$1" != "--update" ]; then exit 1 fi -zon2nix "$BUILD_ZIG_ZON" --14 --nix "$WORK_DIR/build.zig.zon.nix" --txt "$WORK_DIR/build.zig.zon.txt" --json "$WORK_DIR/build.zig.zon.json" --flatpak "$WORK_DIR/zig-packages.json" +zon2nix "$BUILD_ZIG_ZON" --15 --nix "$WORK_DIR/build.zig.zon.nix" --txt "$WORK_DIR/build.zig.zon.txt" --json "$WORK_DIR/build.zig.zon.json" --flatpak "$WORK_DIR/zig-packages.json" alejandra --quiet "$WORK_DIR/build.zig.zon.nix" prettier --log-level warn --write "$WORK_DIR/build.zig.zon.json" prettier --log-level warn --write "$WORK_DIR/zig-packages.json" diff --git a/nix/devShell.nix b/nix/devShell.nix index 0c97ec0da..d37107133 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -70,7 +70,6 @@ wayland-scanner, wayland-protocols, zon2nix, - system, pkgs, # needed by GTK for loading SVG icons while running from within the # developer shell @@ -100,7 +99,7 @@ in scdoc zig zip - zon2nix.packages.${system}.zon2nix + zon2nix.packages.${stdenv.hostPlatform.system}.zon2nix # For web and wasm stuff nodejs @@ -136,6 +135,11 @@ in blueprint-compiler libadwaita gtk4 + + # Python packages + (python3.withPackages (python-pkgs: [ + python-pkgs.ucs-detect + ])) ] ++ lib.optionals stdenv.hostPlatform.isLinux [ # My nix shell environment installs the non-interactive version diff --git a/nix/package.nix b/nix/package.nix index 73d31c3b9..3d00648ec 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -40,7 +40,7 @@ in stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; - version = "1.2.1"; + version = "1.3.0-dev"; # We limit source like this to try and reduce the amount of rebuilds as possible # thus we only provide the source that is needed for the build diff --git a/nix/pkgs/blessed.nix b/nix/pkgs/blessed.nix new file mode 100644 index 000000000..8b6728f43 --- /dev/null +++ b/nix/pkgs/blessed.nix @@ -0,0 +1,37 @@ +{ + lib, + buildPythonPackage, + fetchPypi, + pythonOlder, + flit-core, + six, + wcwidth, +}: +buildPythonPackage rec { + pname = "blessed"; + version = "1.23.0"; + pyproject = true; + + disabled = pythonOlder "3.7"; + + src = fetchPypi { + inherit pname version; + hash = "sha256-VlkaMpZvcE9hMfFACvQVHZ6PX0FEEzpcoDQBl2Pe53s="; + }; + + build-system = [flit-core]; + + propagatedBuildInputs = [ + wcwidth + six + ]; + + doCheck = false; + + meta = with lib; { + homepage = "https://github.com/jquast/blessed"; + description = "Thin, practical wrapper around terminal capabilities in Python"; + maintainers = []; + license = licenses.mit; + }; +} diff --git a/nix/pkgs/ucs-detect.nix b/nix/pkgs/ucs-detect.nix new file mode 100644 index 000000000..07ec6c2fc --- /dev/null +++ b/nix/pkgs/ucs-detect.nix @@ -0,0 +1,41 @@ +{ + lib, + buildPythonPackage, + fetchPypi, + pythonOlder, + setuptools, + # Dependencies + blessed, + wcwidth, + pyyaml, +}: +buildPythonPackage rec { + pname = "ucs-detect"; + version = "1.0.8"; + pyproject = true; + + disabled = pythonOlder "3.8"; + + src = fetchPypi { + inherit version; + pname = "ucs_detect"; + hash = "sha256-ihB+tZCd6ykdeXYxc6V1Q6xALQ+xdCW5yqSL7oppqJc="; + }; + + dependencies = [ + blessed + wcwidth + pyyaml + ]; + + nativeBuildInputs = [setuptools]; + + doCheck = false; + + meta = with lib; { + description = "Measures number of Terminal column cells of wide-character codes"; + homepage = "https://github.com/jquast/ucs-detect"; + license = licenses.mit; + maintainers = []; + }; +} diff --git a/nix/wraptest.nix b/nix/pkgs/wraptest.nix similarity index 100% rename from nix/wraptest.nix rename to nix/pkgs/wraptest.nix diff --git a/nix/tests.nix b/nix/tests.nix new file mode 100644 index 000000000..a9970e80c --- /dev/null +++ b/nix/tests.nix @@ -0,0 +1,283 @@ +{ + self, + system, + nixpkgs, + home-manager, + ... +}: let + nixos-version = nixpkgs.lib.trivial.release; + + pkgs = import nixpkgs { + inherit system; + overlays = [ + self.overlays.debug + ]; + }; + + pink_value = "#FF0087"; + + color_test = '' + import tempfile + import subprocess + + def check_for_pink(final=False) -> bool: + with tempfile.NamedTemporaryFile() as tmpin: + machine.send_monitor_command("screendump {}".format(tmpin.name)) + + cmd = 'convert {} -define histogram:unique-colors=true -format "%c" histogram:info:'.format( + tmpin.name + ) + ret = subprocess.run(cmd, shell=True, capture_output=True) + if ret.returncode != 0: + raise Exception( + "image analysis failed with exit code {}".format(ret.returncode) + ) + + text = ret.stdout.decode("utf-8") + return "${pink_value}" in text + ''; + + mkNodeGnome = { + config, + pkgs, + settings, + sshPort ? null, + ... + }: { + imports = [ + ./vm/wayland-gnome.nix + settings + ]; + + virtualisation = { + forwardPorts = pkgs.lib.optionals (sshPort != null) [ + { + from = "host"; + host.port = sshPort; + guest.port = 22; + } + ]; + + vmVariant = { + virtualisation.host.pkgs = pkgs; + }; + }; + + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "yes"; + PermitEmptyPasswords = "yes"; + }; + }; + + security.pam.services.sshd.allowNullPassword = true; + + users.groups.ghostty = { + gid = 1000; + }; + + users.users.ghostty = { + uid = 1000; + }; + + home-manager = { + users = { + ghostty = { + home = { + username = config.users.users.ghostty.name; + homeDirectory = config.users.users.ghostty.home; + stateVersion = nixos-version; + }; + programs.ssh = { + enable = true; + extraOptionOverrides = { + StrictHostKeyChecking = "accept-new"; + UserKnownHostsFile = "/dev/null"; + }; + }; + }; + }; + }; + + system.stateVersion = nixos-version; + }; + + mkTestGnome = { + name, + settings, + testScript, + ocr ? false, + }: + pkgs.testers.runNixOSTest { + name = name; + + enableOCR = ocr; + + extraBaseModules = { + imports = [ + home-manager.nixosModules.home-manager + ]; + }; + + nodes = { + machine = { + config, + pkgs, + ... + }: + mkNodeGnome { + inherit config pkgs settings; + sshPort = 2222; + }; + }; + + testScript = testScript; + }; +in { + basic-version-check = pkgs.testers.runNixOSTest { + name = "basic-version-check"; + nodes = { + machine = {pkgs, ...}: { + users.groups.ghostty = {}; + users.users.ghostty = { + isNormalUser = true; + group = "ghostty"; + extraGroups = ["wheel"]; + hashedPassword = ""; + packages = [ + pkgs.ghostty + ]; + }; + }; + }; + testScript = {...}: '' + machine.succeed("su - ghostty -c 'ghostty +version'") + ''; + }; + + basic-window-check-gnome = mkTestGnome { + name = "basic-window-check-gnome"; + settings = { + home-manager.users.ghostty = { + xdg.configFile = { + "ghostty/config".text = '' + background = ${pink_value} + ''; + }; + }; + }; + ocr = true; + testScript = {nodes, ...}: let + user = nodes.machine.users.users.ghostty; + bus_path = "/run/user/${toString user.uid}/bus"; + bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}"; + gdbus = "${bus} gdbus"; + ghostty = "${bus} ghostty"; + su = command: "su - ${user.name} -c '${command}'"; + gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval"; + wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class"; + in '' + ${color_test} + + with subtest("wait for x"): + start_all() + machine.wait_for_x() + + machine.wait_for_file("${bus_path}") + + with subtest("Ensuring no pink is present without the terminal."): + assert ( + check_for_pink() == False + ), "Pink was present on the screen before we even launched a terminal!" + + machine.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}") + machine.succeed("${su "${ghostty} +new-window"}") + machine.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'") + + machine.sleep(2) + + with subtest("Have the terminal display a color."): + assert( + check_for_pink() == True + ), "Pink was not found on the screen!" + + machine.systemctl("stop app-com.mitchellh.ghostty-debug.service", user="${user.name}") + ''; + }; + + ssh-integration-test = pkgs.testers.runNixOSTest { + name = "ssh-integration-test"; + extraBaseModules = { + imports = [ + home-manager.nixosModules.home-manager + ]; + }; + nodes = { + server = {...}: { + users.groups.ghostty = {}; + users.users.ghostty = { + isNormalUser = true; + group = "ghostty"; + extraGroups = ["wheel"]; + hashedPassword = ""; + packages = []; + }; + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "yes"; + PermitEmptyPasswords = "yes"; + }; + }; + security.pam.services.sshd.allowNullPassword = true; + }; + client = { + config, + pkgs, + ... + }: + mkNodeGnome { + inherit config pkgs; + settings = { + home-manager.users.ghostty = { + xdg.configFile = { + "ghostty/config".text = let + in '' + shell-integration-features = ssh-terminfo + ''; + }; + }; + }; + sshPort = 2222; + }; + }; + testScript = {nodes, ...}: let + user = nodes.client.users.users.ghostty; + bus_path = "/run/user/${toString user.uid}/bus"; + bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}"; + gdbus = "${bus} gdbus"; + ghostty = "${bus} ghostty"; + su = command: "su - ${user.name} -c '${command}'"; + gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval"; + wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class"; + in '' + with subtest("Start server and wait for ssh to be ready."): + server.start() + server.wait_for_open_port(22) + + with subtest("Start client and wait for ghostty window."): + client.start() + client.wait_for_x() + client.wait_for_file("${bus_path}") + client.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}") + client.succeed("${su "${ghostty} +new-window"}") + client.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'") + + with subtest("SSH from client to server and verify that the Ghostty terminfo is copied.") + client.sleep(2) + client.send_chars("ssh ghostty@server\n") + server.wait_for_file("${user.home}/.terminfo/x/xterm-ghostty", timeout=30) + ''; + }; +} diff --git a/nix/vm/common-gnome.nix b/nix/vm/common-gnome.nix index 0c2bef150..ab4aab9e9 100644 --- a/nix/vm/common-gnome.nix +++ b/nix/vm/common-gnome.nix @@ -22,6 +22,19 @@ }; }; + systemd.user.services = { + "org.gnome.Shell@wayland" = { + serviceConfig = { + ExecStart = [ + # Clear the list before overriding it. + "" + # Eval API is now internal so Shell needs to run in unsafe mode. + "${pkgs.gnome-shell}/bin/gnome-shell --unsafe-mode" + ]; + }; + }; + }; + environment.systemPackages = [ pkgs.gnomeExtensions.no-overview ]; diff --git a/nix/vm/common.nix b/nix/vm/common.nix index eefd7c1c0..b2fec28e8 100644 --- a/nix/vm/common.nix +++ b/nix/vm/common.nix @@ -4,9 +4,6 @@ documentation.nixos.enable = false; - networking.hostName = "ghostty"; - networking.domain = "mitchellh.com"; - virtualisation.vmVariant = { virtualisation.memorySize = 2048; }; @@ -28,17 +25,11 @@ users.groups.ghostty = {}; users.users.ghostty = { + isNormalUser = true; description = "Ghostty"; group = "ghostty"; extraGroups = ["wheel"]; - isNormalUser = true; - initialPassword = "ghostty"; - }; - - environment.etc = { - "xdg/autostart/com.mitchellh.ghostty.desktop" = { - source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop"; - }; + hashedPassword = ""; }; environment.systemPackages = [ @@ -61,6 +52,7 @@ services.displayManager = { autoLogin = { + enable = true; user = "ghostty"; }; }; 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/gtk4-layer-shell/build.zig b/pkg/gtk4-layer-shell/build.zig index b9cf78a23..818b48f45 100644 --- a/pkg/gtk4-layer-shell/build.zig +++ b/pkg/gtk4-layer-shell/build.zig @@ -1,7 +1,6 @@ const std = @import("std"); -// TODO: Import this from build.zig.zon when possible -const version: std.SemanticVersion = .{ .major = 1, .minor = 1, .patch = 0 }; +const version = @import("build.zig.zon").version; const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ .preferred_link_mode = .dynamic, @@ -32,6 +31,7 @@ pub fn build(b: *std.Build) !void { } fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { + const lib_version = try std.SemanticVersion.parse(version); const target = options.target; const optimize = options.optimize; @@ -117,19 +117,9 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu .root = upstream.path("src"), .files = srcs, .flags = &.{ - b.fmt("-DGTK_LAYER_SHELL_MAJOR={}", .{version.major}), - b.fmt("-DGTK_LAYER_SHELL_MINOR={}", .{version.minor}), - b.fmt("-DGTK_LAYER_SHELL_MICRO={}", .{version.patch}), - - // Zig 0.14 regression: this is required because building with - // ubsan results in unknown symbols. Bundling the ubsan/compiler - // RT doesn't help. I'm not sure what the root cause is but I - // suspect its related to this: - // https://github.com/ziglang/zig/issues/23052 - // - // We can remove this in the future for Zig updates and see - // if our binaries run in debug on NixOS. - "-fno-sanitize=undefined", + b.fmt("-DGTK_LAYER_SHELL_MAJOR={}", .{lib_version.major}), + b.fmt("-DGTK_LAYER_SHELL_MINOR={}", .{lib_version.minor}), + b.fmt("-DGTK_LAYER_SHELL_MICRO={}", .{lib_version.patch}), }, }); 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/pkg/wuffs/src/error.zig b/pkg/wuffs/src/error.zig index c75188718..0be55cf4e 100644 --- a/pkg/wuffs/src/error.zig +++ b/pkg/wuffs/src/error.zig @@ -2,7 +2,7 @@ const std = @import("std"); const c = @import("c.zig").c; -pub const Error = std.mem.Allocator.Error || error{WuffsError}; +pub const Error = std.mem.Allocator.Error || error{ WuffsError, Overflow }; pub fn check(log: anytype, status: *const c.struct_wuffs_base__status__struct) error{WuffsError}!void { if (!c.wuffs_base__status__is_ok(status)) { diff --git a/pkg/wuffs/src/jpeg.zig b/pkg/wuffs/src/jpeg.zig index 700ba01b9..69d91c8a9 100644 --- a/pkg/wuffs/src/jpeg.zig +++ b/pkg/wuffs/src/jpeg.zig @@ -4,6 +4,8 @@ const c = @import("c.zig").c; const Error = @import("error.zig").Error; const check = @import("error.zig").check; const ImageData = @import("main.zig").ImageData; +const maximum_image_size = @import("main.zig").maximum_image_size; +const mul = std.math.mul; const log = std.log.scoped(.wuffs_jpeg); @@ -61,9 +63,20 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { height, ); + const size: usize = try mul( + usize, + try mul(usize, width, height), + @sizeOf(c.wuffs_base__color_u32_argb_premul), + ); + + if (size > maximum_image_size) { + log.warn("image size {d} is larger than the maximum allowed ({d})", .{ size, maximum_image_size }); + return error.Overflow; + } + const destination = try alloc.alloc( u8, - width * height * @sizeOf(c.wuffs_base__color_u32_argb_premul), + size, ); errdefer alloc.free(destination); @@ -131,3 +144,8 @@ test "jpeg_decode_FFFFFF" { try std.testing.expectEqual(1, data.height); try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data); } + +test "jpeg: too big" { + const data = decode(std.testing.allocator, @embedFile("too_big.jpg")); + try std.testing.expectError(error.Overflow, data); +} diff --git a/pkg/wuffs/src/main.zig b/pkg/wuffs/src/main.zig index 89f3c008c..207d83f9a 100644 --- a/pkg/wuffs/src/main.zig +++ b/pkg/wuffs/src/main.zig @@ -5,6 +5,10 @@ pub const jpeg = @import("jpeg.zig"); pub const swizzle = @import("swizzle.zig"); pub const Error = @import("error.zig").Error; +/// The maximum image size, based on the 4G limit of Ghostty's +/// `image-storage-limit` config. +pub const maximum_image_size = 4 * 1024 * 1024 * 1024; + pub const ImageData = struct { width: u32, height: u32, diff --git a/pkg/wuffs/src/png.zig b/pkg/wuffs/src/png.zig index d79ae5b56..57a0e63bb 100644 --- a/pkg/wuffs/src/png.zig +++ b/pkg/wuffs/src/png.zig @@ -4,6 +4,8 @@ const c = @import("c.zig").c; const Error = @import("error.zig").Error; const check = @import("error.zig").check; const ImageData = @import("main.zig").ImageData; +const maximum_image_size = @import("main.zig").maximum_image_size; +const mul = std.math.mul; const log = std.log.scoped(.wuffs_png); @@ -61,9 +63,20 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { height, ); + const size: usize = try mul( + usize, + try mul(usize, width, height), + @sizeOf(c.wuffs_base__color_u32_argb_premul), + ); + + if (size > maximum_image_size) { + log.warn("image size {d} is larger than the maximum allowed ({d})", .{ size, maximum_image_size }); + return error.Overflow; + } + const destination = try alloc.alloc( u8, - width * height * @sizeOf(c.wuffs_base__color_u32_argb_premul), + size, ); errdefer alloc.free(destination); @@ -131,3 +144,8 @@ test "png_decode_FFFFFF" { try std.testing.expectEqual(1, data.height); try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data); } + +test "png: too big" { + const data = decode(std.testing.allocator, @embedFile("too_big.png")); + try std.testing.expectError(error.Overflow, data); +} diff --git a/pkg/wuffs/src/too_big.jpg b/pkg/wuffs/src/too_big.jpg new file mode 100644 index 000000000..d7ebf7dbb Binary files /dev/null and b/pkg/wuffs/src/too_big.jpg differ diff --git a/pkg/wuffs/src/too_big.png b/pkg/wuffs/src/too_big.png new file mode 100644 index 000000000..86b134a0d Binary files /dev/null and b/pkg/wuffs/src/too_big.png differ diff --git a/po/README_CONTRIBUTORS.md b/po/README_CONTRIBUTORS.md index 2c405acf3..e232c0620 100644 --- a/po/README_CONTRIBUTORS.md +++ b/po/README_CONTRIBUTORS.md @@ -9,7 +9,7 @@ for any localization that they may add. ## GTK -In the GTK app runtime, translable strings are mainly sourced from Blueprint +In the GTK app runtime, translatable strings are mainly sourced from Blueprint files (located under `src/apprt/gtk/ui`). Blueprints have a native syntax for translatable strings, which look like this: diff --git a/po/README_TRANSLATORS.md b/po/README_TRANSLATORS.md index 582d5037c..25b7cab5b 100644 --- a/po/README_TRANSLATORS.md +++ b/po/README_TRANSLATORS.md @@ -44,9 +44,9 @@ intended to be regenerated by code contributors. If there is a problem with the template file, please reach out to a code contributor. Instead, only edit the translation file corresponding to your language/locale, -identified via the its _locale name_: for example, `de_DE.UTF-8.po` would be -the translation file for German (language code `de`) as spoken in Germany -(country code `DE`). The GNU `gettext` manual contains +identified via its _locale name_: for example, `de_DE.UTF-8.po` would be the +translation file for German (language code `de`) as spoken in Germany (country +code `DE`). The GNU `gettext` manual contains [further information about locale names](https://www.gnu.org/software/gettext/manual/gettext.html#Locale-Names-1), including a list of language and country codes. diff --git a/po/lt_LT.UTF-8.po b/po/lt_LT.UTF-8.po new file mode 100644 index 000000000..0c466d3a4 --- /dev/null +++ b/po/lt_LT.UTF-8.po @@ -0,0 +1,318 @@ +# Language LT translations for com.mitchellh.ghostty package. +# Copyright (C) 2025 "Mitchell Hashimoto, Ghostty contributors" +# This file is distributed under the same license as the com.mitchellh.ghostty package. +# Tadas Lotuzas , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: com.mitchellh.ghostty\n" +"Report-Msgid-Bugs-To: m@mitchellh.com\n" +"POT-Creation-Date: 2025-07-22 17:18+0000\n" +"PO-Revision-Date: 2025-09-17 13:27+0200\n" +"Last-Translator: Tadas Lotuzas \n" +"Language-Team: Language LT\n" +"Language: LT\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5 +msgid "Change Terminal Title" +msgstr "Keisti terminalo pavadinimą" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6 +msgid "Leave blank to restore the default title." +msgstr "Palikite tuščią, kad atkurtumėte numatytąjį pavadinimą." + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/ui/1.2/ccw-paste.blp:10 +#: src/apprt/gtk/CloseDialog.zig:44 +msgid "Cancel" +msgstr "Atšaukti" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:10 +msgid "OK" +msgstr "Gerai" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:5 +msgid "Configuration Errors" +msgstr "Konfigūracijos klaidos" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6 +msgid "" +"One or more configuration errors were found. Please review the errors below, " +"and either reload your configuration or ignore these errors." +msgstr "" +"Rasta viena ar daugiau konfigūracijos klaidų. Peržiūrėkite žemiau esančias klaidas " +"ir arba iš naujo įkelkite konfigūraciją, arba ignoruokite šias klaidas." + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:9 +msgid "Ignore" +msgstr "Ignoruoti" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +msgid "Reload Configuration" +msgstr "Iš naujo įkelti konfigūraciją" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:6 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:38 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:50 +msgid "Split Up" +msgstr "Padalinti aukštyn" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:11 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:43 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:55 +msgid "Split Down" +msgstr "Padalinti žemyn" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:16 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:48 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:60 +msgid "Split Left" +msgstr "Padalinti kairėn" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:21 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:53 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:65 +msgid "Split Right" +msgstr "Padalinti dešinėn" + +#: src/apprt/gtk/ui/1.5/command-palette.blp:16 +msgid "Execute a command…" +msgstr "Vykdyti komandą…" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 +msgid "Copy" +msgstr "Kopijuoti" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:11 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 src/apprt/gtk/ui/1.2/ccw-paste.blp:11 +msgid "Paste" +msgstr "Įklijuoti" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:18 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:73 +msgid "Clear" +msgstr "Išvalyti" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:23 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:78 +msgid "Reset" +msgstr "Atstatyti" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:30 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:42 +msgid "Split" +msgstr "Padalinti" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:33 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:45 +msgid "Change Title…" +msgstr "Keisti pavadinimą…" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:59 +msgid "Tab" +msgstr "Kortelė" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 +#: src/apprt/gtk/Window.zig:265 +msgid "New Tab" +msgstr "Nauja kortelė" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:67 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:35 +msgid "Close Tab" +msgstr "Uždaryti kortelę" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:73 +msgid "Window" +msgstr "Langas" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:76 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:18 +msgid "New Window" +msgstr "Naujas langas" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:81 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:23 +msgid "Close Window" +msgstr "Uždaryti langą" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:89 +msgid "Config" +msgstr "Konfigūracija" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 +msgid "Open Configuration" +msgstr "Atidaryti konfigūraciją" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 +msgid "Command Palette" +msgstr "Komandų paletė" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 +msgid "Terminal Inspector" +msgstr "Terminalo inspektorius" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/Window.zig:1038 +msgid "About Ghostty" +msgstr "Apie Ghostty" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112 +msgid "Quit" +msgstr "Išeiti" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:6 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:6 +msgid "Authorize Clipboard Access" +msgstr "Leisti prieigą prie iškarpinės" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:7 +msgid "" +"An application is attempting to read from the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Programa bando skaityti iš iškarpinės. Žemiau rodomas dabartinis " +"iškarpinės turinys." + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:10 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:10 +msgid "Deny" +msgstr "Drausti" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:11 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:11 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:11 +msgid "Allow" +msgstr "Leisti" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:81 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:77 +msgid "Remember choice for this split" +msgstr "Prisiminti pasirinkimą šiam padalijimui" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 +msgid "Reload configuration to show this prompt again" +msgstr "Iš naujo įkelkite konfigūraciją, kad vėl būtų rodoma ši užuomina" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 +msgid "" +"An application is attempting to write to the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Programa bando rašyti į iškarpinę. Žemiau rodomas dabartinis " +"iškarpinės turinys." + +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 src/apprt/gtk/ui/1.2/ccw-paste.blp:6 +msgid "Warning: Potentially Unsafe Paste" +msgstr "Įspėjimas: galimai nesaugus įklijavimas" + +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 src/apprt/gtk/ui/1.2/ccw-paste.blp:7 +msgid "" +"Pasting this text into the terminal may be dangerous as it looks like some " +"commands may be executed." +msgstr "" +"Šio teksto įklijavimas į terminalą gali būti pavojingas, nes panašu, kad " +"gali būti vykdomos tam tikros komandos." + +#: src/apprt/gtk/CloseDialog.zig:47 src/apprt/gtk/Surface.zig:2531 +msgid "Close" +msgstr "Uždaryti" + +#: src/apprt/gtk/CloseDialog.zig:87 +msgid "Quit Ghostty?" +msgstr "Išeiti iš Ghostty?" + +#: src/apprt/gtk/CloseDialog.zig:88 +msgid "Close Window?" +msgstr "Uždaryti langą?" + +#: src/apprt/gtk/CloseDialog.zig:89 +msgid "Close Tab?" +msgstr "Uždaryti kortelę?" + +#: src/apprt/gtk/CloseDialog.zig:90 +msgid "Close Split?" +msgstr "Uždaryti padalijimą?" + +#: src/apprt/gtk/CloseDialog.zig:96 +msgid "All terminal sessions will be terminated." +msgstr "Visos terminalo sesijos bus nutrauktos." + +#: src/apprt/gtk/CloseDialog.zig:97 +msgid "All terminal sessions in this window will be terminated." +msgstr "Visos terminalo sesijos šiame lange bus nutrauktos." + +#: src/apprt/gtk/CloseDialog.zig:98 +msgid "All terminal sessions in this tab will be terminated." +msgstr "Visos terminalo sesijos šioje kortelėje bus nutrauktos." + +#: src/apprt/gtk/CloseDialog.zig:99 +msgid "The currently running process in this split will be terminated." +msgstr "Šiuo metu vykdomas procesas šiame padalijime bus nutrauktas." + +#: src/apprt/gtk/Surface.zig:1266 +msgid "Copied to clipboard" +msgstr "Nukopijuota į iškarpinę" + +#: src/apprt/gtk/Surface.zig:1268 +msgid "Cleared clipboard" +msgstr "Iškarpinė išvalyta" + +#: src/apprt/gtk/Surface.zig:2525 +msgid "Command succeeded" +msgstr "Komanda sėkminga" + +#: src/apprt/gtk/Surface.zig:2527 +msgid "Command failed" +msgstr "Komanda nepavyko" + +#: src/apprt/gtk/Window.zig:216 +msgid "Main Menu" +msgstr "Pagrindinis meniu" + +#: src/apprt/gtk/Window.zig:239 +msgid "View Open Tabs" +msgstr "Peržiūrėti atidarytas korteles" + +#: src/apprt/gtk/Window.zig:266 +msgid "New Split" +msgstr "Naujas padalijimas" + +#: src/apprt/gtk/Window.zig:329 +msgid "" +"⚠️ You're running a debug build of Ghostty! Performance will be degraded." +msgstr "" +"⚠️ Naudojate Ghostty derinimo versiją! Našumas bus sumažintas." + +#: src/apprt/gtk/Window.zig:775 +msgid "Reloaded the configuration" +msgstr "Konfigūracija įkelta iš naujo" + +#: src/apprt/gtk/Window.zig:1019 +msgid "Ghostty Developers" +msgstr "Ghostty kūrėjai" + +#: src/apprt/gtk/inspector.zig:144 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty: terminalo inspektorius" diff --git a/po/nb_NO.UTF-8.po b/po/nb_NO.UTF-8.po index 047736470..65940d9c7 100644 --- a/po/nb_NO.UTF-8.po +++ b/po/nb_NO.UTF-8.po @@ -90,7 +90,7 @@ msgstr "Del til høyre" #: src/apprt/gtk/ui/1.5/command-palette.blp:16 msgid "Execute a command…" -msgstr "Kjør en kommando..." +msgstr "Kjør en kommando…" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 271deeeb2..7bdbc9b48 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -52,7 +52,7 @@ parts: rm -rf $CRAFT_PART_SRC/* if [[ -n $arch ]]; then - curl -LO --retry-connrefused --retry 10 https://ziglang.org/download/0.15.1/zig-$arch-linux-0.15.1.tar.xz + curl -LO --retry-connrefused --retry 10 https://ziglang.org/download/0.15.2/zig-$arch-linux-0.15.2.tar.xz else echo "Unsupported arch" exit 1 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 018c4206b..96aaf84d8 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"); @@ -148,6 +145,12 @@ focused: bool = true, /// Used to determine whether to continuously scroll. selection_scroll_active: bool = false, +/// True if the surface is in read-only mode. When read-only, no input +/// is sent to the PTY but terminal-level operations like selections, +/// (native) scrolling, and copy keybinds still work. Warn before quit is +/// always enabled in this state. +readonly: bool = false, + /// Used to send notifications that long running commands have finished. /// Requires that shell integration be active. Should represent a nanosecond /// precision timestamp. It does not necessarily need to correspond to the @@ -155,6 +158,12 @@ 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, + +/// Used to rate limit BEL handling. +last_bell_time: ?std.time.Instant = 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 +183,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. @@ -186,7 +215,7 @@ const Mouse = struct { /// The point at which the left mouse click happened. This is in screen /// coordinates so that scrolling preserves the location. left_click_pin: ?*terminal.Pin = null, - left_click_screen: terminal.ScreenType = .primary, + left_click_screen: terminal.ScreenSet.Key = .primary, /// The starting xpos/ypos of the left click. Note that if scrolling occurs, /// these will point to different "cells", but the xpos/ypos will stay @@ -260,6 +289,7 @@ const DerivedConfig = struct { clipboard_trim_trailing_spaces: bool, clipboard_paste_protection: bool, clipboard_paste_bracketed_safe: bool, + clipboard_codepoint_map: configpkg.Config.RepeatableClipboardCodepointMap, copy_on_select: configpkg.CopyOnSelect, right_click_action: configpkg.RightClickAction, confirm_close_surface: configpkg.ConfirmCloseSurface, @@ -268,10 +298,11 @@ const DerivedConfig = struct { font: font.SharedGridSet.DerivedConfig, mouse_interval: u64, mouse_hide_while_typing: bool, + mouse_reporting: bool, mouse_scroll_multiplier: configpkg.MouseScrollMultiplier, mouse_shift_capture: configpkg.MouseShiftCapture, macos_non_native_fullscreen: configpkg.NonNativeFullscreen, - macos_option_as_alt: ?configpkg.OptionAsAlt, + macos_option_as_alt: ?input.OptionAsAlt, selection_clear_on_copy: bool, selection_clear_on_typing: bool, vt_kam_allowed: bool, @@ -333,6 +364,7 @@ const DerivedConfig = struct { .clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces", .clipboard_paste_protection = config.@"clipboard-paste-protection", .clipboard_paste_bracketed_safe = config.@"clipboard-paste-bracketed-safe", + .clipboard_codepoint_map = try config.@"clipboard-codepoint-map".clone(alloc), .copy_on_select = config.@"copy-on-select", .right_click_action = config.@"right-click-action", .confirm_close_surface = config.@"confirm-close-surface", @@ -341,6 +373,7 @@ const DerivedConfig = struct { .font = try font.SharedGridSet.DerivedConfig.init(alloc, config), .mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms .mouse_hide_while_typing = config.@"mouse-hide-while-typing", + .mouse_reporting = config.@"mouse-reporting", .mouse_scroll_multiplier = config.@"mouse-scroll-multiplier", .mouse_shift_capture = config.@"mouse-shift-capture", .macos_non_native_fullscreen = config.@"macos-non-native-fullscreen", @@ -574,6 +607,9 @@ pub fn init( }; errdefer env.deinit(); + // don't leak GHOSTTY_LOG to any subprocesses + env.remove("GHOSTTY_LOG"); + // Initialize our IO backend var io_exec = try termio.Exec.init(alloc, .{ .command = command, @@ -702,13 +738,31 @@ pub fn init( .set_title, .{ .title = title }, ); - } + } else if (command) |cmd| switch (cmd) { + // If a user specifies a command it is appropriate to set the title as argv[0] + // we know in the case of a direct command it has been supplied by the user + .direct => |cmd_str| if (cmd_str.len != 0) { + _ = try rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = cmd_str[0] }, + ); + }, + + // We won't set the title in the case the shell expands the command + // as that should typically be used to launch a shell which should + // set its own titles + .shell => {}, + }; // We are no longer the first surface app.first = false; } 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| @@ -759,6 +813,38 @@ 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 }, + }; +} + +/// Queue a message for the IO thread. +/// +/// We centralize all our logic into this spot so we can intercept +/// messages for example in readonly mode. +fn queueIo( + self: *Surface, + msg: termio.Message, + mutex: termio.Termio.MutexState, +) void { + // In readonly mode, we don't allow any writes through to the pty. + if (self.readonly) { + switch (msg) { + .write_small, + .write_stable, + .write_alloc, + => return, + + else => {}, + } + } + + self.io.queueMessage(msg, mutex); +} + /// 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. @@ -790,7 +876,7 @@ pub fn activateInspector(self: *Surface) !void { // Notify our components we have an inspector active _ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} }); - self.io.queueMessage(.{ .inspector = true }, .unlocked); + self.queueIo(.{ .inspector = true }, .unlocked); } /// Deactivate the inspector and stop collecting any information. @@ -807,7 +893,7 @@ pub fn deactivateInspector(self: *Surface) void { // Notify our components we have deactivated inspector _ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} }); - self.io.queueMessage(.{ .inspector = false }, .unlocked); + self.queueIo(.{ .inspector = false }, .unlocked); // Deinit the inspector insp.deinit(); @@ -818,6 +904,9 @@ pub fn deactivateInspector(self: *Surface) void { /// True if the surface requires confirmation to quit. This should be called /// by apprt to determine if the surface should confirm before quitting. pub fn needsConfirmQuit(self: *Surface) bool { + // If the surface is in read-only mode, always require confirmation + if (self.readonly) return true; + // If the child has exited, then our process is certainly not alive. // We check this first to avoid the locking overhead below. if (self.child_exited) return false; @@ -876,7 +965,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { // We always use an allocating message because we don't know // the length of the title and this isn't a performance critical // path. - self.io.queueMessage(.{ + self.queueIo(.{ .write_alloc = .{ .alloc = self.alloc, .data = data, @@ -968,13 +1057,20 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .renderer_health => |health| self.updateRendererHealth(health), + .scrollbar => |scrollbar| self.updateScrollbar(scrollbar), + .report_color_scheme => |force| self.reportColorScheme(force), .present_surface => try self.presentSurface(), .password_input => |v| try self.passwordInput(v), - .ring_bell => { + .ring_bell => bell: { + const now = std.time.Instant.now() catch unreachable; + if (self.last_bell_time) |last| { + if (now.since(last) < 100 * std.time.ns_per_ms) break :bell; + } + self.last_bell_time = now; _ = self.rt_app.performAction( .{ .surface = self }, .ring_bell, @@ -1022,6 +1118,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 }, + ); + }, } } @@ -1042,11 +1154,21 @@ fn selectionScrollTick(self: *Surface) !void { defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; + // If our screen changed while this is happening, we stop our + // selection scroll. + if (self.mouse.left_click_screen != t.screens.active_key) { + self.queueIo( + .{ .selection_scroll = false }, + .locked, + ); + return; + } + // Scroll the viewport as required try t.scrollViewport(.{ .delta = delta }); // Next, trigger our drag behavior - const pin = t.screen.pages.pin(.{ + const pin = t.screens.active.pages.pin(.{ .viewport = .{ .x = pos_vp.x, .y = pos_vp.y, @@ -1130,7 +1252,7 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { // so that we can close the terminal. We close the terminal on // any key press that encodes a character. t.modes.set(.disable_keyboard, false); - t.screen.kitty_keyboard.set(.set, .{}); + t.screens.active.kitty_keyboard.set(.set, .disabled); } // Waiting after command we stop here. The terminal is updated, our @@ -1267,7 +1389,119 @@ fn reportColorScheme(self: *Surface, force: bool) void { .dark => "\x1B[?997;1n", }; - self.io.queueMessage(.{ .write_stable = output }, .unlocked); + self.queueIo(.{ .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 @@ -1341,7 +1575,7 @@ fn mouseRefreshLinks( const left_idx = @intFromEnum(input.MouseButton.left); if (self.mouse.click_state[left_idx] == .press) click: { const pin = self.mouse.left_click_pin orelse break :click; - const click_pt = self.io.terminal.screen.pages.pointFromPin( + const click_pt = self.io.terminal.screens.active.pages.pointFromPin( .viewport, pin.*, ) orelse break :click; @@ -1355,7 +1589,7 @@ fn mouseRefreshLinks( const link = (try self.linkAtPos(pos)) orelse break :link .{ null, false }; switch (link[0]) { .open => { - const str = try self.io.terminal.screen.selectionString(alloc, .{ + const str = try self.io.terminal.screens.active.selectionString(alloc, .{ .sel = link[1], .trim = false, }); @@ -1385,7 +1619,7 @@ fn mouseRefreshLinks( if (link_) |link| { self.renderer_state.mouse.point = pos_vp; self.mouse.over_link = true; - self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; + self.renderer_state.terminal.screens.active.dirty.hyperlink_hover = true; _ = try self.rt_app.performAction( .{ .surface = self }, .mouse_shape, @@ -1434,6 +1668,17 @@ fn updateRendererHealth(self: *Surface, health: rendererpkg.Health) void { }; } +/// Called when the scrollbar state changes. +fn updateScrollbar(self: *Surface, scrollbar: terminal.Scrollbar) void { + _ = self.rt_app.performAction( + .{ .surface = self }, + .scrollbar, + scrollbar, + ) catch |err| { + log.warn("failed to notify app of scrollbar change err={}", .{err}); + }; +} + /// This should be called anytime `config_conditional_state` changes /// so that the apprt can reload the configuration. fn notifyConfigConditionalState(self: *Surface) void { @@ -1517,7 +1762,7 @@ pub fn updateConfig( errdefer termio_config_ptr.deinit(); _ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} }); - self.io.queueMessage(.{ + self.queueIo(.{ .change_config = .{ .alloc = self.alloc, .ptr = termio_config_ptr, @@ -1650,7 +1895,7 @@ pub fn dumpTextLocked( sel: terminal.Selection, ) !Text { // Read out the text - const text = try self.io.terminal.screen.selectionString(alloc, .{ + const text = try self.io.terminal.screens.active.selectionString(alloc, .{ .sel = sel, .trim = false, }); @@ -1660,19 +1905,19 @@ pub fn dumpTextLocked( const vp: ?Text.Viewport = viewport: { // If our bottom right pin is before the viewport, then we can't // possibly have this text be within the viewport. - const vp_tl_pin = self.io.terminal.screen.pages.getTopLeft(.viewport); - const br_pin = sel.bottomRight(&self.io.terminal.screen); + const vp_tl_pin = self.io.terminal.screens.active.pages.getTopLeft(.viewport); + const br_pin = sel.bottomRight(self.io.terminal.screens.active); if (br_pin.before(vp_tl_pin)) break :viewport null; // If our top-left pin is after the viewport, then we can't possibly // have this text be within the viewport. - const vp_br_pin = self.io.terminal.screen.pages.getBottomRight(.viewport) orelse { + const vp_br_pin = self.io.terminal.screens.active.pages.getBottomRight(.viewport) orelse { // I don't think this is possible but I don't want to crash on // that assertion so let's just break out... log.warn("viewport bottom-right pin not found, bug?", .{}); break :viewport null; }; - const tl_pin = sel.topLeft(&self.io.terminal.screen); + const tl_pin = sel.topLeft(self.io.terminal.screens.active); if (vp_br_pin.before(tl_pin)) break :viewport null; // We established that our top-left somewhere before the viewport @@ -1682,7 +1927,7 @@ pub fn dumpTextLocked( // Our top-left point. If it doesn't exist in the viewport it must // be before and we can return (0,0). - const tl_pt: terminal.Point = self.io.terminal.screen.pages.pointFromPin( + const tl_pt: terminal.Point = self.io.terminal.screens.active.pages.pointFromPin( .viewport, tl_pin, ) orelse tl: { @@ -1695,7 +1940,7 @@ pub fn dumpTextLocked( // Our bottom-right point. If it doesn't exist in the viewport // it must be the bottom-right of the viewport. - const br_pt = self.io.terminal.screen.pages.pointFromPin( + const br_pt = self.io.terminal.screens.active.pages.pointFromPin( .viewport, br_pin, ) orelse br: { @@ -1703,7 +1948,7 @@ pub fn dumpTextLocked( assert(vp_br_pin.before(br_pin)); } - break :br self.io.terminal.screen.pages.pointFromPin( + break :br self.io.terminal.screens.active.pages.pointFromPin( .viewport, vp_br_pin, ).?; @@ -1744,8 +1989,8 @@ pub fn dumpTextLocked( }; // Utilize viewport sizing to convert to offsets - const start = tl_coord.y * self.io.terminal.screen.pages.cols + tl_coord.x; - const end = br_coord.y * self.io.terminal.screen.pages.cols + br_coord.x; + const start = tl_coord.y * self.io.terminal.screens.active.pages.cols + tl_coord.x; + const end = br_coord.y * self.io.terminal.screens.active.pages.cols + br_coord.x; break :viewport .{ .tl_px_x = x, @@ -1765,15 +2010,15 @@ pub fn dumpTextLocked( pub fn hasSelection(self: *const Surface) bool { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - return self.io.terminal.screen.selection != null; + return self.io.terminal.screens.active.selection != null; } /// Returns the selected text. This is allocated. pub fn selectionString(self: *Surface, alloc: Allocator) !?[:0]const u8 { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - const sel = self.io.terminal.screen.selection orelse return null; - return try self.io.terminal.screen.selectionString(alloc, .{ + const sel = self.io.terminal.screens.active.selection orelse return null; + return try self.io.terminal.screens.active.selectionString(alloc, .{ .sel = sel, .trim = false, }); @@ -1796,7 +2041,7 @@ pub fn pwd( /// keyboard should be rendered. pub fn imePoint(self: *const Surface) apprt.IMEPos { self.renderer_state.mutex.lock(); - const cursor = self.renderer_state.terminal.screen.cursor; + const cursor = self.renderer_state.terminal.screens.active.cursor; const preedit_width: usize = if (self.renderer_state.preedit) |preedit| preedit.width() else 0; self.renderer_state.mutex.unlock(); @@ -1905,7 +2150,10 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) // them to confirm the clipboard access. Each app runtime handles this // differently. const confirm = self.config.clipboard_write == .ask; - self.rt_surface.setClipboardString(buf, loc, confirm) catch |err| { + self.rt_surface.setClipboard(loc, &.{.{ + .mime = "text/plain", + .data = buf, + }}, confirm) catch |err| { log.err("error setting clipboard string err={}", .{err}); return; }; @@ -1915,19 +2163,112 @@ fn copySelectionToClipboards( self: *Surface, sel: terminal.Selection, clipboards: []const apprt.Clipboard, -) void { - const buf = self.io.terminal.screen.selectionString(self.alloc, .{ - .sel = sel, - .trim = self.config.clipboard_trim_trailing_spaces, - }) catch |err| { - log.err("error reading selection string err={}", .{err}); - return; - }; - defer self.alloc.free(buf); + format: input.Binding.Action.CopyToClipboard, +) !void { + // Create an arena to simplify memory management here. + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const alloc = arena.allocator(); - for (clipboards) |clipboard| self.rt_surface.setClipboardString( - buf, + // The options we'll use for all formatting. We'll just override the + // emit format. + const opts: terminal.formatter.Options = .{ + .emit = .plain, // We'll override this below + .unwrap = true, + .trim = self.config.clipboard_trim_trailing_spaces, + .codepoint_map = self.config.clipboard_codepoint_map.map.list, + .background = self.io.terminal.colors.background.get(), + .foreground = self.io.terminal.colors.foreground.get(), + .palette = &self.io.terminal.colors.palette.current, + }; + + const ScreenFormatter = terminal.formatter.ScreenFormatter; + var aw: std.Io.Writer.Allocating = .init(alloc); + var contents: std.ArrayList(apprt.ClipboardContent) = .empty; + switch (format) { + .plain => { + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + try contents.append(alloc, .{ + .mime = "text/plain", + .data = try aw.toOwnedSliceSentinel(0), + }); + }, + + .vt => { + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts: { + var copy = opts; + copy.emit = .vt; + break :opts copy; + }); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + + // Note: We don't apply codepoint mappings to VT format since it contains + // escape sequences that should be preserved as-is + try contents.append(alloc, .{ + .mime = "text/plain", + .data = try aw.toOwnedSliceSentinel(0), + }); + }, + + .html => { + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts: { + var copy = opts; + copy.emit = .html; + break :opts copy; + }); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + + // Note: We don't apply codepoint mappings to HTML format since HTML + // has its own character encoding and entity system + try contents.append(alloc, .{ + .mime = "text/html", + .data = try aw.toOwnedSliceSentinel(0), + }); + }, + + .mixed => { + // First, generate plain text with codepoint mappings applied + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + try contents.append(alloc, .{ + .mime = "text/plain", + .data = try aw.toOwnedSliceSentinel(0), + }); + + assert(aw.written().len == 0); + // Second, generate HTML without codepoint mappings + formatter = .init(self.io.terminal.screens.active, opts: { + var copy = opts; + copy.emit = .html; + + // We purposely don't emit background/foreground for mixed + // mode because the HTML contents is often used for rich text + // input and with trimmed spaces it looks pretty bad. + copy.background = null; + copy.foreground = null; + + break :opts copy; + }); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + + // Note: We don't apply codepoint mappings to HTML format + try contents.append(alloc, .{ + .mime = "text/html", + .data = try aw.toOwnedSliceSentinel(0), + }); + }, + } + + assert(contents.items.len > 0); + for (clipboards) |clipboard| self.rt_surface.setClipboard( clipboard, + contents.items, false, ) catch |err| { log.err( @@ -1941,8 +2282,8 @@ fn copySelectionToClipboards( /// /// This must be called with the renderer mutex held. fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { - const prev_ = self.io.terminal.screen.selection; - try self.io.terminal.screen.select(sel_); + const prev_ = self.io.terminal.screens.active.selection; + try self.io.terminal.screens.active.select(sel_); // If copy on select is false then exit early. if (self.config.copy_on_select == .false) return; @@ -1958,9 +2299,11 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { .false => unreachable, // handled above with an early exit // Both standard and selection clipboards are set. - .clipboard => { - self.copySelectionToClipboards(sel, &.{ .standard, .selection }); - }, + .clipboard => try self.copySelectionToClipboards( + sel, + &.{ .standard, .selection }, + .mixed, + ), // The selection clipboard is set if supported, otherwise the standard. .true => { @@ -1968,7 +2311,11 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { .selection else .standard; - self.copySelectionToClipboards(sel, &.{clipboard}); + try self.copySelectionToClipboards( + sel, + &.{clipboard}, + .mixed, + ); }, } } @@ -1981,7 +2328,7 @@ fn setCellSize(self: *Surface, size: rendererpkg.CellSize) !void { self.balancePaddingIfNeeded(); // Notify the terminal - self.io.queueMessage(.{ .resize = self.size }, .unlocked); + self.queueIo(.{ .resize = self.size }, .unlocked); // Update our terminal default size if necessary. self.recomputeInitialSize() catch |err| { @@ -2084,7 +2431,7 @@ fn resize(self: *Surface, size: rendererpkg.ScreenSize) !void { } // Mail the IO thread - self.io.queueMessage(.{ .resize = self.size }, .unlocked); + self.queueIo(.{ .resize = self.size }, .unlocked); } /// Recalculate the balanced padding if needed. @@ -2281,6 +2628,8 @@ pub fn keyCallback( { // Refresh our link state const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); self.mouseRefreshLinks( pos, self.posToViewport(pos.x, pos.y), @@ -2358,7 +2707,7 @@ pub fn keyCallback( } errdefer write_req.deinit(); - self.io.queueMessage(switch (write_req) { + self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, .alloc => |v| .{ .write_alloc = v }, @@ -2587,7 +2936,7 @@ fn endKeySequence( if (self.keyboard.queued.items.len > 0) { switch (action) { .flush => for (self.keyboard.queued.items) |write_req| { - self.io.queueMessage(switch (write_req) { + self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, .alloc => |v| .{ .write_alloc = v }, @@ -2611,56 +2960,32 @@ fn encodeKey( event: input.KeyEvent, insp_ev: ?*inspectorpkg.key.Event, ) !?termio.Message.WriteReq { - // Build up our encoder. Under different modes and - // inputs there are many keybindings that result in no encoding - // whatsoever. - const enc: input.KeyEncoder = enc: { - const option_as_alt: configpkg.OptionAsAlt = self.config.macos_option_as_alt orelse detect: { - // Non-macOS doesn't use this value so ignore. - if (comptime builtin.os.tag != .macos) break :detect .false; - - // If we don't have alt pressed, it doesn't matter what this - // config is so we can just say "false" and break out and avoid - // more expensive checks below. - if (!event.mods.alt) break :detect .false; - - // Alt is pressed, we're on macOS. We break some encapsulation - // here and assume libghostty for ease... - break :detect self.rt_app.keyboardLayout().detectOptionAsAlt(); - }; - - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - const t = &self.io.terminal; - break :enc .{ - .event = event, - .macos_option_as_alt = option_as_alt, - .alt_esc_prefix = t.modes.get(.alt_esc_prefix), - .cursor_key_application = t.modes.get(.cursor_keys), - .keypad_key_application = t.modes.get(.keypad_keys), - .ignore_keypad_with_numlock = t.modes.get(.ignore_keypad_with_numlock), - .modify_other_keys_state_2 = t.flags.modify_other_keys_2, - .kitty_flags = t.screen.kitty_keyboard.current(), - }; - }; - const write_req: termio.Message.WriteReq = req: { + // Build our encoding options, which requires the lock. + const encoding_opts = self.encodeKeyOpts(); + // Try to write the input into a small array. This fits almost // every scenario. Larger situations can happen due to long // pre-edits. var data: termio.Message.WriteReq.Small.Array = undefined; - if (enc.encode(&data)) |seq| { + var writer: std.Io.Writer = .fixed(&data); + if (input.key_encode.encode( + &writer, + event, + encoding_opts, + )) { + const written = writer.buffered(); + // Special-case: we did nothing. - if (seq.len == 0) return null; + if (written.len == 0) return null; break :req .{ .small = .{ .data = data, - .len = @intCast(seq.len), + .len = @intCast(written.len), } }; } else |err| switch (err) { // Means we need to allocate - error.OutOfMemory => {}, - else => return err, + error.WriteFailed => {}, } // We need to allocate. We allocate double the UTF-8 length @@ -2669,16 +2994,23 @@ fn encodeKey( // typing this where we don't have enough space is a long preedit, // and in that case the size we need is exactly the UTF-8 length, // so the double is being safe. - const buf = try self.alloc.alloc(u8, @max( - event.utf8.len * 2, - data.len * 2, - )); - defer self.alloc.free(buf); + var alloc_writer: std.Io.Writer.Allocating = try .initCapacity( + self.alloc, + @max(event.utf8.len * 2, data.len * 2), + ); + defer alloc_writer.deinit(); // This results in a double allocation but this is such an unlikely // path the performance impact is unimportant. - const seq = try enc.encode(buf); - break :req try termio.Message.WriteReq.init(self.alloc, seq); + try input.key_encode.encode( + &alloc_writer.writer, + event, + encoding_opts, + ); + break :req try termio.Message.WriteReq.init( + self.alloc, + alloc_writer.writer.buffered(), + ); }; // Copy the encoded data into the inspector event if we have one. @@ -2698,6 +3030,28 @@ fn encodeKey( return write_req; } +fn encodeKeyOpts(self: *const Surface) input.key_encode.Options { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t = &self.io.terminal; + + var opts: input.key_encode.Options = .fromTerminal(t); + if (comptime builtin.os.tag != .macos) return opts; + + opts.macos_option_as_alt = self.config.macos_option_as_alt orelse detect: { + // If we don't have alt pressed, it doesn't matter what this + // config is so we can just say "false" and break out and avoid + // more expensive checks below. + if (!self.mouse.mods.alt) break :detect .false; + + // Alt is pressed, we're on macOS. We break some encapsulation + // here and assume libghostty for ease... + break :detect self.rt_app.keyboardLayout().detectOptionAsAlt(); + }; + + return opts; +} + /// Sends text as-is to the terminal without triggering any keyboard /// protocol. This will treat the input text as if it was pasted /// from the clipboard so the same logic will be applied. Namely, @@ -2808,7 +3162,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { self.renderer_state.mutex.lock(); self.io.terminal.flags.focused = focused; self.renderer_state.mutex.unlock(); - self.io.queueMessage(.{ .focused = focused }, .unlocked); + self.queueIo(.{ .focused = focused }, .unlocked); } } @@ -2941,7 +3295,7 @@ pub fn scrollCallback( // If we have an active mouse reporting mode, clear the selection. // The selection can occur if the user uses the shift mod key to // override mouse grabbing from the window. - if (self.io.terminal.flags.mouse_event != .none) { + if (self.isMouseReporting()) { try self.setSelection(null); } @@ -2949,7 +3303,7 @@ pub fn scrollCallback( // we convert to cursor keys. This only happens if we're: // (1) alt screen (2) no explicit mouse reporting and (3) alt // scroll mode enabled. - if (self.io.terminal.active_screen == .alternate and + if (self.io.terminal.screens.active_key == .alternate and self.io.terminal.flags.mouse_event == .none and self.io.terminal.modes.get(.mouse_alternate_scroll)) { @@ -2972,7 +3326,7 @@ pub fn scrollCallback( }; }; for (0..y.magnitude()) |_| { - self.io.queueMessage(.{ .write_stable = seq }, .locked); + self.queueIo(.{ .write_stable = seq }, .locked); } } @@ -2984,7 +3338,7 @@ pub fn scrollCallback( // the normal logic. // If we're scrolling up or down, then send a mouse event. - if (self.io.terminal.flags.mouse_event != .none) { + if (self.isMouseReporting()) { for (0..@abs(y.delta)) |_| { const pos = try self.rt_surface.getCursorPos(); try self.mouseReport(switch (y.direction()) { @@ -3057,6 +3411,13 @@ pub fn contentScaleCallback(self: *Surface, content_scale: apprt.ContentScale) ! /// The type of action to report for a mouse event. const MouseReportAction = enum { press, release, motion }; +/// Returns true if mouse reporting is enabled both in the config and +/// the terminal state. +fn isMouseReporting(self: *const Surface) bool { + return self.config.mouse_reporting and + self.io.terminal.flags.mouse_event != .none; +} + fn mouseReport( self: *Surface, button: ?input.MouseButton, @@ -3064,9 +3425,13 @@ fn mouseReport( mods: input.Mods, pos: apprt.CursorPos, ) !void { + // Mouse reporting must be enabled by both config and terminal state + assert(self.config.mouse_reporting); + assert(self.io.terminal.flags.mouse_event != .none); + // Depending on the event, we may do nothing at all. switch (self.io.terminal.flags.mouse_event) { - .none => return, + .none => unreachable, // checked by assert above // X10 only reports clicks with mouse button 1, 2, 3. We verify // the button later. @@ -3145,6 +3510,8 @@ fn mouseReport( .five => 65, .six => 66, .seven => 67, + .eight => 128, + .nine => 129, else => return, // unsupported }; } @@ -3180,7 +3547,7 @@ fn mouseReport( data[5] = 32 + @as(u8, @intCast(viewport_point.y)) + 1; // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = 6, } }, .locked); @@ -3203,7 +3570,7 @@ fn mouseReport( i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.y + 1), data[i..]); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(i), } }, .locked); @@ -3224,7 +3591,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3241,7 +3608,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3270,7 +3637,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3346,7 +3713,7 @@ pub fn mouseButtonCallback( { const pos = try self.rt_surface.getCursorPos(); const point = self.posToViewport(pos.x, pos.y); - const screen = &self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; const p = screen.pages.pin(.{ .viewport = point }) orelse { log.warn("failed to get pin for clicked point", .{}); return false; @@ -3422,7 +3789,7 @@ pub fn mouseButtonCallback( // Stop selection scrolling when releasing the left mouse button // but only when selection scrolling is active. if (self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .unlocked, ); @@ -3434,7 +3801,7 @@ pub fn mouseButtonCallback( if (self.config.copy_on_select != .false) { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - const prev_ = self.io.terminal.screen.selection; + const prev_ = self.io.terminal.screens.active.selection; if (prev_) |prev| { try self.setSelection(terminal.Selection.init( prev.start(), @@ -3461,7 +3828,7 @@ pub fn mouseButtonCallback( { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - if (self.io.terminal.flags.mouse_event != .none) report: { + if (self.isMouseReporting()) report: { // If we have shift-pressed and we aren't allowed to capture it, // then we do not do a mouse report. if (mods.shift and !shift_capture) break :report; @@ -3506,7 +3873,7 @@ pub fn mouseButtonCallback( // If we have a selection then we do not do click to move because // it means that we moved our cursor while pressing the mouse button. - if (self.io.terminal.screen.selection != null) break :click_move; + if (self.io.terminal.screens.active.selection != null) break :click_move; // Moving always resets the click count so that we don't highlight. self.mouse.left_click_count = 0; @@ -3521,7 +3888,7 @@ pub fn mouseButtonCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; - const screen = &self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; const pos = try self.rt_surface.getCursorPos(); const pin = pin: { @@ -3557,14 +3924,15 @@ pub fn mouseButtonCallback( } if (self.mouse.left_click_pin) |prev| { - const pin_screen = t.getScreen(self.mouse.left_click_screen); - pin_screen.pages.untrackPin(prev); + if (t.screens.get(self.mouse.left_click_screen)) |pin_screen| { + pin_screen.pages.untrackPin(prev); + } self.mouse.left_click_pin = null; } // Store it self.mouse.left_click_pin = pin; - self.mouse.left_click_screen = t.active_screen; + self.mouse.left_click_screen = t.screens.active_key; self.mouse.left_click_xpos = pos.x; self.mouse.left_click_ypos = pos.y; @@ -3597,17 +3965,17 @@ pub fn mouseButtonCallback( // Single click 1 => { // If we have a selection, clear it. This always happens. - if (self.io.terminal.screen.selection != null) { - try self.io.terminal.screen.select(null); + if (self.io.terminal.screens.active.selection != null) { + try self.io.terminal.screens.active.select(null); try self.queueRender(); } }, // Double click, select the word under our mouse 2 => { - const sel_ = self.io.terminal.screen.selectWord(pin.*); + const sel_ = self.io.terminal.screens.active.selectWord(pin.*); if (sel_) |sel| { - try self.io.terminal.screen.select(sel); + try self.io.terminal.screens.active.select(sel); try self.queueRender(); } }, @@ -3615,11 +3983,11 @@ pub fn mouseButtonCallback( // Triple click, select the line under our mouse 3 => { const sel_ = if (mods.ctrlOrSuper()) - self.io.terminal.screen.selectOutput(pin.*) + self.io.terminal.screens.active.selectOutput(pin.*) else - self.io.terminal.screen.selectLine(.{ .pin = pin.* }); + self.io.terminal.screens.active.selectLine(.{ .pin = pin.* }); if (sel_) |sel| { - try self.io.terminal.screen.select(sel); + try self.io.terminal.screens.active.select(sel); try self.queueRender(); } }, @@ -3648,7 +4016,7 @@ pub fn mouseButtonCallback( defer self.renderer_state.mutex.unlock(); // Get our viewport pin - const screen = &self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; const pin = pin: { const pos = try self.rt_surface.getCursorPos(); const pt_viewport = self.posToViewport(pos.x, pos.y); @@ -3674,7 +4042,7 @@ pub fn mouseButtonCallback( .@"context-menu" => { // If we already have a selection and the selection contains // where we clicked then we don't want to modify the selection. - if (self.io.terminal.screen.selection) |prev_sel| { + if (self.io.terminal.screens.active.selection) |prev_sel| { if (prev_sel.contains(screen, pin)) break :sel; // The selection doesn't contain our pin, so we create a new @@ -3689,15 +4057,23 @@ pub fn mouseButtonCallback( return false; }, .copy => { - if (self.io.terminal.screen.selection) |sel| { - self.copySelectionToClipboards(sel, &.{.standard}); + if (self.io.terminal.screens.active.selection) |sel| { + try self.copySelectionToClipboards( + sel, + &.{.standard}, + .mixed, + ); } try self.setSelection(null); try self.queueRender(); }, - .@"copy-or-paste" => if (self.io.terminal.screen.selection) |sel| { - self.copySelectionToClipboards(sel, &.{.standard}); + .@"copy-or-paste" => if (self.io.terminal.screens.active.selection) |sel| { + try self.copySelectionToClipboards( + sel, + &.{.standard}, + .mixed, + ); try self.setSelection(null); try self.queueRender(); } else { @@ -3743,7 +4119,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { // Click to move cursor only works on the primary screen where prompts // exist. This means that alt screen multiplexers like tmux will not // support this feature. It is just too messy. - if (t.active_screen != .primary) return; + if (t.screens.active_key != .primary) return; // This flag is only set if we've seen at least one semantic prompt // OSC sequence. If we've never seen that sequence, we can't possibly @@ -3751,8 +4127,8 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { if (!t.flags.shell_redraws_prompt) return; // Get our path - const from = t.screen.cursor.page_pin.*; - const path = t.screen.promptPath(from, to); + const from = t.screens.active.cursor.page_pin.*; + const path = t.screens.active.promptPath(from, to); log.debug("click-to-move-cursor from={} to={} path={}", .{ from, to, path }); // If we aren't moving at all, fast path out of here. @@ -3770,7 +4146,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B"; }; for (0..@abs(path.y)) |_| { - self.io.queueMessage(.{ .write_stable = arrow }, .locked); + self.queueIo(.{ .write_stable = arrow }, .locked); } } if (path.x != 0) { @@ -3780,7 +4156,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C"; }; for (0..@abs(path.x)) |_| { - self.io.queueMessage(.{ .write_stable = arrow }, .locked); + self.queueIo(.{ .write_stable = arrow }, .locked); } } } @@ -3796,7 +4172,7 @@ fn linkAtPos( terminal.Selection, } { // Convert our cursor position to a screen point. - const screen = &self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; const mouse_pin: terminal.Pin = mouse_pin: { const point = self.posToViewport(pos.x, pos.y); const pin = screen.pages.pin(.{ .viewport = point }) orelse { @@ -3884,7 +4260,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { const action, const sel = try self.linkAtPos(pos) orelse return false; switch (action) { .open => { - const str = try self.io.terminal.screen.selectionString(self.alloc, .{ + const str = try self.io.terminal.screens.active.selectionString(self.alloc, .{ .sel = sel, .trim = false, }); @@ -3934,7 +4310,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( @@ -3970,8 +4346,8 @@ pub fn mousePressureCallback( // This should always be set in this state but we don't want // to handle state inconsistency here. const pin = self.mouse.left_click_pin orelse break :select; - const sel = self.io.terminal.screen.selectWord(pin.*) orelse break :select; - try self.io.terminal.screen.select(sel); + const sel = self.io.terminal.screens.active.selectWord(pin.*) orelse break :select; + try self.io.terminal.screens.active.select(sel); try self.queueRender(); } } @@ -4028,7 +4404,7 @@ pub fn cursorPosCallback( // Mark the link's row as dirty, but continue with updating the // mouse state below so we can scroll when our position is negative. - self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; + self.renderer_state.terminal.screens.active.dirty.hyperlink_hover = true; } // Always show the mouse again if it is hidden @@ -4053,7 +4429,7 @@ pub fn cursorPosCallback( // Stop selection scrolling when inside the viewport within a 1px buffer // for fullscreen windows, but only when selection scrolling is active. if (pos.y >= 1 and self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .locked, ); @@ -4069,7 +4445,7 @@ pub fn cursorPosCallback( insp.mouse.last_xpos = pos.x; insp.mouse.last_ypos = pos.y; - const screen = &self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; insp.mouse.last_point = screen.pages.pin(.{ .viewport = .{ .x = pos_vp.x, .y = pos_vp.y, @@ -4099,7 +4475,7 @@ pub fn cursorPosCallback( } // Do a mouse report - if (self.io.terminal.flags.mouse_event != .none) report: { + if (self.isMouseReporting()) report: { // Shift overrides mouse "grabbing" in the window, taken from Kitty. // This only applies if there is a mouse button pressed so that // movement reports are not affected. @@ -4131,6 +4507,12 @@ pub fn cursorPosCallback( // count because we don't want to handle selection. if (self.mouse.left_click_count == 0) break :select; + // If our terminal screen changed then we don't process this. We don't + // invalidate our pin or mouse state because if the screen switches + // back then we can continue our selection. + const t: *terminal.Terminal = self.renderer_state.terminal; + if (self.mouse.left_click_screen != t.screens.active_key) break :select; + // All roads lead to requiring a re-render at this point. try self.queueRender(); @@ -4147,14 +4529,14 @@ pub fn cursorPosCallback( if ((pos.y <= 1 or pos.y > max_y - 1) and !self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = true }, .locked, ); } // Convert to points - const screen = &self.renderer_state.terminal.screen; + const screen: *terminal.Screen = t.screens.active; const pin = screen.pages.pin(.{ .viewport = .{ .x = pos_vp.x, @@ -4183,7 +4565,7 @@ fn dragLeftClickDouble( self: *Surface, drag_pin: terminal.Pin, ) !void { - const screen = &self.io.terminal.screen; + const screen: *terminal.Screen = self.io.terminal.screens.active; const click_pin = self.mouse.left_click_pin.?.*; // Get the word closest to our starting click. @@ -4204,13 +4586,13 @@ fn dragLeftClickDouble( // If our current mouse position is before the starting position, // then the selection start is the word nearest our current position. if (drag_pin.before(click_pin)) { - try self.io.terminal.screen.select(.init( + try self.io.terminal.screens.active.select(.init( word_current.start(), word_start.end(), false, )); } else { - try self.io.terminal.screen.select(.init( + try self.io.terminal.screens.active.select(.init( word_start.start(), word_current.end(), false, @@ -4223,7 +4605,7 @@ fn dragLeftClickTriple( self: *Surface, drag_pin: terminal.Pin, ) !void { - const screen = &self.io.terminal.screen; + const screen: *terminal.Screen = self.io.terminal.screens.active; const click_pin = self.mouse.left_click_pin.?.*; // Get the line selection under our current drag point. If there isn't a @@ -4242,7 +4624,7 @@ fn dragLeftClickTriple( } else { sel.endPtr().* = line.end(); } - try self.io.terminal.screen.select(sel); + try self.io.terminal.screens.active.select(sel); } fn dragLeftClickSingle( @@ -4251,7 +4633,7 @@ fn dragLeftClickSingle( drag_x: f64, ) !void { // This logic is in a separate function so that it can be unit tested. - try self.io.terminal.screen.select(mouseSelection( + try self.io.terminal.screens.active.select(mouseSelection( self.mouse.left_click_pin.?.*, drag_pin, @intFromFloat(@max(0.0, self.mouse.left_click_xpos)), @@ -4523,7 +4905,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .esc => try std.fmt.bufPrint(&buf, "\x1b{s}", .{data}), else => unreachable, }; - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, full_data, ), .unlocked); @@ -4550,7 +4932,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); return true; }; - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, text, ), .unlocked); @@ -4583,9 +4965,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; if (normal) { - self.io.queueMessage(.{ .write_stable = ck.normal }, .unlocked); + self.queueIo(.{ .write_stable = ck.normal }, .unlocked); } else { - self.io.queueMessage(.{ .write_stable = ck.application }, .unlocked); + self.queueIo(.{ .write_stable = ck.application }, .unlocked); } }, @@ -4595,23 +4977,106 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.terminal.fullReset(); }, - .copy_to_clipboard => { - // We can read from the renderer state without holding - // the lock because only we will write to this field. - if (self.io.terminal.screen.selection) |sel| { - const buf = self.io.terminal.screen.selectionString(self.alloc, .{ - .sel = sel, - .trim = self.config.clipboard_trim_trailing_spaces, - }) catch |err| { - log.err("error reading selection string err={}", .{err}); - return true; - }; - defer self.alloc.free(buf); + .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 = "" }, + ); + }, - self.rt_surface.setClipboardString(buf, .standard, false) catch |err| { - log.err("error setting clipboard string err={}", .{err}); - return true; + .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| { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + if (self.io.terminal.screens.active.selection) |sel| { + try self.copySelectionToClipboards( + sel, + &.{.standard}, + format, + ); // Clear the selection if configured to do so. if (self.config.selection_clear_on_copy) { @@ -4633,13 +5098,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .copy_url_to_clipboard => { // If the mouse isn't over a link, nothing we can do. if (!self.mouse.over_link) return false; - const pos = try self.rt_surface.getCursorPos(); + + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); if (try self.linkAtPos(pos)) |link_info| { const url_text = switch (link_info[0]) { .open => url_text: { // For regex links, get the text from selection - break :url_text (self.io.terminal.screen.selectionString(self.alloc, .{ + break :url_text (self.io.terminal.screens.active.selectionString(self.alloc, .{ .sel = link_info[1], .trim = self.config.clipboard_trim_trailing_spaces, })) catch |err| { @@ -4659,7 +5126,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; defer self.alloc.free(url_text); - self.rt_surface.setClipboardString(url_text, .standard, false) catch |err| { + self.rt_surface.setClipboard(.standard, &.{.{ + .mime = "text/plain", + .data = url_text, + }}, false) catch |err| { log.err("error copying url to clipboard err={}", .{err}); return false; }; @@ -4674,7 +5144,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool const title = self.rt_surface.getTitle() orelse return false; if (title.len == 0) return false; - self.rt_surface.setClipboardString(title, .standard, false) catch |err| { + self.rt_surface.setClipboard(.standard, &.{.{ + .mime = "text/plain", + .data = title, + }}, false) catch |err| { log.err("error copying title to clipboard err={}", .{err}); return true; }; @@ -4746,7 +5219,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .prompt_surface_title => return try self.rt_app.performAction( .{ .surface = self }, .prompt_title, - {}, + .surface, + ), + + .prompt_tab_title => return try self.rt_app.performAction( + .{ .surface = self }, + .prompt_title, + .tab, ), .clear_screen => { @@ -4758,44 +5237,59 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - if (self.io.terminal.active_screen == .alternate) return false; + if (self.io.terminal.screens.active_key == .alternate) return false; } - self.io.queueMessage(.{ + self.queueIo(.{ .clear_screen = .{ .history = true }, }, .unlocked); }, .scroll_to_top => { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .top = {} }, }, .unlocked); }, .scroll_to_bottom => { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .bottom = {} }, }, .unlocked); }, + .scroll_to_row => |n| { + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + t.screens.active.scroll(.{ .row = n }); + } + + try self.queueRender(); + }, + .scroll_to_selection => { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - const sel = self.io.terminal.screen.selection orelse return false; - const tl = sel.topLeft(&self.io.terminal.screen); - self.io.terminal.screen.scroll(.{ .pin = tl }); + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const sel = self.io.terminal.screens.active.selection orelse return false; + const tl = sel.topLeft(self.io.terminal.screens.active); + self.io.terminal.screens.active.scroll(.{ .pin = tl }); + } + + try self.queueRender(); }, .scroll_page_up => { const rows: isize = @intCast(self.size.grid().rows); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = -1 * rows }, }, .unlocked); }, .scroll_page_down => { const rows: isize = @intCast(self.size.grid().rows); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = rows }, }, .unlocked); }, @@ -4803,19 +5297,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_page_fractional => |fraction| { const rows: f32 = @floatFromInt(self.size.grid().rows); const delta: isize = @intFromFloat(@trunc(fraction * rows)); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = delta }, }, .unlocked); }, .scroll_page_lines => |lines| { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = lines }, }, .unlocked); }, .jump_to_prompt => |delta| { - self.io.queueMessage(.{ + self.queueIo(.{ .jump_to_prompt = @intCast(delta), }, .unlocked); }, @@ -4847,6 +5341,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool switch (v) { .this => .this, .other => .other, + .right => .right, }, ), @@ -4898,6 +5393,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, ), + .goto_window => |direction| return try self.rt_app.performAction( + .{ .surface = self }, + .goto_window, + switch (direction) { + .previous => .previous, + .next => .next, + }, + ), + .resize_split => |value| return try self.rt_app.performAction( .{ .surface = self }, .resize_split, @@ -4924,6 +5428,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_readonly => { + self.readonly = !self.readonly; + _ = try self.rt_app.performAction( + .{ .surface = self }, + .readonly, + if (self.readonly) .on else .off, + ); + return true; + }, + .reset_window_size => return try self.rt_app.performAction( .{ .surface = self }, .reset_window_size, @@ -4971,6 +5485,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle, ), + .toggle_mouse_reporting => { + self.config.mouse_reporting = !self.config.mouse_reporting; + log.debug("mouse reporting toggled: {}", .{self.config.mouse_reporting}); + }, + .toggle_command_palette => return try self.rt_app.performAction( .{ .surface = self }, .toggle_command_palette, @@ -4984,7 +5503,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ), .select_all => { - const sel = self.io.terminal.screen.selectAll(); + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + const sel = self.io.terminal.screens.active.selectAll(); if (sel) |s| { try self.setSelection(s); try self.queueRender(); @@ -5021,14 +5543,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; }, - .io => self.io.queueMessage(.{ .crash = {} }, .unlocked), + .io => self.queueIo(.{ .crash = {} }, .unlocked), }, .adjust_selection => |direction| { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - const screen = &self.io.terminal.screen; + const screen: *terminal.Screen = self.io.terminal.screens.active; const sel = if (screen.selection) |*sel| sel else { // If we don't have a selection we do not perform this // action, allowing the keybind to fall through to the @@ -5101,14 +5623,24 @@ const WriteScreenLoc = enum { fn writeScreenFile( self: *Surface, loc: WriteScreenLoc, - write_action: input.Binding.Action.WriteScreenAction, + write_screen: input.Binding.Action.WriteScreen, ) !void { // Create a temporary directory to store our scrollback. var tmp_dir = try internal_os.TempDir.init(); errdefer tmp_dir.deinit(); var filename_buf: [std.fs.max_path_bytes]u8 = undefined; - const filename = try std.fmt.bufPrint(&filename_buf, "{s}.txt", .{@tagName(loc)}); + const filename = try std.fmt.bufPrint( + &filename_buf, + "{s}.{s}", + .{ + @tagName(loc), + switch (write_screen.emit) { + .plain, .vt => "txt", + .html => "html", + }, + }, + ); // Open our scrollback file var file = try tmp_dir.dir.createFile( @@ -5133,12 +5665,12 @@ fn writeScreenFile( // We only dump history if we have history. We still keep // the file and write the empty file to the pty so that this // command always works on the primary screen. - const pages = &self.io.terminal.screen.pages; + const pages = &self.io.terminal.screens.active.pages; const sel_: ?terminal.Selection = switch (loc) { .history => history: { // We do not support this for alternate screens // because they don't have scrollback anyways. - if (self.io.terminal.active_screen == .alternate) { + if (self.io.terminal.screens.active_key == .alternate) { break :history null; } @@ -5159,7 +5691,7 @@ fn writeScreenFile( ); }, - .selection => self.io.terminal.screen.selection, + .selection => self.io.terminal.screens.active.selection, }; const sel = sel_ orelse { @@ -5168,18 +5700,24 @@ fn writeScreenFile( return; }; - // Use topLeft and bottomRight to ensure correct coordinate ordering - const tl = sel.topLeft(&self.io.terminal.screen); - const br = sel.bottomRight(&self.io.terminal.screen); - - try self.io.terminal.screen.dumpString( - buf_writer, - .{ - .tl = tl, - .br = br, - .unwrap = true, + const ScreenFormatter = terminal.formatter.ScreenFormatter; + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, .{ + .emit = switch (write_screen.emit) { + .plain => .plain, + .vt => .vt, + .html => .html, }, - ); + .unwrap = true, + .trim = false, + .background = self.io.terminal.colors.background.get(), + .foreground = self.io.terminal.colors.foreground.get(), + .palette = &self.io.terminal.colors.palette.current, + }); + formatter.content = .{ .selection = sel.ordered( + self.io.terminal.screens.active, + .forward, + ) }; + try formatter.format(buf_writer); } try buf_writer.flush(); @@ -5187,14 +5725,23 @@ fn writeScreenFile( var path_buf: [std.fs.max_path_bytes]u8 = undefined; const path = try tmp_dir.dir.realpath(filename, &path_buf); - switch (write_action) { + switch (write_screen.action) { .copy => { const pathZ = try self.alloc.dupeZ(u8, path); defer self.alloc.free(pathZ); - try self.rt_surface.setClipboardString(pathZ, .standard, false); + try self.rt_surface.setClipboard(.standard, &.{.{ + .mime = "text/plain", + .data = pathZ, + }}, false); }, - .open => try self.openUrl(.{ .kind = .text, .url = path }), - .paste => self.io.queueMessage(try termio.Message.writeReq( + .open => try self.openUrl(.{ + .kind = switch (write_screen.emit) { + .plain, .vt => .text, + .html => .html, + }, + .url = path, + }), + .paste => self.queueIo(try termio.Message.writeReq( self.alloc, path, ), .unlocked), @@ -5231,11 +5778,10 @@ pub fn completeClipboardRequest( confirmed, ), - .osc_52_write => |clipboard| try self.rt_surface.setClipboardString( - data, - clipboard, - !confirmed, - ), + .osc_52_write => |clipboard| try self.rt_surface.setClipboard(clipboard, &.{.{ + .mime = "text/plain", + .data = data, + }}, !confirmed), } } @@ -5270,13 +5816,10 @@ fn completeClipboardPaste( ) !void { if (data.len == 0) return; - const critical: struct { - bracketed: bool, - } = critical: { + const encode_opts: input.paste.Options = encode_opts: { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - - const bracketed = self.io.terminal.modes.get(.bracketed_paste); + const opts: input.paste.Options = .fromTerminal(&self.io.terminal); // If we have paste protection enabled, we detect unsafe pastes and return // an error. The error approach allows apprt to attempt to complete the paste @@ -5292,7 +5835,7 @@ fn completeClipboardPaste( // This is set during confirmation usually. if (allow_unsafe) break :unsafe false; - if (bracketed) { + if (opts.bracketed) { // If we're bracketed and the paste contains and ending // bracket then something naughty might be going on and we // never trust it. @@ -5303,7 +5846,7 @@ fn completeClipboardPaste( if (self.config.clipboard_paste_bracketed_safe) break :unsafe false; } - break :unsafe !terminal.isSafePaste(data); + break :unsafe !input.paste.isSafe(data); }; if (unsafe) { @@ -5317,55 +5860,32 @@ fn completeClipboardPaste( log.warn("error scrolling to bottom err={}", .{err}); }; - break :critical .{ - .bracketed = bracketed, - }; + break :encode_opts opts; }; - if (critical.bracketed) { - // If we're bracketd we write the data as-is to the terminal with - // the bracketed paste escape codes around it. - self.io.queueMessage(.{ - .write_stable = "\x1B[200~", - }, .unlocked); - self.io.queueMessage(try termio.Message.writeReq( + // Encode the data. In most cases this doesn't require any + // copies, so we optimize for that case. + var data_duped: ?[]u8 = null; + const vecs = input.paste.encode(data, encode_opts) catch |err| switch (err) { + error.MutableRequired => vecs: { + const buf: []u8 = try self.alloc.dupe(u8, data); + errdefer self.alloc.free(buf); + data_duped = buf; + break :vecs input.paste.encode(buf, encode_opts); + }, + }; + defer if (data_duped) |v| { + // This code path means the data did require a copy and mutation. + // We must free it. + self.alloc.free(v); + }; + + for (vecs) |vec| if (vec.len > 0) { + self.queueIo(try termio.Message.writeReq( self.alloc, - data, + vec, ), .unlocked); - self.io.queueMessage(.{ - .write_stable = "\x1B[201~", - }, .unlocked); - } else { - // If its not bracketed the input bytes are indistinguishable from - // keystrokes, so we must be careful. For example, we must replace - // any newlines with '\r'. - - // We just do a heap allocation here because its easy and I don't think - // worth the optimization of using small messages. - var buf = try self.alloc.alloc(u8, data.len); - defer self.alloc.free(buf); - - // This is super, super suboptimal. We can easily make use of SIMD - // here, but maybe LLVM in release mode is smart enough to figure - // out something clever. Either way, large non-bracketed pastes are - // increasingly rare for modern applications. - var len: usize = 0; - for (data, 0..) |ch, i| { - const dch = switch (ch) { - '\n' => '\r', - '\r' => if (i + 1 < data.len and data[i + 1] == '\n') continue else ch, - else => ch, - }; - - buf[len] = dch; - len += 1; - } - - self.io.queueMessage(try termio.Message.writeReq( - self.alloc, - buf[0..len], - ), .unlocked); - } + }; } fn completeClipboardReadOSC52( @@ -5407,7 +5927,7 @@ fn completeClipboardReadOSC52( const encoded = enc.encode(buf[prefix.len..], data); assert(encoded.len == size); - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, buf, ), .unlocked); @@ -5506,7 +6026,7 @@ fn testMouseSelection( .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, .screen = .{ .width = 110, .height = 110 }, }; - var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0); + var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); defer screen.deinit(); // We hold both ctrl and alt for rectangular @@ -5575,7 +6095,7 @@ fn testMouseSelectionIsNull( .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, .screen = .{ .width = 110, .height = 110 }, }; - var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0); + var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); defer screen.deinit(); // We hold both ctrl and alt for rectangular diff --git a/src/apprt.zig b/src/apprt.zig index 947f29050..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"); @@ -28,6 +26,7 @@ pub const Target = action.Target; pub const ContentScale = structs.ContentScale; pub const Clipboard = structs.Clipboard; +pub const ClipboardContent = structs.ClipboardContent; pub const ClipboardRequest = structs.ClipboardRequest; pub const ClipboardRequestType = structs.ClipboardRequestType; pub const ColorScheme = structs.ColorScheme; diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 14a8165f2..af1c22552 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"); @@ -129,6 +129,9 @@ pub const Action = union(Key) { /// Jump to a specific split. goto_split: GotoSplit, + /// Jump to next/previous window. + goto_window: GotoWindow, + /// Resize the split in the given direction. resize_split: ResizeSplit, @@ -164,6 +167,9 @@ pub const Action = union(Key) { /// The cell size has changed to the given dimensions in pixels. cell_size: CellSize, + /// The scrollbar is updating. + scrollbar: terminal.Scrollbar, + /// The target should be re-rendered. This usually has a specific /// surface target but if the app is targeted then all active /// surfaces should be redrawn. @@ -186,8 +192,9 @@ pub const Action = union(Key) { set_title: SetTitle, /// Set the title of the target to a prompted value. It is up to - /// the apprt to prompt. - prompt_title, + /// the apprt to prompt. The value specifies whether to prompt for the + /// surface title or the tab title. + prompt_title: PromptTitle, /// The current working directory has changed for the target terminal. pwd: Pwd, @@ -298,6 +305,21 @@ 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, + + /// The readonly state of the surface has changed. + readonly: Readonly, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -316,6 +338,7 @@ pub const Action = union(Key) { move_tab, goto_tab, goto_split, + goto_window, resize_split, equalize_splits, toggle_split_zoom, @@ -324,6 +347,7 @@ pub const Action = union(Key) { reset_window_size, initial_size, cell_size, + scrollbar, render, inspector, show_gtk_inspector, @@ -354,6 +378,11 @@ pub const Action = union(Key) { progress_report, show_on_screen_keyboard, command_finished, + start_search, + end_search, + search_total, + search_selected, + readonly, }; /// Sync with: ghostty_action_u @@ -449,6 +478,13 @@ pub const GotoSplit = enum(c_int) { right, }; +// This is made extern (c_int) to make interop easier with our embedded +// runtime. The small size cost doesn't make a difference in our union. +pub const GotoWindow = enum(c_int) { + previous, + next, +}; + /// The amount to resize the split by and the direction to resize it in. pub const ResizeSplit = extern struct { amount: u16, @@ -511,11 +547,22 @@ pub const QuitTimer = enum(c_int) { stop, }; +pub const Readonly = enum(c_int) { + off, + on, +}; + pub const MouseVisibility = enum(c_int) { visible, hidden, }; +/// Whether to prompt for the surface title or tab title. +pub const PromptTitle = enum(c_int) { + surface, + tab, +}; + pub const MouseOverLink = struct { url: [:0]const u8, @@ -720,6 +767,9 @@ pub const OpenUrl = struct { /// should try to open the URL in a text editor or viewer or /// some equivalent, if possible. text, + + /// The URL is known to contain HTML content. + html, }; // Sync with: ghostty_action_open_url_s @@ -744,6 +794,8 @@ pub const CloseTabMode = enum(c_int) { this, /// Close all other tabs. other, + /// Close all tabs to the right of the current tab. + right, }; pub const CommandFinished = struct { @@ -763,3 +815,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 617557995..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"); @@ -66,7 +66,13 @@ pub const App = struct { ) callconv(.c) void, /// Write the clipboard value. - write_clipboard: *const fn (SurfaceUD, [*:0]const u8, c_int, bool) callconv(.c) void, + write_clipboard: *const fn ( + SurfaceUD, + c_int, + [*]const CAPI.ClipboardContent, + usize, + bool, + ) callconv(.c) void, /// Close the current surface given by this function. close_surface: ?*const fn (SurfaceUD, bool) callconv(.c) void = null, @@ -699,16 +705,27 @@ pub const Surface = struct { alloc.destroy(state); } - pub fn setClipboardString( + pub fn setClipboard( self: *const Surface, - val: [:0]const u8, clipboard_type: apprt.Clipboard, + contents: []const apprt.ClipboardContent, confirm: bool, ) !void { + const alloc = self.app.core_app.alloc; + const array = try alloc.alloc(CAPI.ClipboardContent, contents.len); + defer alloc.free(array); + for (contents, 0..) |content, i| { + array[i] = .{ + .mime = content.mime, + .data = content.data, + }; + } + self.app.opts.write_clipboard( self.userdata, - val.ptr, @intCast(@intFromEnum(clipboard_type)), + array.ptr, + array.len, confirm, ); } @@ -1211,6 +1228,12 @@ pub const CAPI = struct { cell_height_px: u32, }; + // ghostty_clipboard_content_s + const ClipboardContent = extern struct { + mime: [*:0]const u8, + data: [*:0]const u8, + }; + // ghostty_text_s const Text = extern struct { tl_px_x: f64, @@ -1535,7 +1558,7 @@ pub const CAPI = struct { defer core_surface.renderer_state.mutex.unlock(); // If we don't have a selection, do nothing. - const core_sel = core_surface.io.terminal.screen.selection orelse return false; + const core_sel = core_surface.io.terminal.screens.active.selection orelse return false; // Read the text from the selection. return readTextLocked(surface, core_sel, result); @@ -1555,7 +1578,7 @@ pub const CAPI = struct { defer surface.core_surface.renderer_state.mutex.unlock(); const core_sel = sel.core( - &surface.core_surface.renderer_state.terminal.screen, + surface.core_surface.renderer_state.terminal.screens.active, ) orelse return false; return readTextLocked(surface, core_sel, result); @@ -2114,7 +2137,7 @@ pub const CAPI = struct { // Get our word selection const sel = sel: { - const screen = &surface.renderer_state.terminal.screen; + const screen: *terminal.Screen = surface.renderer_state.terminal.screens.active; const pos = try ptr.getCursorPos(); const pt_viewport = surface.posToViewport(pos.x, pos.y); const pin = screen.pages.pin(.{ @@ -2126,7 +2149,7 @@ pub const CAPI = struct { if (comptime std.debug.runtime_safety) unreachable; return false; }; - break :sel surface.io.terminal.screen.selectWord(pin) orelse return false; + break :sel surface.io.terminal.screens.active.selectWord(pin) orelse return false; }; // Read the selection 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/Surface.zig b/src/apprt/gtk/Surface.zig index ac82f941b..009ce018d 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -80,15 +80,15 @@ pub fn clipboardRequest( ); } -pub fn setClipboardString( +pub fn setClipboard( self: *Self, - val: [:0]const u8, clipboard_type: apprt.Clipboard, + contents: []const apprt.ClipboardContent, confirm: bool, ) !void { - self.surface.setClipboardString( - val, + self.surface.setClipboard( clipboard_type, + contents, confirm, ); } 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 fabd5763e..c77579aab 100644 --- a/src/apprt/gtk/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -43,9 +43,11 @@ 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" }, + .{ .major = 1, .minor = 5, .name = "surface-scrolled-window" }, .{ .major = 1, .minor = 5, .name = "surface-title-dialog" }, .{ .major = 1, .minor = 3, .name = "surface-child-exited" }, .{ .major = 1, .minor = 5, .name = "tab" }, 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 af56130d3..c951cc6ac 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"); @@ -10,6 +9,7 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); const build_config = @import("../../../build_config.zig"); +const state = &@import("../../../global.zig").state; const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const cgroup = @import("../cgroup.zig"); @@ -394,6 +394,14 @@ pub const Application = extern struct { .{ .detail = "config" }, ); + _ = gtk.CssProvider.signals.parsing_error.connect( + css_provider, + *Self, + signalCssParsingError, + self, + .{}, + ); + // Trigger initial config changes self.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec); @@ -530,12 +538,16 @@ pub const Application = extern struct { } // If we have no windows attached to our app, also quit. - if (priv.requested_window and @as( - ?*glib.List, - self.as(gtk.Application).getWindows(), - ) == null) { - log.debug("must_quit due to no app windows", .{}); - break :q true; + // We only do this if we don't have the closed delay set, + // because with the closed delay set we'll exit eventually. + if (config.@"quit-after-last-window-closed-delay" == null) { + if (priv.requested_window and @as( + ?*glib.List, + self.as(gtk.Application).getWindows(), + ) == null) { + log.debug("must_quit due to no app windows", .{}); + break :q true; + } } // No quit conditions met @@ -649,6 +661,8 @@ pub const Application = extern struct { .goto_split => return Action.gotoSplit(target, value), + .goto_window => return Action.gotoWindow(value), + .goto_tab => return Action.gotoTab(target, value), .initial_size => return Action.initialSize(target, value), @@ -683,7 +697,7 @@ pub const Application = extern struct { .progress_report => return Action.progressReport(target, value), - .prompt_title => return Action.promptTitle(target), + .prompt_title => return Action.promptTitle(target, value), .quit => self.quit(), @@ -697,6 +711,8 @@ pub const Application = extern struct { .ring_bell => Action.ringBell(target), + .scrollbar => Action.scrollbar(target, value), + .set_title => Action.setTitle(target, value), .show_child_exited => return Action.showChildExited(target, value), @@ -715,6 +731,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, @@ -729,6 +750,7 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, + .readonly, => { log.warn("unimplemented action={}", .{action}); return false; @@ -806,19 +828,19 @@ pub const Application = extern struct { } } - fn loadRuntimeCss(self: *Self) Allocator.Error!void { + fn loadRuntimeCss(self: *Self) (Allocator.Error || std.Io.Writer.Error)!void { const alloc = self.allocator(); + const priv: *Private = self.private(); + const config = priv.config.get(); - const config = self.private().config.get(); + var buf: std.Io.Writer.Allocating = try .initCapacity(alloc, 2048); + defer buf.deinit(); - var buf: std.ArrayListUnmanaged(u8) = try .initCapacity(alloc, 2048); - defer buf.deinit(alloc); - - const writer = buf.writer(alloc); + const writer = &buf.writer; // Load standard css first as it can override some of the user configured styling. - try loadRuntimeCss414(config, &writer); - try loadRuntimeCss416(config, &writer); + try loadRuntimeCss414(config, writer); + try loadRuntimeCss416(config, writer); const unfocused_fill: CoreConfig.Color = config.@"unfocused-split-fill" orelse config.background; @@ -858,25 +880,22 @@ pub const Application = extern struct { , .{ .font_family = font_family }); } - // ensure that we have a sentinel - try writer.writeByte(0); + const contents = buf.written(); - const data = buf.items[0 .. buf.items.len - 1 :0]; + log.debug("runtime CSS is {d} bytes", .{contents.len}); - log.debug("runtime CSS is {d} bytes", .{data.len + 1}); + const bytes = glib.Bytes.new(contents.ptr, contents.len); + defer bytes.unref(); // Clears any previously loaded CSS from this provider - loadCssProviderFromData( - self.private().css_provider, - data, - ); + priv.css_provider.loadFromBytes(bytes); } /// Load runtime CSS for older than GTK 4.16 fn loadRuntimeCss414( config: *const CoreConfig, - writer: *const std.ArrayListUnmanaged(u8).Writer, - ) Allocator.Error!void { + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { if (gtk_version.runtimeAtLeast(4, 16, 0)) return; const window_theme = config.@"window-theme"; @@ -911,8 +930,8 @@ pub const Application = extern struct { /// Load runtime for GTK 4.16 and newer fn loadRuntimeCss416( config: *const CoreConfig, - writer: *const std.ArrayListUnmanaged(u8).Writer, - ) Allocator.Error!void { + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { if (gtk_version.runtimeUntil(4, 16, 0)) return; const window_theme = config.@"window-theme"; @@ -1008,8 +1027,8 @@ pub const Application = extern struct { } } - fn loadCustomCss(self: *Self) !void { - const priv = self.private(); + fn loadCustomCss(self: *Self) (std.fs.File.ReadError || Allocator.Error)!void { + const priv: *Private = self.private(); const alloc = self.allocator(); const display = gdk.Display.getDefault() orelse { log.warn("unable to get display", .{}); @@ -1026,7 +1045,7 @@ pub const Application = extern struct { } priv.custom_css_providers.clearRetainingCapacity(); - const config = priv.config.getMut(); + const config = priv.config.get(); for (config.@"gtk-custom-css".value.items) |p| { const path, const optional = switch (p) { .optional => |path| .{ path, true }, @@ -1043,25 +1062,42 @@ pub const Application = extern struct { }; defer file.close(); + const css_file_size_limit = 5 * 1024 * 1024; // 5MB + log.info("loading gtk-custom-css path={s}", .{path}); - var buf: [4096]u8 = undefined; - var reader = file.reader(&buf); - const contents = try reader.interface.readAlloc( + const contents = file.readToEndAlloc( alloc, - 5 * 1024 * 1024, // 5MB, - ); + css_file_size_limit, + ) catch |err| switch (err) { + error.FileTooBig => { + log.warn("gtk-custom-css file {s} was larger than {Bi}", .{ path, css_file_size_limit }); + continue; + }, + else => |e| return e, + }; defer alloc.free(contents); - const data = try alloc.dupeZ(u8, contents); - defer alloc.free(data); + const bytes = glib.Bytes.new(contents.ptr, contents.len); + defer bytes.unref(); + + const css_provider = gtk.CssProvider.new(); + errdefer css_provider.unref(); + + _ = gtk.CssProvider.signals.parsing_error.connect( + css_provider, + *Self, + signalCssParsingError, + self, + .{}, + ); + + try priv.custom_css_providers.append(alloc, css_provider); + + css_provider.loadFromBytes(bytes); - const provider = gtk.CssProvider.new(); - errdefer provider.unref(); - try priv.custom_css_providers.append(alloc, provider); - loadCssProviderFromData(provider, data); gtk.StyleContext.addProviderForDisplay( display, - provider.as(gtk.StyleProvider), + css_provider.as(gtk.StyleProvider), gtk.STYLE_PROVIDER_PRIORITY_USER, ); } @@ -1083,7 +1119,7 @@ pub const Application = extern struct { self.syncActionAccelerator("win.split-down", .{ .new_split = .down }); self.syncActionAccelerator("win.split-left", .{ .new_split = .left }); self.syncActionAccelerator("win.split-up", .{ .new_split = .up }); - self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = {} }); + self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = .mixed }); self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} }); self.syncActionAccelerator("win.reset", .{ .reset = {} }); self.syncActionAccelerator("win.clear", .{ .clear_screen = {} }); @@ -1164,7 +1200,7 @@ pub const Application = extern struct { // just stuck with the old CSS but we don't want to fail the entire // config change operation. self.loadRuntimeCss() catch |err| switch (err) { - error.OutOfMemory => log.warn( + error.WriteFailed, error.OutOfMemory => log.warn( "out of memory loading runtime CSS, no runtime CSS applied", .{}, ), @@ -1177,6 +1213,37 @@ pub const Application = extern struct { }; } + /// Log CSS parsing error + fn signalCssParsingError( + _: *gtk.CssProvider, + css_section: *gtk.CssSection, + err: *glib.Error, + _: *Self, + ) callconv(.c) void { + const location = css_section.toString(); + defer glib.free(location); + if (comptime gtk_version.atLeast(4, 16, 0)) bytes: { + const bytes = css_section.getBytes() orelse break :bytes; + var len: usize = undefined; + const ptr = bytes.getData(&len) orelse break :bytes; + const data = ptr[0..len]; + log.warn("css parsing failed at {s}: {s} {d} {s}\n{s}", .{ + location, + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "«unknown»", + data, + }); + return; + } + log.warn("css parsing failed at {s}: {s} {d} {s}", .{ + location, + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "«unknown»", + }); + } + //--------------------------------------------------------------- // Libghostty Callbacks @@ -1521,7 +1588,7 @@ pub const Application = extern struct { .dark; log.debug("style manager changed scheme={}", .{scheme}); - const priv = self.private(); + const priv: *Private = self.private(); const core_app = priv.core_app; core_app.colorSchemeEvent(self.rt(), scheme) catch |err| { log.warn("error updating app color scheme err={}", .{err}); @@ -1534,6 +1601,26 @@ pub const Application = extern struct { ); }; } + + if (gtk_version.atLeast(4, 20, 0)) { + const gtk_scheme: gtk.InterfaceColorScheme = switch (scheme) { + .light => gtk.InterfaceColorScheme.light, + .dark => gtk.InterfaceColorScheme.dark, + }; + var value = gobject.ext.Value.newFrom(gtk_scheme); + gobject.Object.setProperty( + priv.css_provider.as(gobject.Object), + "prefers-color-scheme", + &value, + ); + for (priv.custom_css_providers.items) |css_provider| { + gobject.Object.setProperty( + css_provider.as(gobject.Object), + "prefers-color-scheme", + &value, + ); + } + } } fn handleReloadConfig( @@ -1931,6 +2018,69 @@ const Action = struct { } } + pub fn gotoWindow(direction: apprt.action.GotoWindow) bool { + const glist = gtk.Window.listToplevels(); + defer glist.free(); + + // The window we're starting from is typically our active window. + const starting: *glib.List = @as(?*glib.List, glist.findCustom( + null, + findActiveWindow, + )) orelse glist; + + // Go forward or backwards in the list until we find a valid + // window that is visible. + var current_: ?*glib.List = starting; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; + } + + // If we reached here, we didn't find a valid window to focus. + // Wrap around. + current_ = switch (direction) { + .next => glist, + .previous => last: { + var end: *glib.List = glist; + while (end.f_next) |next| end = next; + break :last end; + }, + }; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + if (current_ == starting) break; + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; + } + + return false; + } + + fn gotoWindowMaybe(gtk_window: *gtk.Window) bool { + // If it is already active skip it. + if (gtk_window.isActive() != 0) return false; + // If it is hidden, skip it. + if (gtk_window.as(gtk.Widget).isVisible() == 0) return false; + // If it isn't a Ghostty window, skip it. + const window = gobject.ext.cast( + Window, + gtk_window, + ) orelse return false; + + // Focus our active surface + const surface = window.getActiveSurface() orelse return false; + gtk.Window.present(gtk_window); + surface.grabFocus(); + return true; + } + pub fn initialSize( target: apprt.Target, value: apprt.action.InitialSize, @@ -2168,12 +2318,18 @@ const Action = struct { }; } - pub fn promptTitle(target: apprt.Target) bool { - switch (target) { - .app => return false, - .surface => |v| { - v.rt_surface.surface.promptTitle(); - return true; + pub fn promptTitle(target: apprt.Target, value: apprt.action.PromptTitle) bool { + switch (value) { + .surface => switch (target) { + .app => return false, + .surface => |v| { + v.rt_surface.surface.promptTitle(); + return true; + }, + }, + .tab => { + // GTK does not yet support tab title prompting + return false; }, } } @@ -2270,6 +2426,44 @@ const Action = struct { } } + pub fn scrollbar( + target: apprt.Target, + value: apprt.Action.Value(.scrollbar), + ) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setScrollbar(value), + } + } + + 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, @@ -2485,7 +2679,9 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void { /// disable it. @"vulkan-disable": bool = false, } = .{ - .opengl = config.@"gtk-opengl-debug", + // `gtk-opengl-debug` dumps logs directly to stderr so both must be true + // to enable OpenGL debugging. + .opengl = state.logging.stderr and config.@"gtk-opengl-debug", }; var gdk_disable: struct { @@ -2580,8 +2776,3 @@ fn findActiveWindow(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.c) // Abusing integers to be enums and booleans is a terrible idea, C. return if (window.isActive() != 0) 0 else -1; } - -fn loadCssProviderFromData(provider: *gtk.CssProvider, data: [:0]const u8) void { - assert(gtk_version.runtimeAtLeast(4, 12, 0)); - provider.loadFromString(data); -} 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 a498ca5dc..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,20 +7,15 @@ 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; +const SurfaceScrolledWindow = @import("surface_scrolled_window.zig").SurfaceScrolledWindow; const log = std.log.scoped(.gtk_ghostty_split_tree); @@ -874,7 +868,9 @@ pub const SplitTree = extern struct { current: Surface.Tree.Node.Handle, ) *gtk.Widget { return switch (tree.nodes[current.idx()]) { - .leaf => |v| v.as(gtk.Widget), + .leaf => |v| gobject.ext.newInstance(SurfaceScrolledWindow, .{ + .surface = v, + }).as(gtk.Widget), .split => |s| SplitTreeSplit.new( current, &s, diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index cc8359b7e..548ae1a6a 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"); @@ -40,12 +39,16 @@ pub const Surface = extern struct { const Self = @This(); parent_instance: Parent, pub const Parent = adw.Bin; + pub const Implements = [_]type{gtk.Scrollable}; pub const getGObjectType = gobject.ext.defineClass(Self, .{ .name = "GhosttySurface", .instanceInit = &init, .classInit = &Class.init, .parent_class = &Class.parent, .private = .{ .Type = Private, .offset = &Private.offset }, + .implements = &.{ + gobject.ext.implement(gtk.Scrollable, .{}), + }, }); /// A SplitTree implementation that stores surfaces. @@ -301,6 +304,62 @@ pub const Surface = extern struct { }, ); }; + + pub const hadjustment = struct { + pub const name = "hadjustment"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*gtk.Adjustment, + .{ + .accessor = .{ + .getter = getHAdjustmentValue, + .setter = setHAdjustmentValue, + }, + }, + ); + }; + + pub const vadjustment = struct { + pub const name = "vadjustment"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*gtk.Adjustment, + .{ + .accessor = .{ + .getter = getVAdjustmentValue, + .setter = setVAdjustmentValue, + }, + }, + ); + }; + + pub const @"hscroll-policy" = struct { + pub const name = "hscroll-policy"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.ScrollablePolicy, + .{ + .default = .natural, + .accessor = C.privateShallowFieldAccessor("hscroll_policy"), + }, + ); + }; + + pub const @"vscroll-policy" = struct { + pub const name = "vscroll-policy"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.ScrollablePolicy, + .{ + .default = .natural, + .accessor = C.privateShallowFieldAccessor("vscroll_policy"), + }, + ); + }; }; pub const signals = struct { @@ -491,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, @@ -548,6 +610,13 @@ pub const Surface = extern struct { action_group: ?*gio.SimpleActionGroup = null, + // Gtk.Scrollable interface adjustments + hadj: ?*gtk.Adjustment = null, + vadj: ?*gtk.Adjustment = null, + hscroll_policy: gtk.ScrollablePolicy = .natural, + vscroll_policy: gtk.ScrollablePolicy = .natural, + vadj_signal_group: ?*gobject.SignalGroup = null, + // Template binds child_exited_overlay: *ChildExited, context_menu: *gtk.PopoverMenu, @@ -714,6 +783,47 @@ pub const Surface = extern struct { return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0; } + /// Set the scrollbar state for this surface. This will setup the + /// properties for our Gtk.Scrollable interface properly. + pub fn setScrollbar(self: *Self, scrollbar: terminal.Scrollbar) void { + // Update existing adjustment in-place. If we don't have an + // adjustment then we do nothing because we're not part of a + // scrolled window. + const vadj = self.getVAdjustment() orelse return; + + // Check if values match existing adjustment and skip update if so + const value: f64 = @floatFromInt(scrollbar.offset); + const upper: f64 = @floatFromInt(scrollbar.total); + const page_size: f64 = @floatFromInt(scrollbar.len); + + if (std.math.approxEqAbs(f64, vadj.getValue(), value, 0.001) and + std.math.approxEqAbs(f64, vadj.getUpper(), upper, 0.001) and + std.math.approxEqAbs(f64, vadj.getPageSize(), page_size, 0.001)) + { + return; + } + + // If we have a vadjustment we MUST have the signal group since + // it is setup in the prop handler. + const priv = self.private(); + const group = priv.vadj_signal_group.?; + + // During manual scrollbar changes from Ghostty core we don't + // want to emit value-changed signals so we block them. This would + // cause a waste of resources at best and infinite loops at worst. + group.block(); + defer group.unblock(); + + vadj.configure( + value, // value: current scroll position + 0, // lower: minimum value + upper, // upper: maximum value (total scrollable area) + 1, // step_increment: amount to scroll on arrow click + page_size, // page_increment: amount to scroll on page up/down + page_size, // page_size: size of visible area + ); + } + /// Set the current progress report state. pub fn setProgressReport( self: *Self, @@ -838,6 +948,8 @@ pub const Surface = extern struct { if (cfg.@"notify-on-command-finish" == .unfocused and self.getFocused()) return true; } + if (value.duration.lte(cfg.@"notify-on-command-finish-after")) return true; + const action = cfg.@"notify-on-command-finish-action"; if (action.bell) self.setBellRinging(true); @@ -1027,13 +1139,14 @@ pub const Surface = extern struct { if (entry.native == keycode) break :w3c entry.key; } else .unidentified; - // If the key should be remappable, then consult the pre-remapped - // XKB keyval/keysym to get the (possibly) remapped key. + // Consult the pre-remapped XKB keyval/keysym to get the (possibly) + // remapped key. If the W3C key or the remapped key + // is eligible for remapping, we use it. // // See the docs for `shouldBeRemappable` for why we even have to // do this in the first place. - if (w3c_key.shouldBeRemappable()) { - if (gtk_key.keyFromKeyval(keyval)) |remapped| + if (gtk_key.keyFromKeyval(keyval)) |remapped| { + if (w3c_key.shouldBeRemappable() or remapped.shouldBeRemappable()) break :keycode remapped; } @@ -1355,6 +1468,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; @@ -1443,16 +1560,16 @@ pub const Surface = extern struct { ); } - pub fn setClipboardString( + pub fn setClipboard( self: *Self, - val: [:0]const u8, clipboard_type: apprt.Clipboard, + contents: []const apprt.ClipboardContent, confirm: bool, ) void { Clipboard.set( self, - val, clipboard_type, + contents, confirm, ); } @@ -1466,6 +1583,12 @@ pub const Surface = extern struct { pub fn sendDesktopNotification(self: *Self, title: [:0]const u8, body: [:0]const u8) void { const app = Application.default(); + const priv: *Private = self.private(); + + const core_surface = priv.core_surface orelse { + log.warn("can't send notification because there is no core surface", .{}); + return; + }; const t = switch (title.len) { 0 => "Ghostty", @@ -1480,7 +1603,7 @@ pub const Surface = extern struct { defer icon.unref(); notification.setIcon(icon.as(gio.Icon)); - const pointer = glib.Variant.newUint64(@intFromPtr(self)); + const pointer = glib.Variant.newUint64(@intFromPtr(core_surface)); notification.setDefaultActionAndTargetValue( "app.present-surface", pointer, @@ -1511,6 +1634,7 @@ pub const Surface = extern struct { priv.mouse_hidden = false; priv.focused = true; priv.size = .{ .width = 0, .height = 0 }; + priv.vadj_signal_group = null; // If our configuration is null then we get the configuration // from the application. @@ -1575,6 +1699,22 @@ pub const Surface = extern struct { priv.config = null; } + if (priv.vadj_signal_group) |group| { + group.setTarget(null); + group.as(gobject.Object).unref(); + priv.vadj_signal_group = null; + } + + if (priv.hadj) |v| { + v.as(gobject.Object).unref(); + priv.hadj = null; + } + + if (priv.vadj) |v| { + v.as(gobject.Object).unref(); + priv.vadj = null; + } + if (priv.progress_bar_timer) |timer| { if (glib.Source.remove(timer) == 0) { log.warn("unable to remove progress bar timer", .{}); @@ -1816,6 +1956,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, @@ -1988,6 +2151,43 @@ pub const Surface = extern struct { self.as(gtk.Widget).setCursorFromName(name.ptr); } + fn vadjValueChanged(adj: *gtk.Adjustment, self: *Self) callconv(.c) void { + // This will trigger for every single pixel change in the adjustment, + // but our core surface handles the noise from this so that identical + // rows are cheap. + const core_surface = self.core() orelse return; + const row: usize = @intFromFloat(@round(adj.getValue())); + _ = core_surface.performBindingAction(.{ .scroll_to_row = row }) catch |err| { + log.err("error performing scroll_to_row action err={}", .{err}); + }; + } + + fn propVAdjustment( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + + // When vadjustment is first set, we setup the signal group lazily. + // This makes it so that if we don't use scrollbars, we never + // pay the memory cost of this. + const group: *gobject.SignalGroup = priv.vadj_signal_group orelse group: { + const group = gobject.SignalGroup.new(gtk.Adjustment.getGObjectType()); + group.connect( + "value-changed", + @ptrCast(&vadjValueChanged), + self, + ); + + priv.vadj_signal_group = group; + break :group group; + }; + + // Setup our signal group target + group.setTarget(if (priv.vadj) |v| v.as(gobject.Object) else null); + } + /// Handle bell features that need to happen every time a BEL is received /// Currently this is audio and system but this could change in the future. fn ringBell(self: *Self) void { @@ -2052,6 +2252,66 @@ pub const Surface = extern struct { } } + //--------------------------------------------------------------- + // Gtk.Scrollable interface implementation + + pub fn getHAdjustment(self: *Self) ?*gtk.Adjustment { + return self.private().hadj; + } + + pub fn setHAdjustment(self: *Self, adj_: ?*gtk.Adjustment) void { + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.hadjustment.impl.param_spec); + + const priv = self.private(); + if (priv.hadj) |old| { + old.as(gobject.Object).unref(); + priv.hadj = null; + } + + const adj = adj_ orelse return; + _ = adj.as(gobject.Object).ref(); + priv.hadj = adj; + } + + fn getHAdjustmentValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set(value, self.getHAdjustment()); + } + + fn setHAdjustmentValue(self: *Self, value: *const gobject.Value) void { + self.setHAdjustment(gobject.ext.Value.get(value, ?*gtk.Adjustment)); + } + + pub fn getVAdjustment(self: *Self) ?*gtk.Adjustment { + return self.private().vadj; + } + + pub fn setVAdjustment(self: *Self, adj_: ?*gtk.Adjustment) void { + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.vadjustment.impl.param_spec); + + const priv = self.private(); + + if (priv.vadj) |old| { + old.as(gobject.Object).unref(); + priv.vadj = null; + } + + const adj = adj_ orelse return; + _ = adj.as(gobject.Object).ref(); + priv.vadj = adj; + } + + fn getVAdjustmentValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set(value, self.getVAdjustment()); + } + + fn setVAdjustmentValue(self: *Self, value: *const gobject.Value) void { + self.setVAdjustment(gobject.ext.Value.get(value, ?*gtk.Adjustment)); + } + //--------------------------------------------------------------- // Signal Handlers @@ -2938,6 +3198,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; @@ -2952,6 +3241,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), @@ -2971,6 +3261,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", .{}); @@ -3005,8 +3296,13 @@ pub const Surface = extern struct { class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl); class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden); class.bindTemplateCallback("notify_mouse_shape", &propMouseShape); + class.bindTemplateCallback("notify_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, &.{ @@ -3026,6 +3322,12 @@ pub const Surface = extern struct { properties.@"title-override".impl, properties.zoom.impl, properties.@"is-split".impl, + + // For Gtk.Scrollable + properties.hadjustment.impl, + properties.vadjustment.impl, + properties.@"hscroll-policy".impl, + properties.@"vscroll-policy".impl, }); // Signals @@ -3097,24 +3399,80 @@ const Clipboard = struct { /// Set the clipboard contents. pub fn set( self: *Surface, - val: [:0]const u8, clipboard_type: apprt.Clipboard, + contents: []const apprt.ClipboardContent, confirm: bool, ) void { const priv = self.private(); + // Grab our plaintext content for use in confirmation dialogs + // and signals. We always expect one to exist. + const text: [:0]const u8 = for (contents) |content| { + if (std.mem.eql(u8, content.mime, "text/plain")) { + break content.data; + } + } else return; + // If no confirmation is necessary, set the clipboard. if (!confirm) { const clipboard = get( priv.gl_area.as(gtk.Widget), clipboard_type, ) orelse return; - clipboard.setText(val); + + const alloc = Application.default().allocator(); + if (alloc.alloc(*gdk.ContentProvider, contents.len)) |providers| { + // Note: we don't need to unref the individual providers + // because new_union takes ownership of them. + defer alloc.free(providers); + + for (contents, 0..) |content, i| { + const bytes = glib.Bytes.new(content.data.ptr, content.data.len); + defer bytes.unref(); + if (std.mem.eql(u8, content.mime, "text/plain")) { + // Add an explicit UTF-8 encoding parameter to the + // 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;charset=utf-8", + "text/plain", + }; + var text_providers: [text_provider_atoms.len]*gdk.ContentProvider = undefined; + for (text_provider_atoms, 0..) |atom, j| { + const provider = gdk.ContentProvider.newForBytes(atom, bytes); + text_providers[j] = provider; + } + const text_union = gdk.ContentProvider.newUnion( + &text_providers, + text_providers.len, + ); + providers[i] = text_union; + } else { + const provider = gdk.ContentProvider.newForBytes(content.mime, bytes); + providers[i] = provider; + } + } + + const all = gdk.ContentProvider.newUnion(providers.ptr, providers.len); + defer all.unref(); + _ = clipboard.setContent(all); + } else |_| { + // If we fail to alloc, we can at least set the text content. + clipboard.setText(text); + } Surface.signals.@"clipboard-write".impl.emit( self, null, - .{ clipboard_type, val.ptr }, + .{ clipboard_type, text.ptr }, null, ); @@ -3124,7 +3482,7 @@ const Clipboard = struct { showClipboardConfirmation( self, .{ .osc_52_write = clipboard_type }, - val, + text, ); } 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 new file mode 100644 index 000000000..488fdb3f4 --- /dev/null +++ b/src/apprt/gtk/class/surface_scrolled_window.zig @@ -0,0 +1,208 @@ +const std = @import("std"); +const adw = @import("adw"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; +const Surface = @import("surface.zig").Surface; +const Config = @import("config.zig").Config; + +const log = std.log.scoped(.gtk_ghostty_surface_scrolled_window); + +/// A wrapper widget that embeds a Surface inside a GtkScrolledWindow. +/// This provides scrollbar functionality for the terminal surface. +/// The surface property can be set during initialization or changed +/// dynamically via the surface property. +pub const SurfaceScrolledWindow = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhostttySurfaceScrolledWindow", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const surface = struct { + pub const name = "surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = .{ + .getter = getSurfaceValue, + .setter = setSurfaceValue, + }, + }, + ); + }; + }; + + const Private = struct { + config: ?*Config = null, + config_binding: ?*gobject.Binding = null, + surface: ?*Surface = null, + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + + if (priv.config_binding) |binding| { + binding.as(gobject.Object).unref(); + priv.config_binding = null; + } + + if (priv.config) |v| { + v.unref(); + priv.config = null; + } + + 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 { + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + fn getSurfaceValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + self.private().surface, + ); + } + + fn setSurfaceValue(self: *Self, value: *const gobject.Value) void { + self.setSurface(gobject.ext.Value.get( + value, + ?*Surface, + )); + } + + pub fn getSurface(self: *Self) ?*Surface { + return self.private().surface; + } + + pub fn setSurface(self: *Self, surface_: ?*Surface) void { + const priv = self.private(); + + if (surface_ == priv.surface) return; + + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); + + priv.surface = surface_; + } + + fn closureScrollbarPolicy( + _: *Self, + config_: ?*Config, + ) callconv(.c) gtk.PolicyType { + const config = if (config_) |c| c.get() else return .automatic; + return switch (config.scrollbar) { + .never => .never, + .system => .automatic, + }; + } + + fn propSurface( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + const child: *gtk.Widget = self.as(Parent).getChild().?; + const scrolled_window = gobject.ext.cast(gtk.ScrolledWindow, child).?; + scrolled_window.setChild(if (priv.surface) |s| s.as(gtk.Widget) else null); + + // Unbind old config binding if it exists + if (priv.config_binding) |binding| { + binding.as(gobject.Object).unref(); + priv.config_binding = null; + } + + // Bind config from surface to our config property + if (priv.surface) |surface| { + priv.config_binding = surface.as(gobject.Object).bindProperty( + properties.config.name, + self.as(gobject.Object), + properties.config.name, + .{ .sync_create = true }, + ); + } + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "surface-scrolled-window", + }), + ); + + // Bindings + class.bindTemplateCallback("scrollbar_policy", &closureScrollbarPolicy); + class.bindTemplateCallback("notify_surface", &propSurface); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + properties.surface.impl, + }); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk/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 941fa00a9..fb3b8b0ef 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; @@ -353,6 +347,7 @@ pub const Tab = extern struct { switch (mode) { .this => tab_view.closePage(page), .other => tab_view.closeOtherPages(page), + .right => tab_view.closePagesAfter(page), } } @@ -389,8 +384,14 @@ pub const Tab = extern struct { // the terminal title if it exists, otherwise a default string. const plain = plain: { const default = "Ghostty"; + const config_title: ?[*:0]const u8 = title: { + const config = config_ orelse break :title null; + break :title config.get().title orelse null; + }; + const plain = override_ orelse terminal_ orelse + config_title orelse break :plain default; break :plain std.mem.span(plain); }; diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 8efff8729..77fd2eea5 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); @@ -794,7 +793,7 @@ pub const Window = extern struct { /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. - fn getActiveSurface(self: *Self) ?*Surface { + pub fn getActiveSurface(self: *Self) ?*Surface { const tab = self.getSelectedTab() orelse return null; return tab.getActiveSurface(); } @@ -1015,6 +1014,15 @@ pub const Window = extern struct { _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { + // Hide quick-terminal if set to autohide + if (self.isQuickTerminal()) { + if (self.getConfig()) |cfg| { + if (cfg.get().@"quick-terminal-autohide" and self.as(gtk.Window).isActive() == 0) { + self.toggleVisibility(); + } + } + } + // Don't change urgency if we're not the active window. if (self.as(gtk.Window).isActive() == 0) return; @@ -1585,6 +1593,9 @@ pub const Window = extern struct { // Grab focus surface.grabFocus(); + + // Bring the window to the front. + self.as(gtk.Window).present(); } fn surfaceToggleFullscreen( @@ -1789,7 +1800,7 @@ pub const Window = extern struct { _: ?*glib.Variant, self: *Window, ) callconv(.c) void { - self.performBindingAction(.copy_to_clipboard); + self.performBindingAction(.{ .copy_to_clipboard = .mixed }); } fn actionPaste( 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 84e00ac4a..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; @@ -174,6 +191,7 @@ template $GhosttySurface: Adw.Bin { notify::mouse-hover-url => $notify_mouse_hover_url(); notify::mouse-hidden => $notify_mouse_hidden(); notify::mouse-shape => $notify_mouse_shape(); + notify::vadjustment => $notify_vadjustment(); // Some history: we used to use a Stack here and swap between the // terminal and error pages as needed. But a Stack doesn't play nice // with our SplitTree and Gtk.Paned usage[^1]. Replacing this with diff --git a/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp b/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp new file mode 100644 index 000000000..722c4427b --- /dev/null +++ b/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp @@ -0,0 +1,11 @@ +using Gtk 4.0; +using Adw 1; + +template $GhostttySurfaceScrolledWindow: Adw.Bin { + notify::surface => $notify_surface(); + + Gtk.ScrolledWindow { + hscrollbar-policy: never; + vscrollbar-policy: bind $scrollbar_policy(template.config) as ; + } +} 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..1e73c6139 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"); @@ -175,6 +173,12 @@ pub const Window = struct { blur_region: Region = .{}, + // Cache last applied values to avoid redundant X11 property updates. + // Redundant property updates seem to cause some visual glitches + // with some window managers: https://github.com/ghostty-org/ghostty/pull/8075 + last_applied_blur_region: ?Region = null, + last_applied_decoration_hints: ?MotifWMHints = null, + pub fn init( alloc: Allocator, app: *App, @@ -257,30 +261,42 @@ pub const Window = struct { const gtk_widget = self.apprt_window.as(gtk.Widget); const config = if (self.apprt_window.getConfig()) |v| v.get() else return; + // When blur is disabled, remove the property if it was previously set + const blur = config.@"background-blur"; + if (!blur.enabled()) { + if (self.last_applied_blur_region != null) { + try self.deleteProperty(self.app.atoms.kde_blur); + self.last_applied_blur_region = null; + } + + return; + } + // Transform surface coordinates to device coordinates. const scale = gtk_widget.getScaleFactor(); self.blur_region.width = gtk_widget.getWidth() * scale; self.blur_region.height = gtk_widget.getHeight() * scale; - const blur = config.@"background-blur"; + // Only update X11 properties when the blur region actually changes + if (self.last_applied_blur_region) |last| { + if (std.meta.eql(self.blur_region, last)) return; + } + log.debug("set blur={}, window xid={}, region={}", .{ blur, self.x11_surface.getXid(), self.blur_region, }); - if (blur.enabled()) { - try self.changeProperty( - Region, - self.app.atoms.kde_blur, - c.XA_CARDINAL, - ._32, - .{ .mode = .replace }, - &self.blur_region, - ); - } else { - try self.deleteProperty(self.app.atoms.kde_blur); - } + try self.changeProperty( + Region, + self.app.atoms.kde_blur, + c.XA_CARDINAL, + ._32, + .{ .mode = .replace }, + &self.blur_region, + ); + self.last_applied_blur_region = self.blur_region; } fn syncDecorations(self: *Window) !void { @@ -309,6 +325,11 @@ pub const Window = struct { .auto, .client, .none => false, }; + // Only update decoration hints when they actually change + if (self.last_applied_decoration_hints) |last| { + if (std.meta.eql(hints, last)) return; + } + try self.changeProperty( MotifWMHints, self.app.atoms.motif_wm_hints, @@ -317,6 +338,7 @@ pub const Window = struct { .{ .mode = .replace }, &hints, ); + self.last_applied_decoration_hints = hints; } pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { 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/structs.zig b/src/apprt/structs.zig index 89b8c2235..bf14b65a9 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -54,6 +54,11 @@ pub const Clipboard = enum(Backing) { }; }; +pub const ClipboardContent = struct { + mime: [:0]const u8, + data: [:0]const u8, +}; + pub const ClipboardRequestType = enum(u8) { paste, osc_52_read, diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index a46732c16..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 @@ -104,6 +104,15 @@ pub const Message = union(enum) { /// of the command. stop_command: ?u8, + /// 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 dffa5071a..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,13 +89,15 @@ 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 r = f.reader(&read_buf); + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var f_reader = f.reader(&read_buf); + var r = &f_reader.interface; + var d: UTF8Decoder = .{}; var buf: [4096]u8 align(std.atomic.cache_line) = undefined; while (true) { - const n = r.read(&buf) catch |err| { - log.warn("error reading data file err={}", .{err}); + 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 @@ -115,13 +116,15 @@ 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 r = f.reader(&read_buf); + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var f_reader = f.reader(&read_buf); + var r = &f_reader.interface; + var d: UTF8Decoder = .{}; var buf: [4096]u8 align(std.atomic.cache_line) = undefined; while (true) { - const n = r.read(&buf) catch |err| { - log.warn("error reading data file err={}", .{err}); + 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 diff --git a/src/benchmark/OscParser.zig b/src/benchmark/OscParser.zig new file mode 100644 index 000000000..6243aba7d --- /dev/null +++ b/src/benchmark/OscParser.zig @@ -0,0 +1,118 @@ +//! This benchmark tests the throughput of the OSC parser. +const OscParser = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Parser = @import("../terminal/osc.zig").Parser; +const log = std.log.scoped(.@"osc-parser-bench"); + +opts: Options, + +/// The file, opened in the setup function. +data_f: ?std.fs.File = null, + +parser: Parser, + +pub const Options = struct { + /// 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. + data: ?[]const u8 = null, +}; + +/// Create a new terminal stream handler for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*OscParser { + const ptr = try alloc.create(OscParser); + errdefer alloc.destroy(ptr); + ptr.* = .{ + .opts = opts, + .data_f = null, + .parser = .init(alloc), + }; + return ptr; +} + +pub fn destroy(self: *OscParser, alloc: Allocator) void { + self.parser.deinit(); + alloc.destroy(self); +} + +pub fn benchmark(self: *OscParser) Benchmark { + return .init(self, .{ + .stepFn = step, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + + // Open our data file to prepare for reading. We can do more + // validation here eventually. + assert(self.data_f == null); + self.data_f = options.dataFile(self.opts.data) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }; + self.parser.reset(); +} + +fn teardown(ptr: *anyopaque) void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + if (self.data_f) |f| { + f.close(); + self.data_f = null; + } +} + +fn step(ptr: *anyopaque) Benchmark.Error!void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var r = f.reader(&read_buf); + + var osc_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + while (true) { + r.interface.fill(@bitSizeOf(usize) / 8) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + const len = r.interface.takeInt(usize, .little) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + + if (len > osc_buf.len) return error.BenchmarkFailed; + + r.interface.readSliceAll(osc_buf[0..len]) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + + for (osc_buf[0..len]) |c| self.parser.next(c); + _ = self.parser.end(std.ascii.control_code.bel); + self.parser.reset(); + } +} + +test OscParser { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *OscParser = try .create(alloc, .{}); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} 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 ecce509f3..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; @@ -138,8 +138,15 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { const Handler = struct { t: *Terminal, - pub fn print(self: *Handler, cp: u21) !void { - try self.t.print(cp); + pub fn vt( + self: *Handler, + comptime action: Stream.Action.Tag, + value: Stream.Action.Value(action), + ) !void { + switch (action) { + .print => try self.t.print(value.cp), + else => {}, + } } }; diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 3b1c905eb..13f070774 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -8,9 +8,11 @@ const cli = @import("../cli.zig"); pub const Action = enum { @"codepoint-width", @"grapheme-break", + @"screen-clone", @"terminal-parser", @"terminal-stream", @"is-symbol", + @"osc-parser", /// Returns the struct associated with the action. The struct /// should have a few decls: @@ -22,11 +24,13 @@ 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"), .@"terminal-parser" => @import("TerminalParser.zig"), .@"is-symbol" => @import("IsSymbol.zig"), + .@"osc-parser" => @import("OscParser.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/Config.zig b/src/build/Config.zig index 643dfe928..3a8a4e0c7 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -16,13 +16,6 @@ const expandPath = @import("../os/path.zig").expand; const gtk = @import("gtk.zig"); const GitVersion = @import("GitVersion.zig"); -/// The version of the next release. -/// -/// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly. -/// Until then this MUST match build.zig.zon and should always be the -/// _next_ version to release. -const app_version: std.SemanticVersion = .{ .major = 1, .minor = 2, .patch = 1 }; - /// Standard build configuration options. optimize: std.builtin.OptimizeMode, target: std.Build.ResolvedTarget, @@ -62,6 +55,7 @@ emit_macos_app: bool = false, emit_terminfo: bool = false, emit_termcap: bool = false, emit_test_exe: bool = false, +emit_themes: bool = false, emit_xcframework: bool = false, emit_webdata: bool = false, emit_unicode_table_gen: bool = false, @@ -69,7 +63,7 @@ emit_unicode_table_gen: bool = false, /// Environmental properties env: std.process.EnvMap, -pub fn init(b: *std.Build) !Config { +pub fn init(b: *std.Build, appVersion: []const u8) !Config { // Setup our standard Zig target and optimize options, i.e. // `-Doptimize` and `-Dtarget`. const optimize = b.standardOptimizeOption(.{}); @@ -179,7 +173,13 @@ pub fn init(b: *std.Build) !Config { bool, "simd", "Build with SIMD-accelerated code paths. Results in significant performance improvements.", - ) orelse true; + ) orelse simd: { + // We can't build our SIMD dependencies for Wasm. Note that we may + // still use SIMD features in the Wasm-builds. + if (target.result.cpu.arch.isWasm()) break :simd false; + + break :simd true; + }; config.wayland = b.option( bool, @@ -217,6 +217,17 @@ pub fn init(b: *std.Build) !Config { // If an explicit version is given, we always use it. try std.SemanticVersion.parse(v) else version: { + const app_version = try std.SemanticVersion.parse(appVersion); + + // Is ghostty a dependency? If so, skip git detection. + // @src().file won't resolve from b.build_root unless ghostty + // is the project being built. + b.build_root.handle.access(@src().file, .{}) catch break :version .{ + .major = app_version.major, + .minor = app_version.minor, + .patch = app_version.patch, + }; + // If no explicit version is given, we try to detect it from git. const vsn = GitVersion.detect(b) catch |err| switch (err) { // If Git isn't available we just make an unknown dev version. @@ -374,6 +385,12 @@ pub fn init(b: *std.Build) !Config { .ReleaseSafe, .ReleaseFast, .ReleaseSmall => false, }; + config.emit_themes = b.option( + bool, + "emit-themes", + "Install bundled iTerm2-Color-Schemes Ghostty themes", + ) orelse true; + config.emit_webdata = b.option( bool, "emit-webdata", 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 def1dbdb3..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 @@ -43,7 +41,6 @@ pub fn distResources(b: *std.Build) struct { .root_module = b.createModule(.{ .target = b.graph.host, }), - .use_llvm = true, }); exe.addCSourceFile(.{ .file = b.path("src/build/framegen/main.c"), diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 1e57da7b1..aae8ace19 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -1,12 +1,9 @@ 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, @@ -17,7 +14,35 @@ artifact: *std.Build.Step.InstallArtifact, /// The final library file output: std.Build.LazyPath, dsym: ?std.Build.LazyPath, -pkg_config: std.Build.LazyPath, +pkg_config: ?std.Build.LazyPath, + +pub fn initWasm( + b: *std.Build, + zig: *const GhosttyZig, +) !GhosttyLibVt { + const target = zig.vt.resolved_target.?; + assert(target.result.cpu.arch.isWasm()); + + const exe = b.addExecutable(.{ + .name = "ghostty-vt", + .root_module = zig.vt_c, + .version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 }, + }); + + // Allow exported symbols to actually be exported. + exe.rdynamic = true; + + // There is no entrypoint for this wasm module. + exe.entry = .disabled; + + return .{ + .step = &exe.step, + .artifact = b.addInstallArtifact(exe, .{}), + .output = exe.getEmittedBin(), + .dsym = null, + .pkg_config = null, + }; +} pub fn initShared( b: *std.Build, @@ -30,9 +55,10 @@ pub fn initShared( .root_module = zig.vt_c, .version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 }, }); - lib.installHeader( - b.path("include/ghostty/vt.h"), - "ghostty/vt.h", + lib.installHeadersDirectory( + b.path("include/ghostty"), + "ghostty", + .{ .include_extensions = &.{".h"} }, ); // Get our debug symbols @@ -81,9 +107,11 @@ pub fn install( ) void { const b = step.owner; step.dependOn(&self.artifact.step); - step.dependOn(&b.addInstallFileWithDir( - self.pkg_config, - .prefix, - "share/pkgconfig/libghostty-vt.pc", - ).step); + if (self.pkg_config) |pkg_config| { + step.dependOn(&b.addInstallFileWithDir( + pkg_config, + .prefix, + "share/pkgconfig/libghostty-vt.pc", + ).step); + } } diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 7880a98a0..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; @@ -125,14 +126,16 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { } // Themes - if (b.lazyDependency("iterm2_themes", .{})) |upstream| { - const install_step = b.addInstallDirectory(.{ - .source_dir = upstream.path(""), - .install_dir = .{ .custom = "share" }, - .install_subdir = b.pathJoin(&.{ "ghostty", "themes" }), - .exclude_extensions = &.{".md"}, - }); - try steps.append(b.allocator, &install_step.step); + if (cfg.emit_themes) { + if (b.lazyDependency("iterm2_themes", .{})) |upstream| { + const install_step = b.addInstallDirectory(.{ + .source_dir = upstream.path(""), + .install_dir = .{ .custom = "share" }, + .install_subdir = b.pathJoin(&.{ "ghostty", "themes" }), + .exclude_extensions = &.{".md"}, + }); + try steps.append(b.allocator, &install_step.step); + } } // Fish shell completions @@ -225,7 +228,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { // 'ghostty.sublime-syntax' file from zig-out to the '~.config/bat/syntaxes' // directory. The syntax then needs to be mapped to the correct language in // the config file within the '~.config/bat' directory - // (ex: --map-syntax "/Users/user/.config/ghostty/config:Ghostty Config"). + // (ex: --map-syntax "/Users/user/.config/ghostty/config.ghostty:Ghostty Config"). { const run = b.addRunArtifact(build_data_exe); run.addArg("+sublime"); 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/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index 0afb64007..5ca4c5e9a 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -31,7 +31,7 @@ pub fn init( .ReleaseSafe, .ReleaseSmall, .ReleaseFast, - => "Release", + => "ReleaseLocal", }; const xc_arch: ?[]const u8 = switch (deps.xcframework.target) { @@ -151,7 +151,7 @@ pub fn init( // This overrides our default behavior and forces logs to show // up on stderr (in addition to the centralized macOS log). - open.setEnvironmentVariable("GHOSTTY_LOG", "1"); + open.setEnvironmentVariable("GHOSTTY_LOG", "stderr,macos"); // Configure how we're launching open.setEnvironmentVariable("GHOSTTY_MAC_LAUNCH_SOURCE", "zig_run"); diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index dfa676bba..5e2cd40b9 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -719,15 +719,19 @@ pub fn addSimd( } // Highway - if (b.lazyDependency("highway", .{ - .target = target, - .optimize = optimize, - })) |highway_dep| { - m.linkLibrary(highway_dep.artifact("highway")); - if (static_libs) |v| try v.append( - b.allocator, - highway_dep.artifact("highway").getEmittedBin(), - ); + if (b.systemIntegrationOption("highway", .{ .default = false })) { + m.linkSystemLibrary("libhwy", dynamic_link_opts); + } else { + if (b.lazyDependency("highway", .{ + .target = target, + .optimize = optimize, + })) |highway_dep| { + m.linkLibrary(highway_dep.artifact("highway")); + if (static_libs) |v| try v.append( + b.allocator, + highway_dep.artifact("highway").getEmittedBin(), + ); + } } // utfcpp - This is used as a dependency on our hand-written C++ code @@ -746,6 +750,7 @@ pub fn addSimd( m.addIncludePath(b.path("src")); { // From hwy/detect_targets.h + const HWY_AVX10_2: c_int = 1 << 3; const HWY_AVX3_SPR: c_int = 1 << 4; const HWY_AVX3_ZEN4: c_int = 1 << 6; const HWY_AVX3_DL: c_int = 1 << 7; @@ -756,7 +761,7 @@ pub fn addSimd( // The performance difference between AVX2 and AVX512 is not // significant for our use case and AVX512 is very rare on consumer // hardware anyways. - const HWY_DISABLED_TARGETS: c_int = HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3; + const HWY_DISABLED_TARGETS: c_int = HWY_AVX10_2 | HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3; m.addCSourceFiles(.{ .files = &.{ 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/docker/debian/Dockerfile b/src/build/docker/debian/Dockerfile index 815d395cd..ffeef3d6a 100644 --- a/src/build/docker/debian/Dockerfile +++ b/src/build/docker/debian/Dockerfile @@ -24,12 +24,12 @@ RUN DEBIAN_FRONTEND="noninteractive" apt-get -qq update && \ WORKDIR /src -COPY ./build.zig /src +COPY ./build.zig ./build.zig.zon /src/ # Install zig # https://ziglang.org/download/ -RUN export ZIG_VERSION=$(sed -n -e 's/^.*requireZig("\(.*\)").*$/\1/p' build.zig) && curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-$(uname -m)-linux-$ZIG_VERSION.tar.xz" && \ +RUN export ZIG_VERSION=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon) && curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-$(uname -m)-linux-$ZIG_VERSION.tar.xz" && \ tar -xf /tmp/zig.tar.xz -C /opt && \ rm /tmp/zig.tar.xz && \ ln -s "/opt/zig-$(uname -m)-linux-$ZIG_VERSION/zig" /usr/local/bin/zig @@ -41,4 +41,3 @@ RUN zig build \ -Dcpu=baseline RUN ./zig-out/bin/ghostty +version - diff --git a/src/build/docker/lib-c-docs/Dockerfile b/src/build/docker/lib-c-docs/Dockerfile index f30dfba90..a3cfdcc98 100644 --- a/src/build/docker/lib-c-docs/Dockerfile +++ b/src/build/docker/lib-c-docs/Dockerfile @@ -1,17 +1,22 @@ #-------------------------------------------------------------------- # Generate documentation with Doxygen #-------------------------------------------------------------------- -FROM ubuntu:24.04 AS builder +FROM --platform=linux/amd64 archlinux:latest AS builder # Build argument for noindex header ARG ADD_NOINDEX_HEADER=false -RUN apt-get update && apt-get install -y \ +RUN pacman -Syu --noconfirm && \ + pacman -S --noconfirm \ doxygen \ - graphviz \ - && rm -rf /var/lib/apt/lists/* + graphviz && \ + pacman -Scc --noconfirm WORKDIR /ghostty COPY include/ ./include/ +COPY images/ ./images/ +COPY dist/doxygen/ ./dist/doxygen/ +COPY example/ ./example/ COPY Doxyfile ./ +COPY DoxygenLayout.xml ./ RUN mkdir -p zig-out/share/ghostty/doc/libghostty RUN doxygen diff --git a/src/build/docker/lib-c-docs/entrypoint.sh b/src/build/docker/lib-c-docs/entrypoint.sh index 928d6e163..ac9ca1c06 100755 --- a/src/build/docker/lib-c-docs/entrypoint.sh +++ b/src/build/docker/lib-c-docs/entrypoint.sh @@ -6,11 +6,27 @@ server { location / { root /usr/share/nginx/html; index index.html; + etag on; + add_header Cache-Control "no-cache" always; add_header X-Robots-Tag "noindex, nofollow" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always; } } EOF # Remove default server config rm -f /etc/nginx/conf.d/default.conf +else + cat > /etc/nginx/conf.d/default.conf << 'EOF' +server { + listen 80; + location / { + root /usr/share/nginx/html; + index index.html; + etag on; + add_header Cache-Control "no-cache" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always; + } +} +EOF fi exec nginx -g "daemon off;" diff --git a/src/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md index f8e502b45..a63a85fd4 100644 --- a/src/build/mdgen/ghostty_1_footer.md +++ b/src/build/mdgen/ghostty_1_footer.md @@ -1,15 +1,15 @@ # FILES -_\$XDG_CONFIG_HOME/ghostty/config_ +_\$XDG_CONFIG_HOME/ghostty/config.ghostty_ : Location of the default configuration file. -_\$HOME/Library/Application Support/com.mitchellh.ghostty/config_ +_\$HOME/Library/Application Support/com.mitchellh.ghostty/config.ghostty_ : **On macOS**, location of the default configuration file. This location takes precedence over the XDG environment locations. -_\$LOCALAPPDATA/ghostty/config_ +_\$LOCALAPPDATA/ghostty/config.ghostty_ : **On Windows**, if _\$XDG_CONFIG_HOME_ is not set, _\$LOCALAPPDATA_ will be searched for configuration files. @@ -37,6 +37,19 @@ precedence over the XDG environment locations. : **WINDOWS ONLY:** alternate location to search for configuration files. +**GHOSTTY_LOG** + +: The `GHOSTTY_LOG` environment variable can be used to control which +destinations receive logs. Ghostty currently defines two destinations: + +: - `stderr` - logging to `stderr`. +: - `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +: Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. + # BUGS See GitHub issues: diff --git a/src/build/mdgen/ghostty_5_footer.md b/src/build/mdgen/ghostty_5_footer.md index 380d83a53..d2cf024d1 100644 --- a/src/build/mdgen/ghostty_5_footer.md +++ b/src/build/mdgen/ghostty_5_footer.md @@ -1,15 +1,15 @@ # FILES -_\$XDG_CONFIG_HOME/ghostty/config_ +_\$XDG_CONFIG_HOME/ghostty/config.ghostty_ : Location of the default configuration file. -_\$HOME/Library/Application Support/com.mitchellh.ghostty/config_ +_\$HOME/Library/Application Support/com.mitchellh.ghostty/config.ghostty_ : **On macOS**, location of the default configuration file. This location takes precedence over the XDG environment locations. -_\$LOCALAPPDATA/ghostty/config_ +_\$LOCALAPPDATA/ghostty/config.ghostty_ : **On Windows**, if _\$XDG_CONFIG_HOME_ is not set, _\$LOCALAPPDATA_ will be searched for configuration files. diff --git a/src/build/mdgen/ghostty_5_header.md b/src/build/mdgen/ghostty_5_header.md index 078133861..2b12f546a 100644 --- a/src/build/mdgen/ghostty_5_header.md +++ b/src/build/mdgen/ghostty_5_header.md @@ -8,11 +8,11 @@ To configure Ghostty, you must use a configuration file. GUI-based configuration is on the roadmap but not yet supported. The configuration file must be placed -at `$XDG_CONFIG_HOME/ghostty/config`, which defaults to `~/.config/ghostty/config` +at `$XDG_CONFIG_HOME/ghostty/config.ghostty`, which defaults to `~/.config/ghostty/config.ghostty` if the [XDG environment is not set](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html). **If you are using macOS, the configuration file can also be placed at -`$HOME/Library/Application Support/com.mitchellh.ghostty/config`.** This is the +`$HOME/Library/Application Support/com.mitchellh.ghostty/config.ghostty`.** This is the default configuration location for macOS. It will be searched before any of the XDG environment locations listed above. @@ -73,16 +73,12 @@ the public config files of many Ghostty users for examples and inspiration. ## Configuration Errors If your configuration file has any errors, Ghostty does its best to ignore -them and move on. Configuration errors currently show up in the log. The log -is written directly to stderr, so it is up to you to figure out how to access -that for your system (for now). On macOS, you can also use the system `log` CLI -utility with `log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`. +them and move on. Configuration errors will be logged. ## Debugging Configuration You can verify that configuration is being properly loaded by looking at the -debug output of Ghostty. Documentation for how to view the debug output is in -the "building Ghostty" section at the end of the README. +debug output of Ghostty. In the debug output, you should see in the first 20 lines or so messages about loading (or not loading) a configuration file, as well as any errors it may have @@ -93,3 +89,34 @@ will fall back to default values for erroneous keys. You can also view the full configuration Ghostty is loading using `ghostty +show-config` from the command-line. Use the `--help` flag to additional options for that command. + +## Logging + +Ghostty can write logs to a number of destinations. On all platforms, logging to +`stderr` is available. Depending on the platform and how Ghostty was launched, +logs sent to `stderr` may be stored by the system and made available for later +retrieval. + +On Linux if Ghostty is launched by the default `systemd` user service, you can use +`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`. + +On macOS logging to the macOS unified log is available and enabled by default. +--Use the system `log` CLI to view Ghostty's logs: `sudo log stream level debug +--predicate 'subsystem=="com.mitchellh.ghostty"'`. + +Ghostty's logging can be configured in two ways. The first is by what +optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug` +optimizations debug logs will be output to `stderr`. If Ghostty is compiled with +any other optimization the debug logs will not be output to `stderr`. + +Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used +to control which destinations receive logs. Ghostty currently defines two +destinations: + +- `stderr` - logging to `stderr`. +- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. diff --git a/src/build/uucode_config.zig b/src/build/uucode_config.zig index 085ca2561..2fadbdb78 100644 --- a/src/build/uucode_config.zig +++ b/src/build/uucode_config.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const assert = std.debug.assert; const config = @import("config.zig"); const config_x = @import("config.x.zig"); const d = config.default; @@ -17,11 +18,25 @@ fn computeWidth( _ = cp; _ = backing; _ = tracking; - data.width = @intCast(@min(2, @max(0, data.wcwidth))); + + // 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); + } } const width = config.Extension{ - .inputs = &.{"wcwidth"}, + .inputs = &.{ + "wcwidth_standalone", + "wcwidth_zero_in_grapheme", + "is_emoji_modifier", + }, .compute = &computeWidth, .fields = &.{ .{ .name = "width", .type = u2 }, @@ -41,6 +56,7 @@ fn computeIsSymbol( _ = tracking; const block = data.block; data.is_symbol = data.general_category == .other_private_use or + block == .arrows or block == .dingbats or block == .emoticons or block == .miscellaneous_symbols or @@ -74,8 +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_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/zig.zig b/src/build/zig.zig index 7e327127d..3ee8ffe74 100644 --- a/src/build/zig.zig +++ b/src/build/zig.zig @@ -7,10 +7,11 @@ pub fn requireZig(comptime required_zig: []const u8) void { const current_vsn = builtin.zig_version; const required_vsn = std.SemanticVersion.parse(required_zig) catch unreachable; if (current_vsn.major != required_vsn.major or - current_vsn.minor != required_vsn.minor) + current_vsn.minor != required_vsn.minor or + current_vsn.patch < required_vsn.patch) { @compileError(std.fmt.comptimePrint( - "Your Zig version v{} does not meet the required build version of v{}", + "Your Zig version v{f} does not meet the required build version of v{f}", .{ current_vsn, required_vsn }, )); } 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 a34560b78..bd5060d69 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"); @@ -604,7 +604,7 @@ pub fn parseAutoStruct( return result; } -fn parsePackedStruct(comptime T: type, v: []const u8) !T { +pub fn parsePackedStruct(comptime T: type, v: []const u8) !T { const info = @typeInfo(T).@"struct"; comptime assert(info.layout == .@"packed"); @@ -1427,7 +1427,12 @@ pub const LineIterator = struct { // // This will also optimize reads down the line as we're // more likely to beworking with buffered data. - self.r.fillMore() catch {}; + // + // fillMore asserts that the buffer has available capacity, + // so skip this if it's full. + if (self.r.bufferedLen() < self.r.buffer.len) { + self.r.fillMore() catch {}; + } var writer: std.Io.Writer = .fixed(self.entry[2..]); @@ -1590,3 +1595,33 @@ test "LineIterator with CRLF line endings" { try testing.expectEqual(@as(?[]const u8, null), iter.next()); try testing.expectEqual(@as(?[]const u8, null), iter.next()); } + +test "LineIterator with buffered reader" { + const testing = std.testing; + var f: std.Io.Reader = .fixed("A\nB = C\n"); + var buf: [2]u8 = undefined; + var r = f.limited(.unlimited, &buf); + const reader = &r.interface; + + var iter: LineIterator = .init(reader); + try testing.expectEqualStrings("--A", iter.next().?); + try testing.expectEqualStrings("--B=C", iter.next().?); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); +} + +test "LineIterator with buffered and primed reader" { + const testing = std.testing; + var f: std.Io.Reader = .fixed("A\nB = C\n"); + var buf: [2]u8 = undefined; + var r = f.limited(.unlimited, &buf); + const reader = &r.interface; + + try reader.fill(buf.len); + + var iter: LineIterator = .init(reader); + try testing.expectEqualStrings("--A", iter.next().?); + try testing.expectEqualStrings("--B=C", iter.next().?); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); +} diff --git a/src/cli/boo.zig b/src/cli/boo.zig index 756b6d77a..2834eadbd 100644 --- a/src/cli/boo.zig +++ b/src/cli/boo.zig @@ -3,10 +3,9 @@ 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 = @embedFile("framedata"); +const framedata = @import("framedata").compressed; const vxfw = vaxis.vxfw; 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 f103ca4a0..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; @@ -30,9 +30,9 @@ pub const Options = struct { /// this yet. /// /// The filepath opened is the default user-specific configuration -/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config`. +/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config.ghostty`. /// On macOS, this may also be located at -/// `~/Library/Application Support/com.mitchellh.ghostty/config`. +/// `~/Library/Application Support/com.mitchellh.ghostty/config.ghostty`. /// On macOS, whichever path exists and is non-empty will be prioritized, /// prioritizing the Application Support directory if neither are /// non-empty. @@ -73,7 +73,7 @@ fn runInner(alloc: Allocator, stderr: *std.Io.Writer) !u8 { defer config.deinit(); // Find the preferred path. - const path = try Config.preferredDefaultFilePath(alloc); + const path = try configpkg.preferredDefaultFilePath(alloc); defer alloc.free(path); // We don't currently support Windows because we use the exec syscall. diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 9b11947df..716d662b6 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 @@ -266,7 +270,7 @@ const Preview = struct { .hex = false, .mode = .normal, .color_scheme = .light, - .text_input = .init(allocator, &self.vx.unicode), + .text_input = .init(allocator), .theme_filter = theme_filter, }; diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index 608155dfd..62620ecb0 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -5,10 +5,11 @@ 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 xdg = @import("../../os/main.zig").xdg; -const TempDir = @import("../../os/main.zig").TempDir; +const internal_os = @import("../../os/main.zig"); +const xdg = internal_os.xdg; +const TempDir = internal_os.TempDir; const Entry = @import("Entry.zig"); // 512KB - sufficient for approximately 10k entries @@ -69,7 +70,7 @@ pub fn add( // Create cache directory if needed if (std.fs.path.dirname(self.path)) |dir| { - std.fs.makeDirAbsolute(dir) catch |err| switch (err) { + std.fs.cwd().makePath(dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; @@ -180,13 +181,12 @@ pub fn contains( // Open our file const file = std.fs.openFileAbsolute( self.path, - .{ .mode = .read_write }, + .{}, ) catch |err| switch (err) { error.FileNotFound => return false, else => return err, }; defer file.close(); - try fixupPermissions(file); // Read existing entries var entries = try readEntries(alloc, file); @@ -327,55 +327,34 @@ fn readEntries( // Supports both standalone hostnames and user@hostname format fn isValidCacheKey(key: []const u8) bool { - // 253 + 1 + 64 for user@hostname - if (key.len == 0 or key.len > 320) return false; + if (key.len == 0) return false; // Check for user@hostname format - if (std.mem.indexOf(u8, key, "@")) |at_pos| { + if (std.mem.indexOfScalar(u8, key, '@')) |at_pos| { const user = key[0..at_pos]; const hostname = key[at_pos + 1 ..]; - return isValidUser(user) and isValidHostname(hostname); + return isValidUser(user) and isValidHost(hostname); } - return isValidHostname(key); + return isValidHost(key); } -// Basic hostname validation - accepts domains and IPs -// (including IPv6 in brackets) -fn isValidHostname(host: []const u8) bool { - if (host.len == 0 or host.len > 253) return false; - - // Handle IPv6 addresses in brackets - if (host.len >= 4 and host[0] == '[' and host[host.len - 1] == ']') { - const ipv6_part = host[1 .. host.len - 1]; - if (ipv6_part.len == 0) return false; - var has_colon = false; - for (ipv6_part) |c| { - switch (c) { - 'a'...'f', 'A'...'F', '0'...'9' => {}, - ':' => has_colon = true, - else => return false, - } - } - return has_colon; +// Checks if a host is a valid hostname or IP address +fn isValidHost(host: []const u8) bool { + // First check for valid hostnames because this is assumed to be the more + // likely ssh host format. + if (internal_os.hostname.isValid(host)) { + return true; } - // Standard hostname/domain validation - for (host) |c| { - switch (c) { - 'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {}, - else => return false, - } - } - - // No leading/trailing dots or hyphens, no consecutive dots - if (host[0] == '.' or host[0] == '-' or - host[host.len - 1] == '.' or host[host.len - 1] == '-') - { + // We also accept valid IP addresses. In practice, IPv4 addresses are also + // considered valid hostnames due to their overlapping syntax, so we can + // simplify this check to be IPv6-specific. + if (std.net.Address.parseIp6(host, 0)) |_| { + return true; + } else |_| { return false; } - - return std.mem.indexOf(u8, host, "..") == null; } fn isValidUser(user: []const u8) bool { @@ -474,98 +453,73 @@ test "disk cache operations" { ); } -// Tests -test "hostname validation - valid cases" { +test isValidHost { const testing = std.testing; - try testing.expect(isValidHostname("example.com")); - try testing.expect(isValidHostname("sub.example.com")); - try testing.expect(isValidHostname("host-name.domain.org")); - try testing.expect(isValidHostname("192.168.1.1")); - try testing.expect(isValidHostname("a")); - try testing.expect(isValidHostname("1")); + + // Valid hostnames + try testing.expect(isValidHost("localhost")); + try testing.expect(isValidHost("example.com")); + try testing.expect(isValidHost("sub.example.com")); + + // IPv4 addresses + try testing.expect(isValidHost("127.0.0.1")); + try testing.expect(isValidHost("192.168.1.1")); + + // IPv6 addresses + try testing.expect(isValidHost("::1")); + try testing.expect(isValidHost("2001:db8::1")); + try testing.expect(isValidHost("2001:db8:0:1:1:1:1:1")); + try testing.expect(!isValidHost("fe80::1%eth0")); // scopes not supported + + // Invalid hosts + try testing.expect(!isValidHost("")); + try testing.expect(!isValidHost("host\nname")); + try testing.expect(!isValidHost(".example.com")); + try testing.expect(!isValidHost("host..domain")); + try testing.expect(!isValidHost("-hostname")); + try testing.expect(!isValidHost("hostname-")); + try testing.expect(!isValidHost("host name")); + try testing.expect(!isValidHost("host_name")); + try testing.expect(!isValidHost("host@domain")); + try testing.expect(!isValidHost("host:port")); } -test "hostname validation - IPv6 addresses" { +test isValidUser { const testing = std.testing; - try testing.expect(isValidHostname("[::1]")); - try testing.expect(isValidHostname("[2001:db8::1]")); - try testing.expect(!isValidHostname("[fe80::1%eth0]")); // Interface notation not supported - try testing.expect(!isValidHostname("[]")); // Empty IPv6 - try testing.expect(!isValidHostname("[invalid]")); // No colons -} -test "hostname validation - invalid cases" { - const testing = std.testing; - try testing.expect(!isValidHostname("")); - try testing.expect(!isValidHostname("host\nname")); - try testing.expect(!isValidHostname(".example.com")); - try testing.expect(!isValidHostname("example.com.")); - try testing.expect(!isValidHostname("host..domain")); - try testing.expect(!isValidHostname("-hostname")); - try testing.expect(!isValidHostname("hostname-")); - try testing.expect(!isValidHostname("host name")); - try testing.expect(!isValidHostname("host_name")); - try testing.expect(!isValidHostname("host@domain")); - try testing.expect(!isValidHostname("host:port")); - - // Too long - const long_host = "a" ** 254; - try testing.expect(!isValidHostname(long_host)); -} - -test "user validation - valid cases" { - const testing = std.testing; + // Valid try testing.expect(isValidUser("user")); - try testing.expect(isValidUser("deploy")); - try testing.expect(isValidUser("test-user")); + try testing.expect(isValidUser("user-user")); try testing.expect(isValidUser("user_name")); try testing.expect(isValidUser("user.name")); try testing.expect(isValidUser("user123")); - try testing.expect(isValidUser("a")); -} -test "user validation - complex realistic cases" { - const testing = std.testing; - try testing.expect(isValidUser("git")); - try testing.expect(isValidUser("ubuntu")); - try testing.expect(isValidUser("root")); - try testing.expect(isValidUser("service.account")); - try testing.expect(isValidUser("user-with-dashes")); -} - -test "user validation - invalid cases" { - const testing = std.testing; + // Invalid try testing.expect(!isValidUser("")); try testing.expect(!isValidUser("user name")); - try testing.expect(!isValidUser("user@domain")); + try testing.expect(!isValidUser("user@example")); try testing.expect(!isValidUser("user:group")); try testing.expect(!isValidUser("user\nname")); - - // Too long - const long_user = "a" ** 65; - try testing.expect(!isValidUser(long_user)); + try testing.expect(!isValidUser("a" ** 65)); // too long } -test "cache key validation - hostname format" { +test isValidCacheKey { const testing = std.testing; + + // Valid try testing.expect(isValidCacheKey("example.com")); try testing.expect(isValidCacheKey("sub.example.com")); try testing.expect(isValidCacheKey("192.168.1.1")); - try testing.expect(isValidCacheKey("[::1]")); - try testing.expect(!isValidCacheKey("")); - try testing.expect(!isValidCacheKey(".invalid.com")); -} - -test "cache key validation - user@hostname format" { - const testing = std.testing; + try testing.expect(isValidCacheKey("::1")); try testing.expect(isValidCacheKey("user@example.com")); - try testing.expect(isValidCacheKey("deploy@prod.server.com")); - try testing.expect(isValidCacheKey("test-user@192.168.1.1")); - try testing.expect(isValidCacheKey("user_name@host.domain.org")); - try testing.expect(isValidCacheKey("git@github.com")); - try testing.expect(isValidCacheKey("ubuntu@[::1]")); + try testing.expect(isValidCacheKey("user@192.168.1.1")); + try testing.expect(isValidCacheKey("user@::1")); + + // Invalid + try testing.expect(!isValidCacheKey("")); + try testing.expect(!isValidCacheKey(".example.com")); try testing.expect(!isValidCacheKey("@example.com")); try testing.expect(!isValidCacheKey("user@")); - try testing.expect(!isValidCacheKey("user@@host")); - try testing.expect(!isValidCacheKey("user@.invalid.com")); + try testing.expect(!isValidCacheKey("user@@example")); + try testing.expect(!isValidCacheKey("user@.example.com")); } 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.zig b/src/config.zig index 569d4bec2..4abd319a6 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1,5 +1,6 @@ const builtin = @import("builtin"); +const file_load = @import("config/file_load.zig"); const formatter = @import("config/formatter.zig"); pub const Config = @import("config/Config.zig"); pub const conditional = @import("config/conditional.zig"); @@ -12,6 +13,7 @@ pub const ConditionalState = conditional.State; pub const FileFormatter = formatter.FileFormatter; pub const entryFormatter = formatter.entryFormatter; pub const formatEntry = formatter.formatEntry; +pub const preferredDefaultFilePath = file_load.preferredDefaultFilePath; // Field types pub const BoldColor = Config.BoldColor; @@ -29,7 +31,6 @@ pub const Keybinds = Config.Keybinds; pub const MouseShiftCapture = Config.MouseShiftCapture; pub const MouseScrollMultiplier = Config.MouseScrollMultiplier; pub const NonNativeFullscreen = Config.NonNativeFullscreen; -pub const OptionAsAlt = Config.OptionAsAlt; pub const RepeatableCodepointMap = Config.RepeatableCodepointMap; pub const RepeatableFontVariation = Config.RepeatableFontVariation; pub const RepeatableString = Config.RepeatableString; 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 new file mode 100644 index 000000000..fbe539127 --- /dev/null +++ b/src/config/ClipboardCodepointMap.zig @@ -0,0 +1,44 @@ +/// ClipboardCodepointMap is a map of codepoints to replacement values +/// for clipboard operations. When copying text to clipboard, matching +/// codepoints will be replaced with their mapped values. +const ClipboardCodepointMap = @This(); + +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; + +// To ease our usage later, we map it directly to formatter entries. +pub const Entry = @import("../terminal/formatter.zig").CodepointMap; +pub const Replacement = Entry.Replacement; + +/// The list of entries. We use a multiarraylist for cache-friendly lookups. +/// +/// Note: we do a linear search because we expect to always have very +/// few entries, so the overhead of a binary search is not worth it. +list: std.MultiArrayList(Entry) = .{}, + +pub fn deinit(self: *ClipboardCodepointMap, alloc: Allocator) void { + self.list.deinit(alloc); +} + +/// Deep copy of the struct. The given allocator is expected to +/// be an arena allocator of some sort since the struct itself +/// doesn't support fine-grained deallocation of fields. +pub fn clone(self: *const ClipboardCodepointMap, alloc: Allocator) !ClipboardCodepointMap { + var list = try self.list.clone(alloc); + for (list.items(.replacement)) |*r| switch (r.*) { + .string => |s| r.string = try alloc.dupe(u8, s), + .codepoint => {}, // no allocation needed + }; + + return .{ .list = list }; +} + +/// Add an entry to the map. +/// +/// For conflicting codepoints, entries added later take priority over +/// entries added earlier. +pub fn add(self: *ClipboardCodepointMap, alloc: Allocator, entry: Entry) !void { + assert(entry.range[0] <= entry.range[1]); + try self.list.append(alloc, entry); +} diff --git a/src/config/Config.zig b/src/config/Config.zig index caaf5feb8..409e35516 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; @@ -24,12 +24,11 @@ const cli = @import("../cli.zig"); const conditional = @import("conditional.zig"); const Conditional = conditional.Conditional; +const file_load = @import("file_load.zig"); 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; @@ -37,6 +36,7 @@ const RepeatableReadableIO = @import("io.zig").RepeatableReadableIO; const RepeatableStringMap = @import("RepeatableStringMap.zig"); pub const Path = @import("path.zig").Path; pub const RepeatablePath = @import("path.zig").RepeatablePath; +const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig"); // We do this instead of importing all of terminal/main.zig to // limit the dependency graph. This is important because some things @@ -278,6 +278,30 @@ pub const compatibility = std.StaticStringMap( /// i.e. new windows, tabs, etc. @"font-codepoint-map": RepeatableCodepointMap = .{}, +/// Map specific Unicode codepoints to replacement values when copying text +/// to clipboard. +/// +/// This configuration allows you to replace specific Unicode characters with +/// other characters or strings when copying terminal content to the clipboard. +/// This is useful for converting special terminal symbols to more compatible +/// characters for pasting into other applications. +/// +/// The syntax is similar to `font-codepoint-map`: +/// - Single codepoint: `U+1234=U+ABCD` or `U+1234=replacement_text` +/// - Codepoint range: `U+1234-U+5678=U+ABCD` +/// +/// Examples: +/// - `clipboard-codepoint-map = U+2500=U+002D` (box drawing horizontal → hyphen) +/// - `clipboard-codepoint-map = U+2502=U+007C` (box drawing vertical → pipe) +/// - `clipboard-codepoint-map = U+03A3=SUM` (Greek sigma → "SUM") +/// +/// This configuration can be repeated multiple times to specify multiple +/// mappings. Later entries take priority over earlier ones for overlapping +/// ranges. +/// +/// Note: This only applies to text copying operations, not URL copying. +@"clipboard-codepoint-map": RepeatableClipboardCodepointMap = .{}, + /// Draw fonts with a thicker stroke, if supported. /// This is currently only supported on macOS. @"font-thicken": bool = false, @@ -412,16 +436,13 @@ pub const compatibility = std.StaticStringMap( @"adjust-box-thickness": ?MetricModifier = null, /// Height in pixels or percentage adjustment of maximum height for nerd font icons. /// -/// Increasing this value will allow nerd font icons to be larger, but won't -/// necessarily force them to be. Decreasing this value will make nerd font -/// icons smaller. +/// A positive (negative) value will increase (decrease) the maximum icon +/// height. This may not affect all icons equally: the effect depends on whether +/// the default size of the icon is height-constrained, which in turn depends on +/// the aspect ratio of both the icon and your primary font. /// -/// This value only applies to icons that are constrained to a single cell by -/// neighboring characters. An icon that is free to spread across two cells -/// can always use up to the full line height of the primary font. -/// -/// The default value is 2/3 times the height of capital letters in your primary -/// font plus 1/3 times the font's line height. +/// Certain icons designed for box drawing and terminal graphics, such as +/// Powerline symbols, are not affected by this option. /// /// See the notes about adjustments in `adjust-cell-width`. /// @@ -478,6 +499,11 @@ pub const compatibility = std.StaticStringMap( /// /// * `autohint` - Enable the freetype auto-hinter. Enabled by default. /// +/// * `light` - Use a light hinting style, better preserving glyph shapes. +/// This is the most common setting in GTK apps and therefore also Ghostty's +/// default. This has no effect if `monochrome` is enabled. Enabled by +/// default. +/// /// Example: `hinting`, `no-hinting`, `force-autohint`, `no-force-autohint` @"freetype-load-flags": FreetypeLoadFlags = .{}, @@ -696,7 +722,8 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// Color palette for the 256 color form that many terminal applications use. /// The syntax of this configuration is `N=COLOR` where `N` is 0 to 255 (for /// the 256 colors in the terminal color table) and `COLOR` is a typical RGB -/// color code such as `#AABBCC` or `AABBCC`, or a named X11 color. +/// color code such as `#AABBCC` or `AABBCC`, or a named X11 color. For example, +/// `palette = 5=#BB78D9` will set the 'purple' color. /// /// The palette index can be in decimal, binary, octal, or hexadecimal. /// Decimal is assumed unless a prefix is used: `0b` for binary, `0o` for octal, @@ -836,6 +863,18 @@ palette: Palette = .{}, /// * `never` @"mouse-shift-capture": MouseShiftCapture = .false, +/// Enable or disable mouse reporting. When set to `false`, mouse events will +/// not be reported to terminal applications even if they request it. This +/// allows you to always use the mouse for selection and other terminal UI +/// interactions without applications capturing mouse input. +/// +/// When set to `true` (the default), terminal applications can request mouse +/// reporting and will receive mouse events according to their requested mode. +/// +/// This can be toggled at runtime using the `toggle_mouse_reporting` keybind +/// action. +@"mouse-reporting": bool = true, + /// Multiplier for scrolling distance with the mouse wheel. /// /// A prefix of `precision:` or `discrete:` can be used to set the multiplier @@ -888,6 +927,15 @@ palette: Palette = .{}, /// reasonable for a good looking blur. Higher blur intensities may /// cause strange rendering and performance issues. /// +/// On macOS 26.0 and later, there are additional special values that +/// can be set to use the native macOS glass effects: +/// +/// * `macos-glass-regular` - Standard glass effect with some opacity +/// * `macos-glass-clear` - Highly transparent glass effect +/// +/// If the macOS values are set, then this implies `background-blur = true` +/// on non-macOS platforms. +/// /// Supported on macOS and on some Linux desktop environments, including: /// /// * KDE Plasma (Wayland and X11) @@ -937,6 +985,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: @@ -1199,6 +1276,24 @@ input: RepeatableReadableIO = .{}, /// This can be changed at runtime but will only affect new terminal surfaces. @"scrollback-limit": usize = 10_000_000, // 10MB +/// Control when the scrollbar is shown to scroll the scrollback buffer. +/// +/// The default value is `system`. +/// +/// Valid values: +/// +/// * `system` - Respect the system settings for when to show scrollbars. +/// For example, on macOS, this will respect the "Scrollbar behavior" +/// system setting which by default usually only shows scrollbars while +/// actively scrolling or hovering the gutter. +/// +/// * `never` - Never show a scrollbar. You can still scroll using the mouse, +/// keybind actions, etc. but you will not have a visual UI widget showing +/// a scrollbar. +/// +/// This only applies to macOS currently. GTK doesn't yet support scrollbars. +scrollbar: Scrollbar = .system, + /// Match a regular expression against the terminal text and associate clicking /// it with an action. This can be used to match URLs, file paths, etc. Actions /// can be opening using the system opener (e.g. `open` or `xdg-open`) or @@ -1243,7 +1338,7 @@ maximize: bool = false, /// new windows, not just the first one. /// /// On macOS, this setting does not work if window-decoration is set to -/// "false", because native fullscreen on macOS requires window decorations +/// "none", because native fullscreen on macOS requires window decorations /// to be set. fullscreen: bool = false, @@ -1706,7 +1801,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: @@ -1732,7 +1827,7 @@ keybind: Keybinds = .{}, /// * `ghostty` - Use the background and foreground colors specified in the /// Ghostty configuration. This is only supported on Linux builds. /// -/// On macOS, if `macos-titlebar-style` is "tabs", the window theme will be +/// On macOS, if `macos-titlebar-style` is `tabs` or `transparent`, the window theme will be /// automatically set based on the luminosity of the terminal background color. /// This only applies to terminal windows. This setting will still apply to /// non-terminal windows within Ghostty. @@ -1973,7 +2068,9 @@ keybind: Keybinds = .{}, @"clipboard-write": ClipboardAccess = .allow, /// Trims trailing whitespace on data that is copied to the clipboard. This does -/// not affect data sent to the clipboard via `clipboard-write`. +/// not affect data sent to the clipboard via `clipboard-write`. This only +/// applies to trailing whitespace on lines that have other characters. +/// Completely blank lines always have their whitespace trimmed. @"clipboard-trim-trailing-spaces": bool = true, /// Require confirmation before pasting text that appears unsafe. This helps @@ -2074,7 +2171,7 @@ keybind: Keybinds = .{}, /// When this is true, the default configuration file paths will be loaded. /// The default configuration file paths are currently only the XDG -/// config path ($XDG_CONFIG_HOME/ghostty/config). +/// config path ($XDG_CONFIG_HOME/ghostty/config.ghostty). /// /// If this is false, the default configuration paths will not be loaded. /// This is targeted directly at using Ghostty from the CLI in a way @@ -2613,7 +2710,9 @@ keybind: Keybinds = .{}, /// This could result in an audiovisual effect, a notification, or something /// else entirely. Changing these effects require altering system settings: /// for instance under the "Sound > Alert Sound" setting in GNOME, -/// or the "Accessibility > System Bell" settings in KDE Plasma. (GTK only) +/// or the "Accessibility > System Bell" settings in KDE Plasma. +/// +/// On macOS, this plays the system alert sound. /// /// * `audio` /// @@ -2735,7 +2834,7 @@ keybind: Keybinds = .{}, /// also known as the traffic lights, that allow you to close, miniaturize, and /// zoom the window. /// -/// This setting has no effect when `window-decoration = false` or +/// This setting has no effect when `window-decoration = none` or /// `macos-titlebar-style = hidden`, as the window buttons are always hidden in /// these modes. /// @@ -2776,7 +2875,7 @@ keybind: Keybinds = .{}, /// macOS 14 does not have this issue and any other macOS version has not /// been tested. /// -/// The "hidden" style hides the titlebar. Unlike `window-decoration = false`, +/// The "hidden" style hides the titlebar. Unlike `window-decoration = none`, /// however, it does not remove the frame from the window or cause it to have /// squared corners. Changing to or from this option at run-time may affect /// existing windows in buggy ways. @@ -2861,7 +2960,7 @@ keybind: Keybinds = .{}, /// /// The values `left` or `right` enable this for the left or right *Option* /// key, respectively. -@"macos-option-as-alt": ?OptionAsAlt = null, +@"macos-option-as-alt": ?inputpkg.OptionAsAlt = null, /// Whether to enable the macOS window shadow. The default value is true. /// With some window managers and window transparency settings, you may @@ -2957,9 +3056,6 @@ keybind: Keybinds = .{}, /// Supported formats include PNG, JPEG, and ICNS. /// /// Defaults to `~/.config/ghostty/Ghostty.icns` -/// -/// Note: This configuration is required when `macos-icon` is set to -/// `custom` @"macos-custom-icon": ?[:0]const u8 = null, /// The material to use for the frame of the macOS app icon. @@ -3118,7 +3214,7 @@ else /// manager's simple titlebar. The behavior of this option will vary with your /// window manager. /// -/// This option does nothing when `window-decoration` is false or when running +/// This option does nothing when `window-decoration` is none or when running /// under macOS. @"gtk-titlebar": bool = true, @@ -3403,7 +3499,7 @@ pub fn loadIter( /// `path` must be resolved and absolute. pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void { assert(std.fs.path.isAbsolute(path)); - var file = openFile(path) catch |err| switch (err) { + var file = file_load.open(path) catch |err| switch (err) { error.NotAFile => { log.warn( "config-file {s}: not reading because it is not a file", @@ -3416,15 +3512,78 @@ pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void { }; defer file.close(); + try self.loadFsFile(alloc, &file, path); +} + +/// Load config from the given File. +fn loadFsFile(self: *Config, alloc: Allocator, file: *std.fs.File, path: []const u8) !void { std.log.info("reading configuration file path={s}", .{path}); var buf: [2048]u8 = undefined; var file_reader = file.reader(&buf); const reader = &file_reader.interface; + try self.loadReader(alloc, reader, path); +} + +/// Load config from the given Reader. +fn loadReader(self: *Config, alloc: Allocator, reader: *std.Io.Reader, path: []const u8) !void { + bom: { + // If the file starts with a UTF-8 byte order mark, skip it. + // https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8 + const bom: []const u8 = &.{ 0xef, 0xbb, 0xbf }; + const str = reader.peek(bom.len) catch break :bom; + if (std.mem.eql(u8, str, bom)) { + log.info("skipping UTF-8 byte order mark", .{}); + reader.toss(bom.len); + } + } var iter: cli.args.LineIterator = .{ .r = reader, .filepath = path }; try self.loadIter(alloc, &iter); try self.expandPaths(std.fs.path.dirname(path).?); } +test "handle bom in config files" { + const testing = std.testing; + const alloc = testing.allocator; + + { + const data = "\xef\xbb\xbfabnormal-command-exit-runtime = 2500\n"; + var reader: std.Io.Reader = .fixed(data); + var cfg = try Config.default(alloc); + defer cfg.deinit(); + try cfg.loadReader( + alloc, + &reader, + "/home/ghostty/.config/ghostty/config.ghostty", + ); + try cfg.finalize(); + + try testing.expect(cfg._diagnostics.empty()); + try testing.expectEqual( + 2500, + cfg.@"abnormal-command-exit-runtime", + ); + } + + { + const data = "abnormal-command-exit-runtime = 2500\n"; + var reader: std.Io.Reader = .fixed(data); + var cfg = try Config.default(alloc); + defer cfg.deinit(); + try cfg.loadReader( + alloc, + &reader, + "/home/ghostty/.config/ghostty/config.ghostty", + ); + try cfg.finalize(); + + try testing.expect(cfg._diagnostics.empty()); + try testing.expectEqual( + 2500, + cfg.@"abnormal-command-exit-runtime", + ); + } +} + pub const OptionalFileAction = enum { loaded, not_found, @"error" }; /// Load optional configuration file from `path`. All errors are ignored. @@ -3467,31 +3626,60 @@ fn writeConfigTemplate(path: []const u8) !void { } /// Load configurations from the default configuration files. The default -/// configuration file is at `$XDG_CONFIG_HOME/ghostty/config`. +/// configuration file is at `$XDG_CONFIG_HOME/ghostty/config.ghostty`. /// -/// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/config` +/// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/` /// is also loaded. +/// +/// The legacy `config` file (without extension) is first loaded, +/// then `config.ghostty`. pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { // Load XDG first - const xdg_path = try defaultXdgPath(alloc); + const legacy_xdg_path = try file_load.legacyDefaultXdgPath(alloc); + defer alloc.free(legacy_xdg_path); + const xdg_path = try file_load.defaultXdgPath(alloc); defer alloc.free(xdg_path); - const xdg_action = self.loadOptionalFile(alloc, xdg_path); + const xdg_loaded: bool = xdg_loaded: { + const legacy_xdg_action = self.loadOptionalFile(alloc, legacy_xdg_path); + const xdg_action = self.loadOptionalFile(alloc, xdg_path); + if (xdg_action != .not_found and legacy_xdg_action != .not_found) { + log.warn("both config files `{s}` and `{s}` exist.", .{ legacy_xdg_path, xdg_path }); + log.warn("loading them both in that order", .{}); + break :xdg_loaded true; + } + + break :xdg_loaded xdg_action != .not_found or + legacy_xdg_action != .not_found; + }; // On macOS load the app support directory as well if (comptime builtin.os.tag == .macos) { - const app_support_path = try defaultAppSupportPath(alloc); + const legacy_app_support_path = try file_load.legacyDefaultAppSupportPath(alloc); + defer alloc.free(legacy_app_support_path); + const app_support_path = try file_load.preferredAppSupportPath(alloc); defer alloc.free(app_support_path); - const app_support_action = self.loadOptionalFile(alloc, app_support_path); + const app_support_loaded: bool = loaded: { + const legacy_app_support_action = self.loadOptionalFile(alloc, legacy_app_support_path); + const app_support_action = self.loadOptionalFile(alloc, app_support_path); + if (app_support_action != .not_found and legacy_app_support_action != .not_found) { + log.warn("both config files `{s}` and `{s}` exist.", .{ legacy_app_support_path, app_support_path }); + log.warn("loading them both in that order", .{}); + break :loaded true; + } + + break :loaded app_support_action != .not_found or + legacy_app_support_action != .not_found; + }; // If both files are not found, then we create a template file. // For macOS, we only create the template file in the app support - if (app_support_action == .not_found and xdg_action == .not_found) { + if (!app_support_loaded and !xdg_loaded) { writeConfigTemplate(app_support_path) catch |err| { log.warn("error creating template config file err={}", .{err}); }; } } else { - if (xdg_action == .not_found) { + if (!xdg_loaded) { writeConfigTemplate(xdg_path) catch |err| { log.warn("error creating template config file err={}", .{err}); }; @@ -3499,102 +3687,6 @@ pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { } } -/// Default path for the XDG home configuration file. Returned value -/// must be freed by the caller. -fn defaultXdgPath(alloc: Allocator) ![]const u8 { - return try internal_os.xdg.config( - alloc, - .{ .subdir = "ghostty/config" }, - ); -} - -/// Default path for the macOS Application Support configuration file. -/// Returned value must be freed by the caller. -fn defaultAppSupportPath(alloc: Allocator) ![]const u8 { - return try internal_os.macos.appSupportDir(alloc, "config"); -} - -/// Returns the path to the preferred default configuration file. -/// This is the file where users should place their configuration. -/// -/// This doesn't create or populate the file with any default -/// contents; downstream callers must handle this. -/// -/// The returned value must be freed by the caller. -pub fn preferredDefaultFilePath(alloc: Allocator) ![]const u8 { - switch (builtin.os.tag) { - .macos => { - // macOS prefers the Application Support directory - // if it exists. - const app_support_path = try defaultAppSupportPath(alloc); - if (openFile(app_support_path)) |f| { - f.close(); - return app_support_path; - } else |_| {} - - // Try the XDG path if it exists - const xdg_path = try defaultXdgPath(alloc); - if (openFile(xdg_path)) |f| { - f.close(); - alloc.free(app_support_path); - return xdg_path; - } else |_| {} - defer alloc.free(xdg_path); - - // Neither exist, use app support - return app_support_path; - }, - - // All other platforms use XDG only - else => return try defaultXdgPath(alloc), - } -} - -const OpenFileError = error{ - FileNotFound, - FileIsEmpty, - FileOpenFailed, - NotAFile, -}; - -/// Opens the file at the given path and returns the file handle -/// if it exists and is non-empty. This also constrains the possible -/// errors to a smaller set that we can explicitly handle. -fn openFile(path: []const u8) OpenFileError!std.fs.File { - assert(std.fs.path.isAbsolute(path)); - - var file = std.fs.openFileAbsolute( - path, - .{}, - ) catch |err| switch (err) { - error.FileNotFound => return OpenFileError.FileNotFound, - else => { - log.warn("unexpected file open error path={s} err={}", .{ - path, - err, - }); - return OpenFileError.FileOpenFailed; - }, - }; - errdefer file.close(); - - const stat = file.stat() catch |err| { - log.warn("error getting file stat path={s} err={}", .{ - path, - err, - }); - return OpenFileError.FileOpenFailed; - }; - switch (stat.kind) { - .file => {}, - else => return OpenFileError.NotAFile, - } - - if (stat.size == 0) return OpenFileError.FileIsEmpty; - - return file; -} - /// Load and parse the CLI args. pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { switch (builtin.os.tag) { @@ -3796,13 +3888,7 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { }, } - log.info("loading config-file path={s}", .{path}); - var buf: [2048]u8 = undefined; - var file_reader = file.reader(&buf); - const reader = &file_reader.interface; - var iter: cli.args.LineIterator = .{ .r = reader, .filepath = path }; - try self.loadIter(alloc_gpa, &iter); - try self.expandPaths(std.fs.path.dirname(path).?); + try self.loadFsFile(arena_alloc, &file, path); } // If we have a suffix, add that back. @@ -4181,7 +4267,7 @@ pub fn finalize(self: *Config) !void { // Clamp our contrast self.@"minimum-contrast" = @min(21, @max(1, self.@"minimum-contrast")); - // Minimmum window size + // Minimum window size if (self.@"window-width" > 0) self.@"window-width" = @max(10, self.@"window-width"); if (self.@"window-height" > 0) self.@"window-height" = @max(4, self.@"window-height"); @@ -4824,14 +4910,6 @@ pub const NonNativeFullscreen = enum(c_int) { @"padded-notch", }; -/// Valid values for macos-option-as-alt. -pub const OptionAsAlt = enum { - false, - true, - left, - right, -}; - pub const WindowPaddingColor = enum { background, extend, @@ -5001,6 +5079,13 @@ pub const TerminalColor = union(enum) { return .{ .color = try Color.parseCLI(input) }; } + pub fn toTerminalRGB(self: TerminalColor) ?terminal.color.RGB { + return switch (self) { + .color => |v| v.toTerminalRGB(), + .@"cell-foreground", .@"cell-background" => null, + }; + } + /// Used by Formatter pub fn formatEntry(self: TerminalColor, formatter: formatterpkg.EntryFormatter) !void { switch (self) { @@ -5154,6 +5239,7 @@ pub const ColorList = struct { ) Allocator.Error!Self { return .{ .colors = try self.colors.clone(alloc), + .colors_c = try self.colors_c.clone(alloc), }; } @@ -5232,6 +5318,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 @@ -5685,12 +5791,12 @@ pub const Keybinds = struct { try self.set.put( alloc, .{ .key = .{ .physical = .copy } }, - .{ .copy_to_clipboard = {} }, + .{ .copy_to_clipboard = .mixed }, ); try self.set.put( alloc, .{ .key = .{ .physical = .paste } }, - .{ .paste_from_clipboard = {} }, + .paste_from_clipboard, ); // On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an @@ -5703,7 +5809,7 @@ pub const Keybinds = struct { try self.set.put( alloc, .{ .key = .{ .physical = .insert }, .mods = .{ .ctrl = true } }, - .{ .copy_to_clipboard = {} }, + .{ .copy_to_clipboard = .mixed }, ); try self.set.put( alloc, @@ -5722,7 +5828,7 @@ pub const Keybinds = struct { try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'c' }, .mods = mods }, - .{ .copy_to_clipboard = {} }, + .{ .copy_to_clipboard = .mixed }, .{ .performable = true }, ); try self.set.put( @@ -6001,6 +6107,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, @@ -6304,6 +6424,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, @@ -6844,6 +6996,193 @@ pub const RepeatableCodepointMap = struct { } }; +/// See "clipboard-codepoint-map" for documentation. +pub const RepeatableClipboardCodepointMap = struct { + const Self = @This(); + + map: ClipboardCodepointMap = .{}, + + pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { + const input = input_ orelse return error.ValueRequired; + const eql_idx = std.mem.indexOf(u8, input, "=") orelse return error.InvalidValue; + const whitespace = " \t"; + const key = std.mem.trim(u8, input[0..eql_idx], whitespace); + const value = std.mem.trim(u8, input[eql_idx + 1 ..], whitespace); + + // Parse the replacement value - either a codepoint or string + const replacement: ClipboardCodepointMap.Replacement = if (std.mem.startsWith(u8, value, "U+")) blk: { + // Parse as codepoint + const cp_str = value[2..]; // Skip "U+" + const cp = std.fmt.parseInt(u21, cp_str, 16) catch return error.InvalidValue; + break :blk .{ .codepoint = cp }; + } else blk: { + // Parse as UTF-8 string - validate it's valid UTF-8 + if (!std.unicode.utf8ValidateSlice(value)) return error.InvalidValue; + const value_copy = try alloc.dupe(u8, value); + break :blk .{ .string = value_copy }; + }; + + var p: UnicodeRangeParser = .{ .input = key }; + while (try p.next()) |range| { + try self.map.add(alloc, .{ + .range = range, + .replacement = replacement, + }); + } + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + return .{ .map = try self.map.clone(alloc) }; + } + + /// Compare if two of our value are equal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + const itemsA = self.map.list.slice(); + const itemsB = other.map.list.slice(); + if (itemsA.len != itemsB.len) return false; + for (0..itemsA.len) |i| { + const a = itemsA.get(i); + const b = itemsB.get(i); + if (!std.meta.eql(a.range, b.range)) return false; + switch (a.replacement) { + .codepoint => |cp_a| switch (b.replacement) { + .codepoint => |cp_b| if (cp_a != cp_b) return false, + .string => return false, + }, + .string => |str_a| switch (b.replacement) { + .string => |str_b| if (!std.mem.eql(u8, str_a, str_b)) return false, + .codepoint => return false, + }, + } + } + return true; + } + + /// Used by Formatter + pub fn formatEntry( + self: Self, + formatter: anytype, + ) !void { + if (self.map.list.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var buf: [1024]u8 = undefined; + var value_buf: [32]u8 = undefined; + const ranges = self.map.list.items(.range); + const replacements = self.map.list.items(.replacement); + for (ranges, replacements) |range, replacement| { + const value_str = switch (replacement) { + .codepoint => |cp| try std.fmt.bufPrint(&value_buf, "U+{X:0>4}", .{cp}), + .string => |s| s, + }; + + if (range[0] == range[1]) { + try formatter.formatEntry( + []const u8, + std.fmt.bufPrint( + &buf, + "U+{X:0>4}={s}", + .{ range[0], value_str }, + ) catch return error.OutOfMemory, + ); + } else { + try formatter.formatEntry( + []const u8, + std.fmt.bufPrint( + &buf, + "U+{X:0>4}-U+{X:0>4}={s}", + .{ range[0], range[1], value_str }, + ) catch return error.OutOfMemory, + ); + } + } + } + + /// Reuse the same UnicodeRangeParser from RepeatableCodepointMap + const UnicodeRangeParser = RepeatableCodepointMap.UnicodeRangeParser; + + test "parseCLI codepoint replacement" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+2500=U+002D"); // box drawing → hyphen + + try testing.expectEqual(@as(usize, 1), list.map.list.len); + const entry = list.map.list.get(0); + try testing.expectEqual([2]u21{ 0x2500, 0x2500 }, entry.range); + try testing.expect(entry.replacement == .codepoint); + try testing.expectEqual(@as(u21, 0x002D), entry.replacement.codepoint); + } + + test "parseCLI string replacement" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+03A3=SUM"); // Greek sigma → "SUM" + + try testing.expectEqual(@as(usize, 1), list.map.list.len); + const entry = list.map.list.get(0); + try testing.expectEqual([2]u21{ 0x03A3, 0x03A3 }, entry.range); + try testing.expect(entry.replacement == .string); + try testing.expectEqualStrings("SUM", entry.replacement.string); + } + + test "parseCLI range replacement" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+2500-U+2503=|"); // box drawing range → pipe + + try testing.expectEqual(@as(usize, 1), list.map.list.len); + const entry = list.map.list.get(0); + try testing.expectEqual([2]u21{ 0x2500, 0x2503 }, entry.range); + try testing.expect(entry.replacement == .string); + try testing.expectEqualStrings("|", entry.replacement.string); + } + + test "formatConfig codepoint" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+2500=U+002D"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = U+2500=U+002D\n", buf.written()); + } + + test "formatConfig string" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+03A3=SUM"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = U+03A3=SUM\n", buf.written()); + } +}; + pub const FontStyle = union(enum) { const Self = @This(); @@ -7657,7 +7996,8 @@ pub const QuickTerminalSize = struct { tag: Tag, value: Value, - pub const Tag = enum(u8) { none, percentage, pixels }; + /// c_int because it needs to be extern compatible + pub const Tag = enum(c_int) { none, percentage, pixels }; pub const Value = extern union { percentage: f32, @@ -7948,11 +8288,15 @@ pub const BackgroundImageFit = enum { pub const FreetypeLoadFlags = packed struct { // The defaults here at the time of writing this match the defaults // for Freetype itself. Ghostty hasn't made any opinionated changes - // to these defaults. + // to these defaults. (Strictly speaking, `light` isn't FreeType's + // own default, but appears to be the effective default with most + // Fontconfig-aware software using FreeType, so until Ghostty + // implements Fontconfig support we default to `light`.) hinting: bool = true, @"force-autohint": bool = false, monochrome: bool = false, autohint: bool = true, + light: bool = true, }; /// See linux-cgroup @@ -7980,6 +8324,8 @@ pub const AutoUpdate = enum { pub const BackgroundBlur = union(enum) { false, true, + @"macos-glass-regular", + @"macos-glass-clear", radius: u8, pub fn parseCLI(self: *BackgroundBlur, input: ?[]const u8) !void { @@ -7989,14 +8335,35 @@ pub const BackgroundBlur = union(enum) { return; }; - self.* = if (cli.args.parseBool(input_)) |b| - if (b) .true else .false - else |_| - .{ .radius = std.fmt.parseInt( - u8, - input_, - 0, - ) catch return error.InvalidValue }; + // Try to parse normal bools + if (cli.args.parseBool(input_)) |b| { + self.* = if (b) .true else .false; + return; + } else |_| {} + + // Try to parse enums + if (std.meta.stringToEnum( + std.meta.Tag(BackgroundBlur), + input_, + )) |v| switch (v) { + inline else => |tag| tag: { + // We can only parse void types + const info = std.meta.fieldInfo(BackgroundBlur, tag); + if (info.type != void) break :tag; + self.* = @unionInit( + BackgroundBlur, + @tagName(tag), + {}, + ); + return; + }, + }; + + self.* = .{ .radius = std.fmt.parseInt( + u8, + input_, + 0, + ) catch return error.InvalidValue }; } pub fn enabled(self: BackgroundBlur) bool { @@ -8004,14 +8371,24 @@ pub const BackgroundBlur = union(enum) { .false => false, .true => true, .radius => |v| v > 0, + + // We treat these as true because they both imply some blur! + // This has the effect of making the standard blur happen on + // Linux. + .@"macos-glass-regular", .@"macos-glass-clear" => true, }; } - pub fn cval(self: BackgroundBlur) u8 { + pub fn cval(self: BackgroundBlur) i16 { return switch (self) { .false => 0, .true => 20, .radius => |v| v, + // I hate sentinel values like this but this is only for + // our macOS application currently. We can switch to a proper + // tagged union if we ever need to. + .@"macos-glass-regular" => -1, + .@"macos-glass-clear" => -2, }; } @@ -8023,6 +8400,8 @@ pub const BackgroundBlur = union(enum) { .false => try formatter.formatEntry(bool, false), .true => try formatter.formatEntry(bool, true), .radius => |v| try formatter.formatEntry(u8, v), + .@"macos-glass-regular" => try formatter.formatEntry([]const u8, "macos-glass-regular"), + .@"macos-glass-clear" => try formatter.formatEntry([]const u8, "macos-glass-clear"), } } @@ -8042,6 +8421,12 @@ pub const BackgroundBlur = union(enum) { try v.parseCLI("42"); try testing.expectEqual(42, v.radius); + try v.parseCLI("macos-glass-regular"); + try testing.expectEqual(.@"macos-glass-regular", v); + + try v.parseCLI("macos-glass-clear"); + try testing.expectEqual(.@"macos-glass-clear", v); + try testing.expectError(error.InvalidValue, v.parseCLI("")); try testing.expectError(error.InvalidValue, v.parseCLI("aaaa")); try testing.expectError(error.InvalidValue, v.parseCLI("420")); @@ -8459,6 +8844,12 @@ pub const WindowPadding = struct { } }; +/// See scrollbar +pub const Scrollbar = enum { + system, + never, +}; + /// See scroll-to-bottom pub const ScrollToBottom = packed struct { keystroke: bool = true, diff --git a/src/config/c_get.zig b/src/config/c_get.zig index f235f596a..0f8f897a2 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -193,20 +193,32 @@ test "c_get: background-blur" { { c.@"background-blur" = .false; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(0, cval); } { c.@"background-blur" = .true; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(20, cval); } { c.@"background-blur" = .{ .radius = 42 }; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(42, cval); } + { + c.@"background-blur" = .@"macos-glass-regular"; + var cval: i16 = undefined; + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); + try testing.expectEqual(-1, cval); + } + { + c.@"background-blur" = .@"macos-glass-clear"; + var cval: i16 = undefined; + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); + try testing.expectEqual(-2, cval); + } } 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 07bb7ee5a..8cedc47a5 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -1,9 +1,9 @@ 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. /// @@ -89,20 +89,16 @@ fn configPath(alloc_arena: Allocator) ![]const u8 { /// Returns a const list of possible paths the main config file could be /// in for the current OS. fn configPathCandidates(alloc_arena: Allocator) ![]const []const u8 { - var paths: std.ArrayList([]const u8) = try .initCapacity(alloc_arena, 2); + var paths: std.ArrayList([]const u8) = try .initCapacity(alloc_arena, 4); errdefer paths.deinit(alloc_arena); if (comptime builtin.os.tag == .macos) { - paths.appendAssumeCapacity(try internal_os.macos.appSupportDir( - alloc_arena, - "config", - )); + paths.appendAssumeCapacity(try file_load.defaultAppSupportPath(alloc_arena)); + paths.appendAssumeCapacity(try file_load.legacyDefaultAppSupportPath(alloc_arena)); } - paths.appendAssumeCapacity(try internal_os.xdg.config( - alloc_arena, - .{ .subdir = "ghostty/config" }, - )); + paths.appendAssumeCapacity(try file_load.defaultXdgPath(alloc_arena)); + paths.appendAssumeCapacity(try file_load.legacyDefaultXdgPath(alloc_arena)); return paths.items; } diff --git a/src/config/file_load.zig b/src/config/file_load.zig new file mode 100644 index 000000000..7885de32a --- /dev/null +++ b/src/config/file_load.zig @@ -0,0 +1,166 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const internal_os = @import("../os/main.zig"); + +const log = std.log.scoped(.config); + +/// Default path for the XDG home configuration file. Returned value +/// must be freed by the caller. +pub fn defaultXdgPath(alloc: Allocator) ![]const u8 { + return try internal_os.xdg.config( + alloc, + .{ .subdir = "ghostty/config.ghostty" }, + ); +} + +/// Ghostty <1.3.0 default path for the XDG home configuration file. +/// Returned value must be freed by the caller. +pub fn legacyDefaultXdgPath(alloc: Allocator) ![]const u8 { + return try internal_os.xdg.config( + alloc, + .{ .subdir = "ghostty/config" }, + ); +} + +/// Preferred default path for the XDG home configuration file. +/// Returned value must be freed by the caller. +pub fn preferredXdgPath(alloc: Allocator) ![]const u8 { + // If the XDG path exists, use that. + const xdg_path = try defaultXdgPath(alloc); + if (open(xdg_path)) |f| { + f.close(); + return xdg_path; + } else |_| {} + + // Try the legacy path + errdefer alloc.free(xdg_path); + const legacy_xdg_path = try legacyDefaultXdgPath(alloc); + if (open(legacy_xdg_path)) |f| { + f.close(); + alloc.free(xdg_path); + return legacy_xdg_path; + } else |_| {} + + // Legacy path and XDG path both don't exist. Return the + // new one. + alloc.free(legacy_xdg_path); + return xdg_path; +} + +/// Default path for the macOS Application Support configuration file. +/// Returned value must be freed by the caller. +pub fn defaultAppSupportPath(alloc: Allocator) ![]const u8 { + return try internal_os.macos.appSupportDir(alloc, "config.ghostty"); +} + +/// Ghostty <1.3.0 default path for the macOS Application Support +/// configuration file. Returned value must be freed by the caller. +pub fn legacyDefaultAppSupportPath(alloc: Allocator) ![]const u8 { + return try internal_os.macos.appSupportDir(alloc, "config"); +} + +/// Preferred default path for the macOS Application Support configuration file. +/// Returned value must be freed by the caller. +pub fn preferredAppSupportPath(alloc: Allocator) ![]const u8 { + // If the app support path exists, use that. + const app_support_path = try defaultAppSupportPath(alloc); + if (open(app_support_path)) |f| { + f.close(); + return app_support_path; + } else |_| {} + + // Try the legacy path + errdefer alloc.free(app_support_path); + const legacy_app_support_path = try legacyDefaultAppSupportPath(alloc); + if (open(legacy_app_support_path)) |f| { + f.close(); + alloc.free(app_support_path); + return legacy_app_support_path; + } else |_| {} + + // Legacy path and app support path both don't exist. Return the + // new one. + alloc.free(legacy_app_support_path); + return app_support_path; +} + +/// Returns the path to the preferred default configuration file. +/// This is the file where users should place their configuration. +/// +/// This doesn't create or populate the file with any default +/// contents; downstream callers must handle this. +/// +/// The returned value must be freed by the caller. +pub fn preferredDefaultFilePath(alloc: Allocator) ![]const u8 { + switch (builtin.os.tag) { + .macos => { + // macOS prefers the Application Support directory + // if it exists. + const app_support_path = try preferredAppSupportPath(alloc); + const app_support_file = open(app_support_path) catch { + // Try the XDG path if it exists + const xdg_path = try preferredXdgPath(alloc); + const xdg_file = open(xdg_path) catch { + // If neither file exists, use app support + alloc.free(xdg_path); + return app_support_path; + }; + xdg_file.close(); + alloc.free(app_support_path); + return xdg_path; + }; + app_support_file.close(); + return app_support_path; + }, + + // All other platforms use XDG only + else => return try preferredXdgPath(alloc), + } +} + +const OpenFileError = error{ + FileNotFound, + FileIsEmpty, + FileOpenFailed, + NotAFile, +}; + +/// Opens the file at the given path and returns the file handle +/// if it exists and is non-empty. This also constrains the possible +/// errors to a smaller set that we can explicitly handle. +pub fn open(path: []const u8) OpenFileError!std.fs.File { + assert(std.fs.path.isAbsolute(path)); + + var file = std.fs.openFileAbsolute( + path, + .{}, + ) catch |err| switch (err) { + error.FileNotFound => return OpenFileError.FileNotFound, + else => { + log.warn("unexpected file open error path={s} err={}", .{ + path, + err, + }); + return OpenFileError.FileOpenFailed; + }, + }; + errdefer file.close(); + + const stat = file.stat() catch |err| { + log.warn("error getting file stat path={s} err={}", .{ + path, + err, + }); + return OpenFileError.FileOpenFailed; + }; + switch (stat.kind) { + .file => {}, + else => return OpenFileError.NotAFile, + } + + if (stat.size == 0) return OpenFileError.FileIsEmpty; + + return file; +} 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 06bc8267f..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. @@ -73,7 +71,7 @@ pub fn BlockingQueue( not_full_waiters: usize = 0, /// Allocate the blocking queue on the heap. - pub fn create(alloc: Allocator) !*Self { + pub fn create(alloc: Allocator) Allocator.Error!*Self { const ptr = try alloc.create(Self); errdefer alloc.destroy(ptr); 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/extra/vim.zig b/src/extra/vim.zig index 2c0192d03..9140b83f8 100644 --- a/src/extra/vim.zig +++ b/src/extra/vim.zig @@ -10,7 +10,7 @@ pub const ftdetect = \\" \\" THIS FILE IS AUTO-GENERATED \\ - \\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/* setf ghostty + \\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/*,*.ghostty setf ghostty \\ ; pub const ftplugin = diff --git a/src/fastmem.zig b/src/fastmem.zig index bdea44155..a21f84c58 100644 --- a/src/fastmem.zig +++ b/src/fastmem.zig @@ -1,21 +1,13 @@ const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; -/// Same as std.mem.copyForwards/Backwards but prefers libc memmove if it is -/// available because it is generally much faster. +/// Same as @memmove but prefers libc memmove if it is +/// available because it is generally much faster?. pub inline fn move(comptime T: type, dest: []T, source: []const T) void { if (builtin.link_libc) { _ = memmove(dest.ptr, source.ptr, source.len * @sizeOf(T)); } else { - // Depending on the ordering of the copy, we need to use the - // proper call here. Unfortunately this function call is - // too generic to know this at comptime. - if (@intFromPtr(dest.ptr) <= @intFromPtr(source.ptr)) { - std.mem.copyForwards(T, dest, source); - } else { - std.mem.copyBackwards(T, dest, source); - } + @memmove(dest, source); } } 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 5ec076608..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"); @@ -1228,7 +1227,8 @@ test "metrics" { .overline_thickness = 1, .box_thickness = 1, .cursor_height = 17, - .icon_height = 12.24, + .icon_height = 16.784, + .icon_height_single = 12.24, .face_width = 8.0, .face_height = 16.784, .face_y = -0.04, @@ -1248,7 +1248,8 @@ test "metrics" { .overline_thickness = 2, .box_thickness = 2, .cursor_height = 34, - .icon_height = 24.48, + .icon_height = 33.568, + .icon_height_single = 24.48, .face_width = 16.0, .face_height = 33.568, .face_y = -0.08, 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 668b6f15f..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, @@ -38,14 +39,18 @@ cursor_height: u32, /// The constraint height for nerd fonts icons. icon_height: f64, +/// The constraint height for nerd fonts icons limited to a single cell width. +icon_height_single: f64, + /// The unrounded face width, used in scaling calculations. 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 @@ -60,6 +65,7 @@ const Minimums = struct { const cursor_thickness = 1; const cursor_height = 1; const icon_height = 1.0; + const icon_height_single = 1.0; const face_height = 1.0; const face_width = 1.0; }; @@ -219,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. @@ -251,8 +300,11 @@ pub fn calc(face: FaceMetrics) Metrics { const underline_position = @round(top_to_baseline - face.underlinePosition()); const strikethrough_position = @round(top_to_baseline - face.strikethroughPosition()); - // Same heuristic as the font_patcher script - const icon_height = (2 * cap_height + face_height) / 3; + // Same heuristic as the font_patcher script. We store icon_height + // separately from face_height such that modifiers can apply to the former + // without affecting the latter. + const icon_height = face_height; + const icon_height_single = (2 * cap_height + face_height) / 3; var result: Metrics = .{ .cell_width = @intFromFloat(cell_width), @@ -267,6 +319,7 @@ pub fn calc(face: FaceMetrics) Metrics { .box_thickness = @intFromFloat(underline_thickness), .cursor_height = @intFromFloat(cell_height), .icon_height = icon_height, + .icon_height_single = icon_height_single, .face_width = face_width, .face_height = face_height, .face_y = face_y, @@ -303,31 +356,54 @@ 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 => { + self.icon_height = entry.value_ptr.apply(self.icon_height); + self.icon_height_single = entry.value_ptr.apply(self.icon_height_single); + }, inline else => |tag| { @field(self, @tagName(tag)) = entry.value_ptr.apply(@field(self, @tagName(tag))); @@ -339,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| { @@ -529,6 +620,7 @@ fn init() Metrics { .box_thickness = 0, .cursor_height = 0, .icon_height = 0.0, + .icon_height_single = 0.0, .face_width = 0.0, .face_height = 0.0, .face_y = 0.0, @@ -557,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(); @@ -567,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); } @@ -587,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(); @@ -597,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); @@ -609,6 +707,48 @@ test "Metrics: adjust cell height larger" { try testing.expectEqual(@as(u32, 100), m.cursor_height); } +test "Metrics: adjust icon height by percentage" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: ModifierSet = .{}; + defer set.deinit(alloc); + try set.put(alloc, .icon_height, .{ .percent = 0.75 }); + + var m: Metrics = init(); + m.icon_height = 100.0; + m.icon_height_single = 80.0; + m.face_height = 100.0; + m.face_y = 1.0; + m.apply(set); + try testing.expectEqual(75.0, m.icon_height); + try testing.expectEqual(60.0, m.icon_height_single); + // Face metrics not affected + try testing.expectEqual(100.0, m.face_height); + try testing.expectEqual(1.0, m.face_y); +} + +test "Metrics: adjust icon height by absolute pixels" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: ModifierSet = .{}; + defer set.deinit(alloc); + try set.put(alloc, .icon_height, .{ .absolute = -5 }); + + var m: Metrics = init(); + m.icon_height = 100.0; + m.icon_height_single = 80.0; + m.face_height = 100.0; + m.face_y = 1.0; + m.apply(set); + try testing.expectEqual(95.0, m.icon_height); + try testing.expectEqual(75.0, m.icon_height_single); + // Face metrics not affected + try testing.expectEqual(100.0, m.face_height); + try testing.expectEqual(1.0, m.face_y); +} + test "Modifier: parse absolute" { const testing = std.testing; 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.zig b/src/font/face.zig index f660565fe..a1312c45a 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -216,11 +216,13 @@ pub const RenderOptions = struct { }; pub const Height = enum { - /// Always use the full height of the cell for constraining this glyph. + /// Use the full line height of the primary face for + /// constraining this glyph. cell, - /// When the constraint width is 1, use the "icon height" from the grid - /// metrics as the height. (When the constraint width is >1, the - /// constraint height is always the full cell height.) + /// Use the icon height from the grid metrics for + /// constraining this glyph. Unlike `cell`, the value of + /// this height depends on both the constraint width and the + /// affected by the `adjust-icon-height` config option. icon, }; @@ -346,12 +348,14 @@ pub const RenderOptions = struct { const target_width = pad_width_factor * metrics.face_width; const target_height = pad_height_factor * switch (self.height) { .cell => metrics.face_height, - // icon_height only applies with single-cell constraints. - // This mirrors font_patcher. + // Like font-patcher, the icon constraint height depends on the + // constraint width. Unlike font-patcher, the multi-cell + // icon_height may be different from face_height due to the + // `adjust-icon-height` config option. .icon => if (multi_cell) - metrics.face_height + metrics.icon_height else - metrics.icon_height, + metrics.icon_height_single, }; var width_factor = target_width / group.width; @@ -507,3 +511,197 @@ test "Variation.Id: slnt should be 1936486004" { try testing.expectEqual(@as(u32, 1936486004), @as(u32, @bitCast(id))); try testing.expectEqualStrings("slnt", &(id.str())); } + +test "Constraints" { + const comparison = @import("../datastruct/comparison.zig"); + const getConstraint = @import("nerd_font_attributes.zig").getConstraint; + + // Hardcoded data matches metrics from CoreText at size 12 and DPI 96. + + // Define grid metrics (matches font-family = JetBrains Mono) + const metrics: Metrics = .{ + .cell_width = 10, + .cell_height = 22, + .cell_baseline = 5, + .underline_position = 19, + .underline_thickness = 1, + .strikethrough_position = 12, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_thickness = 1, + .cursor_height = 22, + .icon_height = 21.12, + .icon_height_single = 44.48 / 3.0, + .face_width = 9.6, + .face_height = 21.12, + .face_y = 0.2, + }; + + // ASCII (no constraint). + { + const constraint: RenderOptions.Constraint = .none; + + // BBox of 'x' from JetBrains Mono. + const glyph_x: GlyphSize = .{ + .width = 6.784, + .height = 15.28, + .x = 1.408, + .y = 4.84, + }; + + // Any constraint width: do nothing. + inline for (.{ 1, 2 }) |constraint_width| { + try comparison.expectApproxEqual( + glyph_x, + constraint.constrain(glyph_x, metrics, constraint_width), + ); + } + } + + // Symbol (same constraint as hardcoded in Renderer.addGlyph). + { + const constraint: RenderOptions.Constraint = .{ .size = .fit }; + + // BBox of '■' (0x25A0 black square) from Iosevka. + // NOTE: This glyph is designed to span two cells. + const glyph_25A0: GlyphSize = .{ + .width = 10.272, + .height = 10.272, + .x = 2.864, + .y = 5.304, + }; + + // Constraint width 1: scale down and shift to fit a single cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = metrics.face_width, + .height = metrics.face_width, + .x = 0, + .y = 5.64, + }, + constraint.constrain(glyph_25A0, metrics, 1), + ); + + // Constraint width 2: do nothing. + try comparison.expectApproxEqual( + glyph_25A0, + constraint.constrain(glyph_25A0, metrics, 2), + ); + } + + // Emoji (same constraint as hardcoded in SharedGrid.renderGlyph). + { + const constraint: RenderOptions.Constraint = .{ + .size = .cover, + .align_horizontal = .center, + .align_vertical = .center, + .pad_left = 0.025, + .pad_right = 0.025, + }; + + // BBox of '🥸' (0x1F978) from Apple Color Emoji. + const glyph_1F978: GlyphSize = .{ + .width = 20, + .height = 20, + .x = 0.46, + .y = 1, + }; + + // Constraint width 2: scale to cover two cells with padding, center; + try comparison.expectApproxEqual( + GlyphSize{ + .width = 18.72, + .height = 18.72, + .x = 0.44, + .y = 1.4, + }, + constraint.constrain(glyph_1F978, metrics, 2), + ); + } + + // Nerd Font default. + { + const constraint = getConstraint(0xea61).?; + + // Verify that this is the constraint we expect. + try std.testing.expectEqual(.fit_cover1, constraint.size); + try std.testing.expectEqual(.icon, constraint.height); + try std.testing.expectEqual(.center1, constraint.align_horizontal); + try std.testing.expectEqual(.center1, constraint.align_vertical); + + // BBox of '' (0xEA61 nf-cod-lightbulb) from Symbols Only. + // NOTE: This icon is part of a group, so the + // constraint applies to a larger bounding box. + const glyph_EA61: GlyphSize = .{ + .width = 9.015625, + .height = 13.015625, + .x = 3.015625, + .y = 3.76525, + }; + + // Constraint width 1: scale and shift group to fit a single cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = 7.2125, + .height = 10.4125, + .x = 0.8125, + .y = 5.950695224719102, + }, + constraint.constrain(glyph_EA61, metrics, 1), + ); + + // Constraint width 2: no scaling; left-align and vertically center group. + try comparison.expectApproxEqual( + GlyphSize{ + .width = glyph_EA61.width, + .height = glyph_EA61.height, + .x = 1.015625, + .y = 4.7483690308988775, + }, + constraint.constrain(glyph_EA61, metrics, 2), + ); + } + + // Nerd Font stretch. + { + const constraint = getConstraint(0xe0c0).?; + + // Verify that this is the constraint we expect. + try std.testing.expectEqual(.stretch, constraint.size); + try std.testing.expectEqual(.cell, constraint.height); + try std.testing.expectEqual(.start, constraint.align_horizontal); + try std.testing.expectEqual(.center1, constraint.align_vertical); + + // BBox of ' ' (0xE0C0 nf-ple-flame_thick) from Symbols Only. + const glyph_E0C0: GlyphSize = .{ + .width = 16.796875, + .height = 16.46875, + .x = -0.796875, + .y = 1.7109375, + }; + + // Constraint width 1: stretch and position to exactly cover one cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = @floatFromInt(metrics.cell_width), + .height = @floatFromInt(metrics.cell_height), + .x = 0, + .y = 0, + }, + constraint.constrain(glyph_E0C0, metrics, 1), + ); + + // Constraint width 1: stretch and position to exactly cover two cells. + try comparison.expectApproxEqual( + GlyphSize{ + .width = @floatFromInt(2 * metrics.cell_width), + .height = @floatFromInt(metrics.cell_height), + .x = 0, + .y = 0, + }, + constraint.constrain(glyph_E0C0, metrics, 2), + ); + } +} diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index bd1716a61..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"); @@ -363,9 +363,20 @@ pub const Face = struct { // We center all glyphs within the pixel-rounded and adjusted // cell width if it's larger than the face width, so that they // aren't weirdly off to the left. - if (metrics.face_width < cell_width) { + // + // 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) { // 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, @@ -378,18 +389,6 @@ pub const Face = struct { y = @round(y); } - // We center all glyphs within the pixel-rounded and adjusted - // cell width if it's larger than the face width, so that they - // aren't weirdly off to the left. - // - // 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)) { - // We add half the difference to re-center. - x += (cell_width - metrics.face_width) / 2; - } - // We make an assumption that font smoothing ("thicken") // adds no more than 1 extra pixel to any edge. We don't // add extra size if it's a sbix color font though, since diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 259e91b8c..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,7 +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 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 @@ -1143,7 +1150,7 @@ test { ft_font.glyphIndex('A').?, .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); - try testing.expectEqual(@as(u32, 20), g2.height); + try testing.expectEqual(@as(u32, 21), g2.height); } } @@ -1177,43 +1184,6 @@ test "color emoji" { const glyph_id = ft_font.glyphIndex('🥸').?; try testing.expect(ft_font.isColorGlyph(glyph_id)); } - - // resize - // TODO: Comprehensive tests for constraints, - // this is just an adapted legacy test. - { - const glyph = try ft_font.renderGlyph( - alloc, - &atlas, - ft_font.glyphIndex('🥸').?, - .{ - .grid_metrics = .{ - .cell_width = 13, - .cell_height = 24, - .cell_baseline = 0, - .underline_position = 0, - .underline_thickness = 0, - .strikethrough_position = 0, - .strikethrough_thickness = 0, - .overline_position = 0, - .overline_thickness = 0, - .box_thickness = 0, - .cursor_height = 0, - .icon_height = 0, - .face_width = 13, - .face_height = 24, - .face_y = 0, - }, - .constraint_width = 2, - .constraint = .{ - .size = .fit, - .align_horizontal = .center, - .align_vertical = .center, - }, - }, - ); - try testing.expectEqual(@as(u32, 24), glyph.height); - } } test "mono to bgra" { 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/nerd_font_attributes.zig b/src/font/nerd_font_attributes.zig index 138108288..f4a19d963 100644 --- a/src/font/nerd_font_attributes.zig +++ b/src/font/nerd_font_attributes.zig @@ -16,10 +16,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .center1, .align_vertical = .center1, - .pad_left = 0.1, - .pad_right = 0.1, - .pad_top = 0.1, - .pad_bottom = 0.1, + .pad_left = 0.05, + .pad_right = 0.05, + .pad_top = 0.05, + .pad_bottom = 0.05, }, 0x276c...0x276d, => .{ @@ -69,10 +69,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.06, - .pad_right = -0.06, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.03, + .pad_right = -0.03, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.7, }, 0xe0b1, @@ -89,10 +89,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = -0.06, - .pad_right = -0.06, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.03, + .pad_right = -0.03, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.7, }, 0xe0b3, @@ -109,10 +109,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.06, - .pad_right = -0.06, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.03, + .pad_right = -0.03, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.59, }, 0xe0b5, @@ -129,10 +129,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = -0.06, - .pad_right = -0.06, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.03, + .pad_right = -0.03, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.59, }, 0xe0b7, @@ -143,6 +143,18 @@ pub fn getConstraint(cp: u21) ?Constraint { .align_vertical = .center1, .max_xy_ratio = 0.5, }, + 0xe0b8, + 0xe0bc, + => .{ + .size = .stretch, + .max_constraint_width = 1, + .align_horizontal = .start, + .align_vertical = .center1, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, + }, 0xe0b9, 0xe0bd, => .{ @@ -151,6 +163,18 @@ pub fn getConstraint(cp: u21) ?Constraint { .align_horizontal = .start, .align_vertical = .center1, }, + 0xe0ba, + 0xe0be, + => .{ + .size = .stretch, + .max_constraint_width = 1, + .align_horizontal = .end, + .align_vertical = .center1, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, + }, 0xe0bb, 0xe0bf, => .{ @@ -165,10 +189,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.05, - .pad_right = -0.05, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, }, 0xe0c1, => .{ @@ -182,10 +206,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = -0.05, - .pad_right = -0.05, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, }, 0xe0c3, => .{ @@ -198,10 +222,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, .max_xy_ratio = 0.86, }, 0xe0c5, @@ -209,10 +233,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, .max_xy_ratio = 0.86, }, 0xe0c6, @@ -220,10 +244,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, .max_xy_ratio = 0.78, }, 0xe0c7, @@ -231,10 +255,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, .max_xy_ratio = 0.78, }, 0xe0cc, @@ -242,10 +266,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.02, - .pad_right = -0.02, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.01, + .pad_right = -0.01, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.85, }, 0xe0cd, @@ -268,10 +292,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.02, - .pad_right = -0.02, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.01, + .pad_right = -0.01, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.7, }, 0xe0d4, @@ -280,10 +304,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = -0.02, - .pad_right = -0.02, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.01, + .pad_right = -0.01, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.7, }, 0xe0d6, @@ -292,10 +316,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.05, - .pad_right = -0.05, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.7, }, 0xe0d7, @@ -304,10 +328,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = -0.05, - .pad_right = -0.05, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.7, }, 0xe300, @@ -401,6 +425,7 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_y = 0.0214843750000000, }, 0xe30a, + 0xe35f, => .{ .size = .fit_cover1, .height = .icon, @@ -546,6 +571,7 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_y = 0.0195312500000000, }, 0xe31a, + 0xe35e, => .{ .size = .fit_cover1, .height = .icon, @@ -662,6 +688,7 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_y = 0.0195312500000000, }, 0xe327, + 0xe361, => .{ .size = .fit_cover1, .height = .icon, @@ -830,8 +857,7 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.4882812500000000, - .relative_y = 0.2109375000000000, + .relative_height = 0.8445945945945946, }, 0xe33a, => .{ @@ -851,16 +877,7 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.5449218750000000, .relative_y = 0.2148437500000000, }, - 0xe33c, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6006674082313682, - .relative_y = 0.1952169076751947, - }, - 0xe33d, + 0xe33c...0xe33d, => .{ .size = .fit_cover1, .height = .icon, @@ -875,8 +892,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.1904296875000000, - .relative_y = 0.5986328125000000, + .relative_height = 0.3293918918918919, + .relative_y = 0.6706081081081081, }, 0xe33f, => .{ @@ -884,8 +901,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.3300781250000000, - .relative_y = 0.3544921875000000, + .relative_height = 0.5200000000000000, + .relative_y = 0.2707692307692308, }, 0xe340, => .{ @@ -893,8 +910,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.5273437500000000, - .relative_y = 0.2373046875000000, + .relative_height = 0.8307692307692308, + .relative_y = 0.0861538461538462, }, 0xe341, => .{ @@ -902,17 +919,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.4814453125000000, - .relative_y = 0.2138671875000000, - }, - 0xe343, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6816406250000000, - .relative_y = 0.1591796875000000, + .relative_height = 0.8327702702702703, + .relative_y = 0.0050675675675676, }, 0xe344, => .{ @@ -920,8 +928,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.3369140625000000, - .relative_y = 0.3154296875000000, + .relative_height = 0.5307692307692308, + .relative_y = 0.2092307692307692, }, 0xe345, => .{ @@ -929,17 +937,17 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.6073507601038191, - .relative_y = 0.1629495736002966, + .relative_height = 0.5332112630208333, + .relative_y = 0.2040934244791667, }, - 0xe348, + 0xe347, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.6347656250000000, - .relative_y = 0.1826171875000000, + .relative_height = 0.8307692307692308, + .relative_y = 0.1246153846153846, }, 0xe349, => .{ @@ -947,17 +955,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.3402542969850663, - .relative_y = 0.3471400394477318, - }, - 0xe34b, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6621093750000000, - .relative_y = 0.1689453125000000, + .relative_height = 0.5307967032967034, + .relative_y = 0.2615384615384616, }, 0xe34c, => .{ @@ -965,8 +964,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8662109375000000, - .relative_y = 0.1337890625000000, + .relative_height = 0.8659995118379302, + .relative_y = 0.1340004881620698, }, 0xe34d, => .{ @@ -974,7 +973,17 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.9892578125000000, + .relative_height = 0.9890163534293386, + .relative_y = 0.0002440810349036, + }, + 0xe34f, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5751953125000000, + .relative_y = 0.1142578125000000, }, 0xe351, => .{ @@ -982,8 +991,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7992831541218638, - .relative_y = 0.0919952210274791, + .relative_height = 0.6533203125000000, + .relative_y = 0.1328125000000000, }, 0xe352, => .{ @@ -991,8 +1000,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.4050179211469534, - .relative_y = 0.3739545997610514, + .relative_height = 0.5215384615384615, + .relative_y = 0.2846153846153846, }, 0xe353, => .{ @@ -1000,44 +1009,27 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7040688830943068, - .relative_y = 0.1811983920034767, + .relative_height = 0.8308012820512821, + .relative_y = 0.1230448717948718, }, - 0xe356, + 0xe354...0xe356, + 0xe358...0xe359, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7564102564102564, - .relative_y = 0.1213017751479290, + .relative_height = 0.9935233160621761, + .relative_y = 0.0025906735751295, }, 0xe357, + 0xe3a9, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7509765625000000, - .relative_y = 0.1230468750000000, - }, - 0xe358, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7490234375000000, - .relative_y = 0.1250000000000000, - }, - 0xe359, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7643248629795715, - .relative_y = 0.1121076233183857, + .relative_height = 0.9961139896373057, }, 0xe35a, => .{ @@ -1045,8 +1037,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7643248629795715, - .relative_y = 0.1111111111111111, + .relative_height = 0.9935233160621761, + .relative_y = 0.0012953367875648, }, 0xe35b, => .{ @@ -1054,43 +1046,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7683109118086696, - .relative_y = 0.1111111111111111, - }, - 0xe35c, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9895366218236173, - }, - 0xe35d, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.5590433482810164, - .relative_y = 0.2152466367713005, - }, - 0xe35e, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7443946188340808, - .relative_y = 0.0134529147982063, - }, - 0xe35f, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9845540607872446, - .relative_y = 0.0154459392127554, + .relative_height = 0.9987046632124352, + .relative_y = 0.0012953367875648, }, 0xe360, => .{ @@ -1098,17 +1055,26 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7852516193323368, - .relative_y = 0.0154459392127554, + .relative_height = 0.7695312500000000, + .relative_y = 0.0302734375000000, }, - 0xe361, + 0xe362, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8241155954160438, - .relative_y = 0.0124564025909317, + .relative_height = 0.9902343750000000, + .relative_y = 0.0097656250000000, + }, + 0xe363, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7900390625000000, + .relative_y = 0.0097656250000000, }, 0xe364, => .{ @@ -1143,8 +1109,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.9335937500000000, - .relative_y = 0.0263671875000000, + .relative_height = 0.9333658774713205, + .relative_y = 0.0266048328044911, }, 0xe36c, => .{ @@ -1170,8 +1136,26 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8712355686563498, - .relative_y = 0.0383689511176615, + .relative_height = 0.7529721467391304, + .relative_y = 0.0956606657608696, + }, + 0xe36f, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6835937500000000, + .relative_y = 0.1250000000000000, + }, + 0xe370, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8642578125000000, + .relative_y = 0.0625000000000000, }, 0xe371, => .{ @@ -1179,8 +1163,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.6163708086785010, - .relative_y = 0.1903353057199211, + .relative_height = 0.6103515625000000, + .relative_y = 0.1933593750000000, }, 0xe372, => .{ @@ -1188,7 +1172,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.9725209080047790, + .relative_height = 0.7949218750000000, + .relative_y = 0.0576171875000000, }, 0xe373, => .{ @@ -1196,8 +1181,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8737672583826430, - .relative_y = 0.0009861932938856, + .relative_height = 0.8652343750000000, + .relative_y = 0.0058593750000000, }, 0xe374, => .{ @@ -1205,8 +1190,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.3185404339250493, - .relative_y = 0.2840236686390533, + .relative_height = 0.3154296875000000, + .relative_y = 0.2861328125000000, }, 0xe375, => .{ @@ -1214,8 +1199,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.6839250493096647, - .relative_y = 0.1267258382642998, + .relative_height = 0.6772460937500000, + .relative_y = 0.1303710937500000, }, 0xe376, => .{ @@ -1223,8 +1208,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7061143984220908, - .relative_y = 0.1301775147928994, + .relative_height = 0.6992187500000000, + .relative_y = 0.1337890625000000, }, 0xe377, => .{ @@ -1232,8 +1217,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7386587771203156, - .relative_y = 0.1518737672583826, + .relative_height = 0.7314453125000000, + .relative_y = 0.1552734375000000, }, 0xe378, => .{ @@ -1241,8 +1226,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7386587771203156, - .relative_y = 0.1508875739644970, + .relative_height = 0.7314453125000000, + .relative_y = 0.1542968750000000, }, 0xe379, => .{ @@ -1250,8 +1235,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.5808678500986193, - .relative_y = 0.1794871794871795, + .relative_height = 0.5751953125000000, + .relative_y = 0.1826171875000000, }, 0xe37a, => .{ @@ -1259,8 +1244,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.5315581854043393, - .relative_y = 0.2258382642998027, + .relative_height = 0.5263671875000000, + .relative_y = 0.2285156250000000, }, 0xe37b, => .{ @@ -1268,8 +1253,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.5808678500986193, - .relative_y = 0.1804733727810651, + .relative_height = 0.5751953125000000, + .relative_y = 0.1835937500000000, }, 0xe37d, => .{ @@ -1295,8 +1280,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.3300781250000000, - .relative_y = 0.3593750000000000, + .relative_height = 0.5200000000000000, + .relative_y = 0.2784615384615385, }, 0xe380, => .{ @@ -1304,29 +1289,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.3300781250000000, - .relative_y = 0.3496093750000000, - }, - 0xe381...0xe383, - 0xe385...0xe388, - 0xf451...0xf453, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7500000000000000, - .relative_y = 0.1250000000000000, - }, - 0xe389...0xe38c, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_width = 0.9987004548408057, - .relative_height = 0.9974025974025974, - .relative_y = 0.0012987012987013, + .relative_height = 0.5200000000000000, + .relative_y = 0.2630769230769231, }, 0xe38e...0xe391, 0xe394, @@ -1349,25 +1313,70 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.9987012987012988, .relative_x = 0.4990253411306043, }, - 0xe395...0xe396, - 0xe39b, - 0xe3a2...0xe3a8, + 0xe395, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7568897637795275, - .relative_y = 0.1190944881889764, + .relative_width = 0.5471085120207927, + .relative_height = 0.9987012987012988, + .relative_x = 0.4515919428200130, }, - 0xe397...0xe39a, + 0xe396, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7578740157480315, - .relative_y = 0.1190944881889764, + .relative_width = 0.5945419103313840, + .relative_height = 0.9987012987012988, + .relative_x = 0.4041585445094217, + }, + 0xe397, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6426250812215725, + .relative_x = 0.3573749187784275, + }, + 0xe398, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6900584795321637, + .relative_x = 0.3099415204678362, + }, + 0xe399, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7381416504223521, + .relative_x = 0.2618583495776478, + }, + 0xe39a, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7855750487329435, + .relative_x = 0.2144249512670565, + }, + 0xe39b, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9987004548408057, + .relative_height = 0.9987012987012988, }, 0xe39c, => .{ @@ -1375,8 +1384,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7529527559055118, - .relative_y = 0.1190944881889764, + .relative_width = 0.8323586744639376, + .relative_height = 0.9935064935064936, }, 0xe39d, => .{ @@ -1384,17 +1393,35 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7539370078740157, - .relative_y = 0.1190944881889764, + .relative_width = 0.7855750487329435, + .relative_height = 0.9948051948051948, }, - 0xe39e...0xe3a0, + 0xe39e, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7549212598425197, - .relative_y = 0.1190944881889764, + .relative_width = 0.7381416504223521, + .relative_height = 0.9961038961038962, + }, + 0xe39f, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6907082521117609, + .relative_height = 0.9961038961038962, + }, + 0xe3a0, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6426250812215725, + .relative_height = 0.9961038961038962, }, 0xe3a1, => .{ @@ -1402,17 +1429,29 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7559055118110236, - .relative_y = 0.1190944881889764, + .relative_width = 0.5945419103313840, + .relative_height = 0.9974025974025974, }, - 0xe3a9, + 0xe3a2...0xe3a3, + 0xe3a5, + 0xe3a7...0xe3a8, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7568897637795275, - .relative_y = 0.1181102362204724, + .relative_width = 0.4990253411306043, + .relative_height = 0.9987012987012988, + }, + 0xe3a4, + 0xe3a6, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.4996751137102014, + .relative_height = 0.9987012987012988, }, 0xe3aa, => .{ @@ -1420,8 +1459,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.9980314960629921, - .relative_y = 0.0019685039370079, + .relative_height = 0.9902343750000000, + .relative_y = 0.0078125000000000, }, 0xe3ab, => .{ @@ -1429,7 +1468,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7962598425196851, + .relative_height = 0.7900390625000000, + .relative_y = 0.0058593750000000, }, 0xe3ac, => .{ @@ -1437,8 +1477,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8316929133858267, - .relative_y = 0.0019685039370079, + .relative_height = 0.8251953125000000, + .relative_y = 0.0078125000000000, }, 0xe3ad, => .{ @@ -1446,8 +1486,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7578740157480315, - .relative_y = 0.0009842519685039, + .relative_height = 0.7519531250000000, + .relative_y = 0.0068359375000000, }, 0xe3ae, => .{ @@ -1455,110 +1495,28 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.6200787401574803, - .relative_y = 0.2283464566929134, + .relative_height = 0.6152343750000000, + .relative_y = 0.2324218750000000, }, 0xe3af, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7057086614173228, - .relative_y = 0.1456692913385827, - }, - 0xe3b0, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7037401574803149, - .relative_y = 0.1476377952755905, - }, - 0xe3b1, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7125062282012955, - .relative_y = 0.1400099651220728, - }, - 0xe3b2, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6982421875000000, - .relative_y = 0.1523437500000000, - }, 0xe3b3, - 0xe3b5...0xe3b6, + 0xe3b5...0xe3bb, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7001953125000000, - .relative_y = 0.1503906250000000, + .relative_height = 0.9986072423398329, + .relative_y = 0.0013927576601671, }, - 0xe3b4, + 0xe3b0...0xe3b2, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7011718750000000, - .relative_y = 0.1494140625000000, - }, - 0xe3b7...0xe3bb, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7000244081034903, - .relative_y = 0.1505979985355138, - }, - 0xe3bc, - 0xe3c0, - 0xe3c3, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9997559189650964, - .relative_y = 0.0002440810349036, - }, - 0xe3bd, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9431291188674640, - .relative_y = 0.0285574810837198, - }, - 0xe3be, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9896346920510943, - .relative_y = 0.0051257017329753, - }, - 0xe3bf, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9060288015621186, - .relative_y = 0.0471076397363925, + .relative_height = 0.9958217270194986, + .relative_y = 0.0041782729805014, }, 0xe3c1, => .{ @@ -1577,35 +1535,26 @@ pub fn getConstraint(cp: u21) ?Constraint { .align_vertical = .center1, .relative_height = 0.7939956065413717, }, - 0xe3c9...0xe3ca, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9175627240143369, - .relative_y = 0.0824372759856631, - }, 0x23fb...0x23fe, 0x2665, 0x26a1, 0x2b58, 0xe000...0xe00a, 0xe200...0xe2a9, - 0xe342, - 0xe346...0xe347, - 0xe34a, - 0xe34e...0xe350, - 0xe354...0xe355, - 0xe362...0xe363, + 0xe342...0xe343, + 0xe346, + 0xe348, + 0xe34a...0xe34b, + 0xe34e, + 0xe350, + 0xe35c...0xe35d, 0xe368, 0xe36a, - 0xe36f...0xe370, 0xe37c, - 0xe384, - 0xe38d, - 0xe3c4...0xe3c8, - 0xe3cb...0xe3e3, + 0xe381...0xe38d, + 0xe3b4, + 0xe3bc...0xe3c0, + 0xe3c3...0xe3e3, 0xe5fa...0xe6b8, 0xe700...0xe8ef, 0xea60, @@ -1658,23 +1607,27 @@ pub fn getConstraint(cp: u21) ?Constraint { 0xf22e...0xf254, 0xf259, 0xf25c...0xf381, - 0xf400...0xf418, - 0xf41a...0xf42f, - 0xf431...0xf43d, - 0xf43f, - 0xf441...0xf443, - 0xf445...0xf449, - 0xf44b...0xf450, - 0xf454...0xf459, - 0xf45c...0xf470, - 0xf472...0xf47a, - 0xf47c...0xf480, - 0xf482...0xf491, - 0xf493...0xf49e, - 0xf4a0...0xf4c2, + 0xf400...0xf415, + 0xf417...0xf423, + 0xf425...0xf430, + 0xf435...0xf437, + 0xf439...0xf43d, + 0xf43f...0xf442, + 0xf446...0xf449, + 0xf44c...0xf45b, + 0xf45d...0xf45f, + 0xf462...0xf466, + 0xf468...0xf46b, + 0xf46d...0xf46f, + 0xf471...0xf475, + 0xf477...0xf479, + 0xf47f...0xf48a, + 0xf48c...0xf492, + 0xf494...0xf499, + 0xf49b...0xf4c2, 0xf4c4...0xf4ee, 0xf4f3...0xf51c, - 0xf51e...0xf532, + 0xf51e...0xf533, 0xf0001...0xf1af0, => .{ .size = .fit_cover1, @@ -1955,8 +1908,6 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_width = 0.8008342022940563, .relative_x = 0.1991657977059437, }, - 0xe0ba, - 0xe0be, 0xee00, 0xee03, => .{ @@ -1964,10 +1915,14 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = -0.05, - .pad_right = -0.05, - .pad_top = -0.01, - .pad_bottom = -0.01, + .relative_width = 0.8681172291296625, + .relative_height = 0.8626692456479691, + .relative_x = 0.1314387211367673, + .relative_y = 0.0686653771760155, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, }, 0xee01, 0xee04, @@ -1976,13 +1931,13 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .center1, .align_vertical = .center1, - .pad_left = -0.1, - .pad_right = -0.1, - .pad_top = -0.01, - .pad_bottom = -0.01, + .relative_height = 0.8626692456479691, + .relative_y = 0.0686653771760155, + .pad_left = -0.05, + .pad_right = -0.05, + .pad_top = -0.005, + .pad_bottom = -0.005, }, - 0xe0b8, - 0xe0bc, 0xee02, 0xee05, => .{ @@ -1990,10 +1945,13 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.05, - .pad_right = -0.05, - .pad_top = -0.01, - .pad_bottom = -0.01, + .relative_width = 0.8685612788632326, + .relative_height = 0.8626692456479691, + .relative_y = 0.0686653771760155, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, }, 0xee06, => .{ @@ -2005,10 +1963,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.2234524408656266, .relative_x = 0.1470292044310171, .relative_y = 0.7765475591343735, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, }, 0xee07, => .{ @@ -2020,10 +1978,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.7498741821841973, .relative_x = 0.5000000000000000, .relative_y = 0.2501258178158027, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, }, 0xee08, => .{ @@ -2034,10 +1992,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_width = 0.6299093655589124, .relative_height = 0.8535480624056366, .relative_x = 0.3700906344410876, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, }, 0xee09, => .{ @@ -2046,10 +2004,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .align_horizontal = .center1, .align_vertical = .center1, .relative_height = 0.4997483643683945, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, }, 0xee0a, => .{ @@ -2059,10 +2017,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .align_vertical = .center1, .relative_width = 0.6299093655589124, .relative_height = 0.8535480624056366, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, }, 0xee0b, => .{ @@ -2073,10 +2031,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_width = 0.5000000000000000, .relative_height = 0.7498741821841973, .relative_y = 0.2501258178158027, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, }, 0xf005, => .{ @@ -2502,24 +2460,80 @@ pub fn getConstraint(cp: u21) ?Constraint { .align_vertical = .center1, .relative_height = 0.9975006099019084, }, - 0xf419, - 0xf45a, + 0xf416, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8750000000000000, - .relative_y = 0.0625000000000000, + .relative_height = 0.6090604026845637, + .relative_y = 0.2119686800894855, }, - 0xf430, + 0xf424, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8496093750000000, - .relative_y = 0.0751953125000000, + .relative_width = 0.5019531250000000, + .relative_height = 0.5755033557046980, + .relative_x = 0.2480468750000000, + .relative_y = 0.2108501118568233, + }, + 0xf431, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6240234375000000, + .relative_height = 0.7695749440715883, + .relative_x = 0.2031250000000000, + .relative_y = 0.1420581655480984, + }, + 0xf432, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6718750000000000, + .relative_height = 0.7147651006711410, + .relative_x = 0.1875000000000000, + .relative_y = 0.1610738255033557, + }, + 0xf433, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6240234375000000, + .relative_height = 0.7695749440715883, + .relative_x = 0.2041015625000000, + .relative_y = 0.0883668903803132, + }, + 0xf434, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6718750000000000, + .relative_height = 0.7147651006711410, + .relative_x = 0.1406250000000000, + .relative_y = 0.1599552572706935, + }, + 0xf438, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.2436523437500000, + .relative_height = 0.4560546875000000, + .relative_x = 0.3813476562500000, + .relative_y = 0.2719726562500000, }, 0xf43e, => .{ @@ -2527,26 +2541,31 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.5024414062500000, - .relative_y = 0.2500000000000000, + .relative_width = 0.5029296875000000, + .relative_height = 0.5755033557046980, + .relative_x = 0.2500000000000000, + .relative_y = 0.2136465324384788, }, - 0xf440, - 0xf492, + 0xf443, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8437500000000000, - .relative_y = 0.0781250000000000, + .relative_width = 0.7500000000000000, + .relative_x = 0.1250000000000000, }, - 0xf444, + 0xf444...0xf445, + 0xf4c3, + 0xf51d, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, + .relative_width = 0.5000000000000000, .relative_height = 0.5000000000000000, + .relative_x = 0.2500000000000000, .relative_y = 0.2500000000000000, }, 0xf44a, @@ -2555,55 +2574,169 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, + .relative_width = 0.2436523437500000, .relative_height = 0.4560546875000000, + .relative_x = 0.3750000000000000, .relative_y = 0.2719726562500000, }, - 0xf45b, + 0xf44b, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.0937500000000000, - .relative_y = 0.4531250000000000, + .relative_width = 0.4560546875000000, + .relative_height = 0.2436523437500000, + .relative_x = 0.2719726562500000, + .relative_y = 0.3188476562500000, }, - 0xf471, - 0xf481, + 0xf45c, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.9375000000000000, - .relative_y = 0.0312500000000000, + .relative_width = 0.5019531250000000, + .relative_height = 0.5749440715883669, + .relative_x = 0.2480468750000000, + .relative_y = 0.2114093959731544, }, - 0xf47b, + 0xf460, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, + .relative_width = 0.3593750000000000, + .relative_height = 0.6240234375000000, + .relative_x = 0.3750000000000000, + .relative_y = 0.1884765625000000, + }, + 0xf461, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6237816764132553, + .relative_height = 0.9988851727982163, + .relative_x = 0.1881091617933723, + }, + 0xf467, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.5639648437500000, + .relative_height = 0.5649414062500000, + .relative_x = 0.2187500000000000, + .relative_y = 0.2177734375000000, + }, + 0xf46c, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.5039062500000000, + .relative_height = 0.5771812080536913, + .relative_x = 0.2490234375000000, + .relative_y = 0.2091722595078300, + }, + 0xf470, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9926757812500000, + .relative_height = 0.2690429687500000, + .relative_y = 0.6865234375000000, + }, + 0xf476, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8732325694783033, + .relative_x = 0.0633837152608484, + }, + 0xf47a, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.5843079922027290, + .relative_height = 0.9509476031215162, + .relative_x = 0.2066276803118908, + .relative_y = 0.0234113712374582, + }, + 0xf47b...0xf47c, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6250000000000000, .relative_height = 0.3593750000000000, + .relative_x = 0.1875000000000000, .relative_y = 0.3281250000000000, }, - 0xf49f, + 0xf47d, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7680000000000000, - .relative_y = 0.1160000000000000, + .relative_width = 0.3593750000000000, + .relative_height = 0.6240234375000000, + .relative_x = 0.2656250000000000, + .relative_y = 0.1875000000000000, }, - 0xf4c3, - 0xf51d, + 0xf47e, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.5417989417989418, - .relative_y = 0.2291005291005291, + .relative_width = 0.4560546875000000, + .relative_height = 0.2436523437500000, + .relative_x = 0.2719726562500000, + .relative_y = 0.3750000000000000, + }, + 0xf48b, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7187500000000000, + .relative_height = 0.0937500000000000, + .relative_x = 0.1250000000000000, + .relative_y = 0.4687500000000000, + }, + 0xf493, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8313840155945419, + .relative_height = 0.9509476031215162, + .relative_x = 0.0843079922027290, + .relative_y = 0.0234113712374582, + }, + 0xf49a, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8727450024378351, + .relative_x = 0.0633837152608484, }, 0xf4ef, 0xf4f2, @@ -2636,15 +2769,6 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_x = 0.0357142857142857, .relative_y = 0.1111111111111111, }, - 0xf533, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9228395061728395, - .relative_y = 0.0390946502057613, - }, else => null, }; } diff --git a/src/font/nerd_font_codegen.py b/src/font/nerd_font_codegen.py index 4b1a2b857..8ddc0c113 100644 --- a/src/font/nerd_font_codegen.py +++ b/src/font/nerd_font_codegen.py @@ -15,13 +15,14 @@ SymbolsNerdFont (not Mono!) font is passed as the first argument to it. import ast import sys import math -from fontTools.ttLib import TTFont +from fontTools.ttLib import TTFont, TTLibError from fontTools.pens.boundsPen import BoundsPen from collections import defaultdict from contextlib import suppress from pathlib import Path from types import SimpleNamespace from typing import Literal, TypedDict, cast +from urllib.request import urlretrieve type PatchSetAttributes = dict[Literal["default"] | int, PatchSetAttributeEntry] type AttributeHash = tuple[ @@ -58,6 +59,8 @@ class PatchSetAttributeEntry(TypedDict): class PatchSet(TypedDict): Name: str + Filename: str + Exact: bool SymStart: int SymEnd: int SrcStart: int | None @@ -69,6 +72,18 @@ class PatchSetExtractor(ast.NodeVisitor): def __init__(self) -> None: self.symbol_table: dict[str, ast.expr] = {} self.patch_set_values: list[PatchSet] = [] + self.nf_version: str = "" + + def visit_Assign(self, node): + if ( + node.col_offset == 0 # top-level assignment + and len(node.targets) == 1 # no funny destructuring business + and isinstance(node.targets[0], ast.Name) # no setitem et cetera + and node.targets[0].id == "version" # it's the version string! + ): + self.nf_version = ast.literal_eval(node.value) + else: + return self.generic_visit(node) def visit_ClassDef(self, node: ast.ClassDef) -> None: if node.name != "font_patcher": @@ -140,12 +155,8 @@ class PatchSetExtractor(ast.NodeVisitor): def process_patch_entry(self, dict_node: ast.Dict) -> None: entry = {} - disallowed_key_nodes = frozenset({"Filename", "Exact"}) for key_node, value_node in zip(dict_node.keys, dict_node.values): - if ( - isinstance(key_node, ast.Constant) - and key_node.value not in disallowed_key_nodes - ): + if isinstance(key_node, ast.Constant): if key_node.value == "Enabled": if self.safe_literal_eval(value_node): continue # This patch set is enabled, continue to next key @@ -156,11 +167,11 @@ class PatchSetExtractor(ast.NodeVisitor): self.patch_set_values.append(cast("PatchSet", entry)) -def extract_patch_set_values(source_code: str) -> list[PatchSet]: +def extract_patch_set_values(source_code: str) -> tuple[list[PatchSet], str]: tree = ast.parse(source_code) extractor = PatchSetExtractor() extractor.visit(tree) - return extractor.patch_set_values + return extractor.patch_set_values, extractor.nf_version def parse_alignment(val: str) -> str | None: @@ -271,12 +282,12 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry) # `overlap` and `ypadding` are mutually exclusive, # this is asserted in the nerd fonts patcher itself. if overlap: - pad = -overlap + pad = -overlap / 2 s += f" .pad_left = {pad},\n" s += f" .pad_right = {pad},\n" # In the nerd fonts patcher, overlap values # are capped at 0.01 in the vertical direction. - v_pad = -min(0.01, overlap) + v_pad = -min(0.01, overlap) / 2 s += f" .pad_top = {v_pad},\n" s += f" .pad_bottom = {v_pad},\n" elif y_padding: @@ -290,12 +301,124 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry) return s +def generate_codepoint_tables( + patch_sets: list[PatchSet], + nerd_font: TTFont, + nf_version: str, +) -> dict[str, dict[int, int]]: + # We may already have the table saved from a previous run. + if Path("nerd_font_codepoint_tables.py").exists(): + import nerd_font_codepoint_tables + + if nerd_font_codepoint_tables.version == nf_version: + return nerd_font_codepoint_tables.cp_tables + + cp_tables: dict[str, dict[int, int]] = {} + cp_nerdfont_used: set[int] = set() + cmap = nerd_font.getBestCmap() + for entry in patch_sets: + patch_set_name = entry["Name"] + print(f"Info: Extracting codepoint table from patch set '{patch_set_name}'") + + # Extract codepoint map from original font file; download if needed + source_filename = entry["Filename"] + target_folder = Path("nerd_font_symbol_fonts") + target_folder.mkdir(exist_ok=True) + target_file = target_folder / Path(source_filename).name + if not target_file.exists(): + print(f"Info: Downloading '{source_filename}'") + urlretrieve( + f"https://github.com/ryanoasis/nerd-fonts/raw/refs/tags/v{nf_version}/src/glyphs/{source_filename}", + target_file, + ) + try: + with TTFont(target_file) as patchfont: + patch_cmap = patchfont.getBestCmap() + except TTLibError: + # Not a TTF/OTF font. This is OK if this patch set is exact, so we + # let if pass. If there's a problem, later checks will catch it. + patch_cmap = None + + # A glyph's scale rules are specified using its codepoint in + # the original font, which is sometimes different from its + # Nerd Font codepoint. If entry["Exact"] is False, the codepoints are + # mapped according to the following rules: + # * entry["SymStart"] and entry["SymEnd"] denote the patch set's codepoint + # range in the original font. + # * entry["SrcStart"] is the starting point of the patch set's mapped + # codepoint range. It must not be None if entry["Exact"] is False. + # * The destination codepoint range is packed; that is, while there may be + # gaps without glyphs in the original font's codepoint range, there are + # none in the Nerd Font range. Hence there is no constant codepoint + # offset; instead we must iterate through the range and increment the + # destination codepoint every time we encounter a glyph in the original + # font. + # If entry["Exact"] is True, the origin and Nerd Font codepoints are the + # same, gaps included, and entry["SrcStart"] must be None. + if entry["Exact"]: + assert entry["SrcStart"] is None + cp_nerdfont = 0 + else: + assert entry["SrcStart"] + assert patch_cmap is not None + cp_nerdfont = entry["SrcStart"] - 1 + + if patch_set_name not in cp_tables: + # There are several patch sets with the same name, representing + # different codepoint ranges within the same original font. Merging + # these into a single table is OK. However, we need to keep separate + # tables for the different fonts to correctly deal with cases where + # they fill in each other's gaps. + cp_tables[patch_set_name] = {} + for cp_original in range(entry["SymStart"], entry["SymEnd"] + 1): + if patch_cmap and cp_original not in patch_cmap: + continue + if not entry["Exact"]: + cp_nerdfont += 1 + else: + cp_nerdfont = cp_original + if cp_nerdfont not in cmap: + raise ValueError( + f"Missing codepoint in Symbols Only Font: {hex(cp_nerdfont)} in patch set '{patch_set_name}'" + ) + elif cp_nerdfont in cp_nerdfont_used: + raise ValueError( + f"Overlap for codepoint {hex(cp_nerdfont)} in patch set '{patch_set_name}'" + ) + cp_tables[patch_set_name][cp_original] = cp_nerdfont + cp_nerdfont_used.add(cp_nerdfont) + + # Store the table and corresponding Nerd Fonts version together in a module. + with open("nerd_font_codepoint_tables.py", "w") as f: + print( + """#! This is a generated file, produced by nerd_font_codegen.py +#! DO NOT EDIT BY HAND! +#! +#! This file specifies the mapping of codepoints in the original symbol +#! fonts to codepoints in a patched Nerd Font. This is extracted from +#! the nerd fonts patcher script and the symbol font files.""", + file=f, + ) + print(f'version = "{nf_version}"', file=f) + print("cp_tables = {", file=f) + for name, table in cp_tables.items(): + print(f' "{name}": {{', file=f) + for key, value in table.items(): + print(f" {hex(key)}: {hex(value)},", file=f) + print(" },", file=f) + print("}", file=f) + + return cp_tables + + def generate_zig_switch_arms( patch_sets: list[PatchSet], nerd_font: TTFont, + nf_version: str, ) -> str: cmap = nerd_font.getBestCmap() glyphs = nerd_font.getGlyphSet() + cp_tables = generate_codepoint_tables(patch_sets, nerd_font, nf_version) entries: dict[int, PatchSetAttributeEntry] = {} for entry in patch_sets: @@ -305,47 +428,21 @@ def generate_zig_switch_arms( attributes = entry["Attributes"] patch_set_entries: dict[int, PatchSetAttributeEntry] = {} - # A glyph's scale rules are specified using its codepoint in - # the original font, which is sometimes different from its - # Nerd Font codepoint. In font_patcher, the font to be patched - # (including the Symbols Only font embedded in Ghostty) is - # termed the sourceFont, while the original font is the - # symbolFont. Thus, the offset that maps the scale rule - # codepoint to the Nerd Font codepoint is SrcStart - SymStart. - cp_offset = entry["SrcStart"] - entry["SymStart"] if entry["SrcStart"] else 0 - for cp_rule in range(entry["SymStart"], entry["SymEnd"] + 1): - cp_font = cp_rule + cp_offset - if cp_font not in cmap: - print(f"Info: Skipping missing codepoint {hex(cp_font)}") + cp_table = cp_tables[patch_set_name] + for cp_original in range(entry["SymStart"], entry["SymEnd"] + 1): + if cp_original not in cp_table: continue - elif cp_font in entries: - # Patch sets sometimes have overlapping codepoint ranges. - # Sometimes a later set is a smaller set filling in a gap - # in the range of a larger, preceding set. Sometimes it's - # the other way around. The best thing we can do is hardcode - # each case. - if patch_set_name == "Font Awesome": - # The Font Awesome range has a gap matching the - # prededing Progress Indicators range. - print(f"Info: Not overwriting existing codepoint {hex(cp_font)}") - continue - elif patch_set_name == "Octicons": - # The fourth Octicons range overlaps with the first. - print(f"Info: Overwriting existing codepoint {hex(cp_font)}") - else: - raise ValueError( - f"Unknown case of overlap for codepoint {hex(cp_font)} in patch set '{patch_set_name}'" - ) - if cp_rule in attributes: - patch_set_entries[cp_font] = attributes[cp_rule].copy() + cp_nerdfont = cp_table[cp_original] + if cp_nerdfont in entries: + raise ValueError( + f"Overlap for codepoint {hex(cp_nerdfont)} in patch set '{patch_set_name}'" + ) + if cp_original in attributes: + patch_set_entries[cp_nerdfont] = attributes[cp_original].copy() else: - patch_set_entries[cp_font] = attributes["default"].copy() + patch_set_entries[cp_nerdfont] = attributes["default"].copy() if entry["ScaleRules"] is not None: - if "ScaleGroups" not in entry["ScaleRules"]: - raise ValueError( - f"Scale rule format {entry['ScaleRules']} not implemented." - ) for group in entry["ScaleRules"]["ScaleGroups"]: xMin = math.inf yMin = math.inf @@ -353,15 +450,43 @@ def generate_zig_switch_arms( yMax = -math.inf individual_bounds: dict[int, tuple[int, int, int, int]] = {} individual_advances: set[float] = set() - for cp_rule in group: - cp_font = cp_rule + cp_offset - if cp_font not in cmap: + for cp_original in group: + if cp_original not in cp_table: + # There is one special case where a scale group includes + # a glyph from the original font that's not in any patch + # set, and hence not in the Symbols Only font. The point + # of this glyph is to add extra vertical padding to a + # stretched (^xy) scale group, which means that its + # scaled and aligned position would span the line height + # plus overlap. Thus, we can use any other stretched + # glyph with overlap as stand-in to get the vertical + # bounds, such as as 0xE0B0 (powerline left hard + # divider). We don't worry about the horizontal bounds, + # as they by design should not affect the group's + # bounding box. + if ( + patch_set_name == "Progress Indicators" + and cp_original == 0xEDFF + ): + glyph = glyphs[cmap[0xE0B0]] + bounds = BoundsPen(glyphSet=glyphs) + glyph.draw(bounds) + yMin = min(bounds.bounds[1], yMin) + yMax = max(bounds.bounds[3], yMax) + else: + # Other cases are due to lazily specified scale + # groups with gaps in the codepoint range. + print( + f"Info: Skipping scale group codepoint {hex(cp_original)}, which does not exist in patch set '{patch_set_name}'" + ) continue - glyph = glyphs[cmap[cp_font]] + + cp_nerdfont = cp_table[cp_original] + glyph = glyphs[cmap[cp_nerdfont]] individual_advances.add(glyph.width) bounds = BoundsPen(glyphSet=glyphs) glyph.draw(bounds) - individual_bounds[cp_font] = bounds.bounds + individual_bounds[cp_nerdfont] = bounds.bounds xMin = min(bounds.bounds[0], xMin) yMin = min(bounds.bounds[1], yMin) xMax = max(bounds.bounds[2], xMax) @@ -371,34 +496,38 @@ def generate_zig_switch_arms( group_is_monospace = (len(individual_bounds) > 1) and ( len(individual_advances) == 1 ) - for cp_rule in group: - cp_font = cp_rule + cp_offset + for cp_original in group: + if cp_original not in cp_table: + continue + cp_nerdfont = cp_table[cp_original] if ( - cp_font not in cmap - or cp_font not in patch_set_entries + # Scale groups may cut across patch sets, but we're only + # updating a single patch set at a time, so we skip + # codepoints not in it. + cp_nerdfont not in patch_set_entries # Codepoints may contribute to the bounding box of multiple groups, # but should be scaled according to the first group they are found # in. Hence, to avoid overwriting, we need to skip codepoints that # have already been assigned a scale group. - or "relative_height" in patch_set_entries[cp_font] + or "relative_height" in patch_set_entries[cp_nerdfont] ): continue - this_bounds = individual_bounds[cp_font] + this_bounds = individual_bounds[cp_nerdfont] this_height = this_bounds[3] - this_bounds[1] - patch_set_entries[cp_font]["relative_height"] = ( + patch_set_entries[cp_nerdfont]["relative_height"] = ( this_height / group_height ) - patch_set_entries[cp_font]["relative_y"] = ( + patch_set_entries[cp_nerdfont]["relative_y"] = ( this_bounds[1] - yMin ) / group_height # Horizontal alignment should only be grouped if the group is monospace, # that is, if all glyphs in the group have the same advance width. if group_is_monospace: this_width = this_bounds[2] - this_bounds[0] - patch_set_entries[cp_font]["relative_width"] = ( + patch_set_entries[cp_nerdfont]["relative_width"] = ( this_width / group_width ) - patch_set_entries[cp_font]["relative_x"] = ( + patch_set_entries[cp_nerdfont]["relative_x"] = ( this_bounds[0] - xMin ) / group_width entries |= patch_set_entries @@ -427,7 +556,7 @@ if __name__ == "__main__": patcher_path = project_root / "vendor" / "nerd-fonts" / "font-patcher.py" source = patcher_path.read_text(encoding="utf-8") - patch_set = extract_patch_set_values(source) + patch_set, nf_version = extract_patch_set_values(source) out_path = project_root / "src" / "font" / "nerd_font_attributes.zig" @@ -444,5 +573,5 @@ const Constraint = @import("face.zig").RenderOptions.Constraint; pub fn getConstraint(cp: u21) ?Constraint { return switch (cp) { """) - f.write(generate_zig_switch_arms(patch_set, nerd_font)) + f.write(generate_zig_switch_arms(patch_set, nerd_font, nf_version)) f.write("\n else => null,\n };\n}\n") diff --git a/src/font/nerd_font_codepoint_tables.py b/src/font/nerd_font_codepoint_tables.py new file mode 100644 index 000000000..89a623f1c --- /dev/null +++ b/src/font/nerd_font_codepoint_tables.py @@ -0,0 +1,10449 @@ +#! This is a generated file, produced by nerd_font_codegen.py +#! DO NOT EDIT BY HAND! +#! +#! This file specifies the mapping of codepoints in the original symbol +#! fonts to codepoints in a patched Nerd Font. This is extracted from +#! the nerd fonts patcher script and the symbol font files. +version = "3.4.0" +cp_tables = { + "Seti-UI + Custom": { + 0xe4fa: 0xe5fa, + 0xe4fb: 0xe5fb, + 0xe4fc: 0xe5fc, + 0xe4fd: 0xe5fd, + 0xe4fe: 0xe5fe, + 0xe4ff: 0xe5ff, + 0xe500: 0xe600, + 0xe501: 0xe601, + 0xe502: 0xe602, + 0xe503: 0xe603, + 0xe504: 0xe604, + 0xe505: 0xe605, + 0xe506: 0xe606, + 0xe507: 0xe607, + 0xe508: 0xe608, + 0xe509: 0xe609, + 0xe50a: 0xe60a, + 0xe50b: 0xe60b, + 0xe50c: 0xe60c, + 0xe50d: 0xe60d, + 0xe50e: 0xe60e, + 0xe50f: 0xe60f, + 0xe510: 0xe610, + 0xe511: 0xe611, + 0xe512: 0xe612, + 0xe513: 0xe613, + 0xe514: 0xe614, + 0xe515: 0xe615, + 0xe516: 0xe616, + 0xe517: 0xe617, + 0xe518: 0xe618, + 0xe519: 0xe619, + 0xe51a: 0xe61a, + 0xe51b: 0xe61b, + 0xe51c: 0xe61c, + 0xe51d: 0xe61d, + 0xe51e: 0xe61e, + 0xe51f: 0xe61f, + 0xe520: 0xe620, + 0xe521: 0xe621, + 0xe522: 0xe622, + 0xe523: 0xe623, + 0xe524: 0xe624, + 0xe525: 0xe625, + 0xe526: 0xe626, + 0xe527: 0xe627, + 0xe528: 0xe628, + 0xe529: 0xe629, + 0xe52a: 0xe62a, + 0xe52b: 0xe62b, + 0xe52c: 0xe62c, + 0xe52d: 0xe62d, + 0xe52e: 0xe62e, + 0xe52f: 0xe62f, + 0xe530: 0xe630, + 0xe531: 0xe631, + 0xe532: 0xe632, + 0xe533: 0xe633, + 0xe534: 0xe634, + 0xe535: 0xe635, + 0xe536: 0xe636, + 0xe537: 0xe637, + 0xe538: 0xe638, + 0xe539: 0xe639, + 0xe53a: 0xe63a, + 0xe53b: 0xe63b, + 0xe53c: 0xe63c, + 0xe53d: 0xe63d, + 0xe53e: 0xe63e, + 0xe53f: 0xe63f, + 0xe540: 0xe640, + 0xe541: 0xe641, + 0xe542: 0xe642, + 0xe543: 0xe643, + 0xe544: 0xe644, + 0xe545: 0xe645, + 0xe546: 0xe646, + 0xe547: 0xe647, + 0xe548: 0xe648, + 0xe549: 0xe649, + 0xe54a: 0xe64a, + 0xe54b: 0xe64b, + 0xe54c: 0xe64c, + 0xe54d: 0xe64d, + 0xe54e: 0xe64e, + 0xe54f: 0xe64f, + 0xe550: 0xe650, + 0xe551: 0xe651, + 0xe552: 0xe652, + 0xe553: 0xe653, + 0xe554: 0xe654, + 0xe555: 0xe655, + 0xe556: 0xe656, + 0xe557: 0xe657, + 0xe558: 0xe658, + 0xe559: 0xe659, + 0xe55a: 0xe65a, + 0xe55b: 0xe65b, + 0xe55c: 0xe65c, + 0xe55d: 0xe65d, + 0xe55e: 0xe65e, + 0xe55f: 0xe65f, + 0xe560: 0xe660, + 0xe561: 0xe661, + 0xe562: 0xe662, + 0xe563: 0xe663, + 0xe564: 0xe664, + 0xe565: 0xe665, + 0xe566: 0xe666, + 0xe567: 0xe667, + 0xe568: 0xe668, + 0xe569: 0xe669, + 0xe56a: 0xe66a, + 0xe56b: 0xe66b, + 0xe56c: 0xe66c, + 0xe56d: 0xe66d, + 0xe56e: 0xe66e, + 0xe56f: 0xe66f, + 0xe570: 0xe670, + 0xe571: 0xe671, + 0xe572: 0xe672, + 0xe573: 0xe673, + 0xe574: 0xe674, + 0xe575: 0xe675, + 0xe576: 0xe676, + 0xe577: 0xe677, + 0xe578: 0xe678, + 0xe579: 0xe679, + 0xe57a: 0xe67a, + 0xe57b: 0xe67b, + 0xe57c: 0xe67c, + 0xe57d: 0xe67d, + 0xe57e: 0xe67e, + 0xe57f: 0xe67f, + 0xe580: 0xe680, + 0xe581: 0xe681, + 0xe582: 0xe682, + 0xe583: 0xe683, + 0xe584: 0xe684, + 0xe585: 0xe685, + 0xe586: 0xe686, + 0xe587: 0xe687, + 0xe588: 0xe688, + 0xe589: 0xe689, + 0xe58a: 0xe68a, + 0xe58b: 0xe68b, + 0xe58c: 0xe68c, + 0xe58d: 0xe68d, + 0xe58e: 0xe68e, + 0xe58f: 0xe68f, + 0xe590: 0xe690, + 0xe591: 0xe691, + 0xe592: 0xe692, + 0xe593: 0xe693, + 0xe594: 0xe694, + 0xe595: 0xe695, + 0xe596: 0xe696, + 0xe597: 0xe697, + 0xe598: 0xe698, + 0xe599: 0xe699, + 0xe59a: 0xe69a, + 0xe59b: 0xe69b, + 0xe59c: 0xe69c, + 0xe59d: 0xe69d, + 0xe59e: 0xe69e, + 0xe59f: 0xe69f, + 0xe5a0: 0xe6a0, + 0xe5a1: 0xe6a1, + 0xe5a2: 0xe6a2, + 0xe5a3: 0xe6a3, + 0xe5a4: 0xe6a4, + 0xe5a5: 0xe6a5, + 0xe5a6: 0xe6a6, + 0xe5a7: 0xe6a7, + 0xe5a8: 0xe6a8, + 0xe5a9: 0xe6a9, + 0xe5aa: 0xe6aa, + 0xe5ab: 0xe6ab, + 0xe5ac: 0xe6ac, + 0xe5ad: 0xe6ad, + 0xe5ae: 0xe6ae, + 0xe5af: 0xe6af, + 0xe5b0: 0xe6b0, + 0xe5b1: 0xe6b1, + 0xe5b2: 0xe6b2, + 0xe5b3: 0xe6b3, + 0xe5b4: 0xe6b4, + 0xe5b5: 0xe6b5, + 0xe5b6: 0xe6b6, + 0xe5b7: 0xe6b7, + 0xe5b8: 0xe6b8, + }, + "Heavy Angle Brackets": { + 0x276c: 0x276c, + 0x276d: 0x276d, + 0x276e: 0x276e, + 0x276f: 0x276f, + 0x2770: 0x2770, + 0x2771: 0x2771, + }, + "Progress Indicators": { + 0xee00: 0xee00, + 0xee01: 0xee01, + 0xee02: 0xee02, + 0xee03: 0xee03, + 0xee04: 0xee04, + 0xee05: 0xee05, + 0xee06: 0xee06, + 0xee07: 0xee07, + 0xee08: 0xee08, + 0xee09: 0xee09, + 0xee0a: 0xee0a, + 0xee0b: 0xee0b, + }, + "Devicons": { + 0xe600: 0xe700, + 0xe601: 0xe701, + 0xe602: 0xe702, + 0xe603: 0xe703, + 0xe604: 0xe704, + 0xe605: 0xe705, + 0xe606: 0xe706, + 0xe607: 0xe707, + 0xe608: 0xe708, + 0xe609: 0xe709, + 0xe60a: 0xe70a, + 0xe60b: 0xe70b, + 0xe60c: 0xe70c, + 0xe60d: 0xe70d, + 0xe60e: 0xe70e, + 0xe60f: 0xe70f, + 0xe610: 0xe710, + 0xe611: 0xe711, + 0xe612: 0xe712, + 0xe613: 0xe713, + 0xe614: 0xe714, + 0xe615: 0xe715, + 0xe616: 0xe716, + 0xe617: 0xe717, + 0xe618: 0xe718, + 0xe619: 0xe719, + 0xe61a: 0xe71a, + 0xe61b: 0xe71b, + 0xe61c: 0xe71c, + 0xe61d: 0xe71d, + 0xe61e: 0xe71e, + 0xe61f: 0xe71f, + 0xe620: 0xe720, + 0xe621: 0xe721, + 0xe622: 0xe722, + 0xe623: 0xe723, + 0xe624: 0xe724, + 0xe625: 0xe725, + 0xe626: 0xe726, + 0xe627: 0xe727, + 0xe628: 0xe728, + 0xe629: 0xe729, + 0xe62a: 0xe72a, + 0xe62b: 0xe72b, + 0xe62c: 0xe72c, + 0xe62d: 0xe72d, + 0xe62e: 0xe72e, + 0xe62f: 0xe72f, + 0xe630: 0xe730, + 0xe631: 0xe731, + 0xe632: 0xe732, + 0xe633: 0xe733, + 0xe634: 0xe734, + 0xe635: 0xe735, + 0xe636: 0xe736, + 0xe637: 0xe737, + 0xe638: 0xe738, + 0xe639: 0xe739, + 0xe63a: 0xe73a, + 0xe63b: 0xe73b, + 0xe63c: 0xe73c, + 0xe63d: 0xe73d, + 0xe63e: 0xe73e, + 0xe63f: 0xe73f, + 0xe640: 0xe740, + 0xe641: 0xe741, + 0xe642: 0xe742, + 0xe643: 0xe743, + 0xe644: 0xe744, + 0xe645: 0xe745, + 0xe646: 0xe746, + 0xe647: 0xe747, + 0xe648: 0xe748, + 0xe649: 0xe749, + 0xe64a: 0xe74a, + 0xe64b: 0xe74b, + 0xe64c: 0xe74c, + 0xe64d: 0xe74d, + 0xe64e: 0xe74e, + 0xe64f: 0xe74f, + 0xe650: 0xe750, + 0xe651: 0xe751, + 0xe652: 0xe752, + 0xe653: 0xe753, + 0xe654: 0xe754, + 0xe655: 0xe755, + 0xe656: 0xe756, + 0xe657: 0xe757, + 0xe658: 0xe758, + 0xe659: 0xe759, + 0xe65a: 0xe75a, + 0xe65b: 0xe75b, + 0xe65c: 0xe75c, + 0xe65d: 0xe75d, + 0xe65e: 0xe75e, + 0xe65f: 0xe75f, + 0xe660: 0xe760, + 0xe661: 0xe761, + 0xe662: 0xe762, + 0xe663: 0xe763, + 0xe664: 0xe764, + 0xe665: 0xe765, + 0xe666: 0xe766, + 0xe667: 0xe767, + 0xe668: 0xe768, + 0xe669: 0xe769, + 0xe66a: 0xe76a, + 0xe66b: 0xe76b, + 0xe66c: 0xe76c, + 0xe66d: 0xe76d, + 0xe66e: 0xe76e, + 0xe66f: 0xe76f, + 0xe670: 0xe770, + 0xe671: 0xe771, + 0xe672: 0xe772, + 0xe673: 0xe773, + 0xe674: 0xe774, + 0xe675: 0xe775, + 0xe676: 0xe776, + 0xe677: 0xe777, + 0xe678: 0xe778, + 0xe679: 0xe779, + 0xe67a: 0xe77a, + 0xe67b: 0xe77b, + 0xe67c: 0xe77c, + 0xe67d: 0xe77d, + 0xe67e: 0xe77e, + 0xe67f: 0xe77f, + 0xe680: 0xe780, + 0xe681: 0xe781, + 0xe682: 0xe782, + 0xe683: 0xe783, + 0xe684: 0xe784, + 0xe685: 0xe785, + 0xe686: 0xe786, + 0xe687: 0xe787, + 0xe688: 0xe788, + 0xe689: 0xe789, + 0xe68a: 0xe78a, + 0xe68b: 0xe78b, + 0xe68c: 0xe78c, + 0xe68d: 0xe78d, + 0xe68e: 0xe78e, + 0xe68f: 0xe78f, + 0xe690: 0xe790, + 0xe691: 0xe791, + 0xe692: 0xe792, + 0xe693: 0xe793, + 0xe694: 0xe794, + 0xe695: 0xe795, + 0xe696: 0xe796, + 0xe697: 0xe797, + 0xe698: 0xe798, + 0xe699: 0xe799, + 0xe69a: 0xe79a, + 0xe69b: 0xe79b, + 0xe69c: 0xe79c, + 0xe69d: 0xe79d, + 0xe69e: 0xe79e, + 0xe69f: 0xe79f, + 0xe6a0: 0xe7a0, + 0xe6a1: 0xe7a1, + 0xe6a2: 0xe7a2, + 0xe6a3: 0xe7a3, + 0xe6a4: 0xe7a4, + 0xe6a5: 0xe7a5, + 0xe6a6: 0xe7a6, + 0xe6a7: 0xe7a7, + 0xe6a8: 0xe7a8, + 0xe6a9: 0xe7a9, + 0xe6aa: 0xe7aa, + 0xe6ab: 0xe7ab, + 0xe6ac: 0xe7ac, + 0xe6ad: 0xe7ad, + 0xe6ae: 0xe7ae, + 0xe6af: 0xe7af, + 0xe6b0: 0xe7b0, + 0xe6b1: 0xe7b1, + 0xe6b2: 0xe7b2, + 0xe6b3: 0xe7b3, + 0xe6b4: 0xe7b4, + 0xe6b5: 0xe7b5, + 0xe6b6: 0xe7b6, + 0xe6b7: 0xe7b7, + 0xe6b8: 0xe7b8, + 0xe6b9: 0xe7b9, + 0xe6ba: 0xe7ba, + 0xe6bb: 0xe7bb, + 0xe6bc: 0xe7bc, + 0xe6bd: 0xe7bd, + 0xe6be: 0xe7be, + 0xe6bf: 0xe7bf, + 0xe6c0: 0xe7c0, + 0xe6c1: 0xe7c1, + 0xe6c2: 0xe7c2, + 0xe6c3: 0xe7c3, + 0xe6c4: 0xe7c4, + 0xe6c5: 0xe7c5, + 0xe6c6: 0xe7c6, + 0xe6c7: 0xe7c7, + 0xe6c8: 0xe7c8, + 0xe6c9: 0xe7c9, + 0xe6ca: 0xe7ca, + 0xe6cb: 0xe7cb, + 0xe6cc: 0xe7cc, + 0xe6cd: 0xe7cd, + 0xe6ce: 0xe7ce, + 0xe6cf: 0xe7cf, + 0xe6d0: 0xe7d0, + 0xe6d1: 0xe7d1, + 0xe6d2: 0xe7d2, + 0xe6d3: 0xe7d3, + 0xe6d4: 0xe7d4, + 0xe6d5: 0xe7d5, + 0xe6d6: 0xe7d6, + 0xe6d7: 0xe7d7, + 0xe6d8: 0xe7d8, + 0xe6d9: 0xe7d9, + 0xe6da: 0xe7da, + 0xe6db: 0xe7db, + 0xe6dc: 0xe7dc, + 0xe6dd: 0xe7dd, + 0xe6de: 0xe7de, + 0xe6df: 0xe7df, + 0xe6e0: 0xe7e0, + 0xe6e1: 0xe7e1, + 0xe6e2: 0xe7e2, + 0xe6e3: 0xe7e3, + 0xe6e4: 0xe7e4, + 0xe6e5: 0xe7e5, + 0xe6e6: 0xe7e6, + 0xe6e7: 0xe7e7, + 0xe6e8: 0xe7e8, + 0xe6e9: 0xe7e9, + 0xe6ea: 0xe7ea, + 0xe6eb: 0xe7eb, + 0xe6ec: 0xe7ec, + 0xe6ed: 0xe7ed, + 0xe6ee: 0xe7ee, + 0xe6ef: 0xe7ef, + 0xe6f0: 0xe7f0, + 0xe6f1: 0xe7f1, + 0xe6f2: 0xe7f2, + 0xe6f3: 0xe7f3, + 0xe6f4: 0xe7f4, + 0xe6f5: 0xe7f5, + 0xe6f6: 0xe7f6, + 0xe6f7: 0xe7f7, + 0xe6f8: 0xe7f8, + 0xe6f9: 0xe7f9, + 0xe6fa: 0xe7fa, + 0xe6fb: 0xe7fb, + 0xe6fc: 0xe7fc, + 0xe6fd: 0xe7fd, + 0xe6fe: 0xe7fe, + 0xe6ff: 0xe7ff, + 0xe700: 0xe800, + 0xe701: 0xe801, + 0xe702: 0xe802, + 0xe703: 0xe803, + 0xe704: 0xe804, + 0xe705: 0xe805, + 0xe706: 0xe806, + 0xe707: 0xe807, + 0xe708: 0xe808, + 0xe709: 0xe809, + 0xe70a: 0xe80a, + 0xe70b: 0xe80b, + 0xe70c: 0xe80c, + 0xe70d: 0xe80d, + 0xe70e: 0xe80e, + 0xe70f: 0xe80f, + 0xe710: 0xe810, + 0xe711: 0xe811, + 0xe712: 0xe812, + 0xe713: 0xe813, + 0xe714: 0xe814, + 0xe715: 0xe815, + 0xe716: 0xe816, + 0xe717: 0xe817, + 0xe718: 0xe818, + 0xe719: 0xe819, + 0xe71a: 0xe81a, + 0xe71b: 0xe81b, + 0xe71c: 0xe81c, + 0xe71d: 0xe81d, + 0xe71e: 0xe81e, + 0xe71f: 0xe81f, + 0xe720: 0xe820, + 0xe721: 0xe821, + 0xe722: 0xe822, + 0xe723: 0xe823, + 0xe724: 0xe824, + 0xe725: 0xe825, + 0xe726: 0xe826, + 0xe727: 0xe827, + 0xe728: 0xe828, + 0xe729: 0xe829, + 0xe72a: 0xe82a, + 0xe72b: 0xe82b, + 0xe72c: 0xe82c, + 0xe72d: 0xe82d, + 0xe72e: 0xe82e, + 0xe72f: 0xe82f, + 0xe730: 0xe830, + 0xe731: 0xe831, + 0xe732: 0xe832, + 0xe733: 0xe833, + 0xe734: 0xe834, + 0xe735: 0xe835, + 0xe736: 0xe836, + 0xe737: 0xe837, + 0xe738: 0xe838, + 0xe739: 0xe839, + 0xe73a: 0xe83a, + 0xe73b: 0xe83b, + 0xe73c: 0xe83c, + 0xe73d: 0xe83d, + 0xe73e: 0xe83e, + 0xe73f: 0xe83f, + 0xe740: 0xe840, + 0xe741: 0xe841, + 0xe742: 0xe842, + 0xe743: 0xe843, + 0xe744: 0xe844, + 0xe745: 0xe845, + 0xe746: 0xe846, + 0xe747: 0xe847, + 0xe748: 0xe848, + 0xe749: 0xe849, + 0xe74a: 0xe84a, + 0xe74b: 0xe84b, + 0xe74c: 0xe84c, + 0xe74d: 0xe84d, + 0xe74e: 0xe84e, + 0xe74f: 0xe84f, + 0xe750: 0xe850, + 0xe751: 0xe851, + 0xe752: 0xe852, + 0xe753: 0xe853, + 0xe754: 0xe854, + 0xe755: 0xe855, + 0xe756: 0xe856, + 0xe757: 0xe857, + 0xe758: 0xe858, + 0xe759: 0xe859, + 0xe75a: 0xe85a, + 0xe75b: 0xe85b, + 0xe75c: 0xe85c, + 0xe75d: 0xe85d, + 0xe75e: 0xe85e, + 0xe75f: 0xe85f, + 0xe760: 0xe860, + 0xe761: 0xe861, + 0xe762: 0xe862, + 0xe763: 0xe863, + 0xe764: 0xe864, + 0xe765: 0xe865, + 0xe766: 0xe866, + 0xe767: 0xe867, + 0xe768: 0xe868, + 0xe769: 0xe869, + 0xe76a: 0xe86a, + 0xe76b: 0xe86b, + 0xe76c: 0xe86c, + 0xe76d: 0xe86d, + 0xe76e: 0xe86e, + 0xe76f: 0xe86f, + 0xe770: 0xe870, + 0xe771: 0xe871, + 0xe772: 0xe872, + 0xe773: 0xe873, + 0xe774: 0xe874, + 0xe775: 0xe875, + 0xe776: 0xe876, + 0xe777: 0xe877, + 0xe778: 0xe878, + 0xe779: 0xe879, + 0xe77a: 0xe87a, + 0xe77b: 0xe87b, + 0xe77c: 0xe87c, + 0xe77d: 0xe87d, + 0xe77e: 0xe87e, + 0xe77f: 0xe87f, + 0xe780: 0xe880, + 0xe781: 0xe881, + 0xe782: 0xe882, + 0xe783: 0xe883, + 0xe784: 0xe884, + 0xe785: 0xe885, + 0xe786: 0xe886, + 0xe787: 0xe887, + 0xe788: 0xe888, + 0xe789: 0xe889, + 0xe78a: 0xe88a, + 0xe78b: 0xe88b, + 0xe78c: 0xe88c, + 0xe78d: 0xe88d, + 0xe78e: 0xe88e, + 0xe78f: 0xe88f, + 0xe790: 0xe890, + 0xe791: 0xe891, + 0xe792: 0xe892, + 0xe793: 0xe893, + 0xe794: 0xe894, + 0xe795: 0xe895, + 0xe796: 0xe896, + 0xe797: 0xe897, + 0xe798: 0xe898, + 0xe799: 0xe899, + 0xe79a: 0xe89a, + 0xe79b: 0xe89b, + 0xe79c: 0xe89c, + 0xe79d: 0xe89d, + 0xe79e: 0xe89e, + 0xe79f: 0xe89f, + 0xe7a0: 0xe8a0, + 0xe7a1: 0xe8a1, + 0xe7a2: 0xe8a2, + 0xe7a3: 0xe8a3, + 0xe7a4: 0xe8a4, + 0xe7a5: 0xe8a5, + 0xe7a6: 0xe8a6, + 0xe7a7: 0xe8a7, + 0xe7a8: 0xe8a8, + 0xe7a9: 0xe8a9, + 0xe7aa: 0xe8aa, + 0xe7ab: 0xe8ab, + 0xe7ac: 0xe8ac, + 0xe7ad: 0xe8ad, + 0xe7ae: 0xe8ae, + 0xe7af: 0xe8af, + 0xe7b0: 0xe8b0, + 0xe7b1: 0xe8b1, + 0xe7b2: 0xe8b2, + 0xe7b3: 0xe8b3, + 0xe7b4: 0xe8b4, + 0xe7b5: 0xe8b5, + 0xe7b6: 0xe8b6, + 0xe7b7: 0xe8b7, + 0xe7b8: 0xe8b8, + 0xe7b9: 0xe8b9, + 0xe7ba: 0xe8ba, + 0xe7bb: 0xe8bb, + 0xe7bc: 0xe8bc, + 0xe7bd: 0xe8bd, + 0xe7be: 0xe8be, + 0xe7bf: 0xe8bf, + 0xe7c0: 0xe8c0, + 0xe7c1: 0xe8c1, + 0xe7c2: 0xe8c2, + 0xe7c3: 0xe8c3, + 0xe7c4: 0xe8c4, + 0xe7c5: 0xe8c5, + 0xe7c6: 0xe8c6, + 0xe7c7: 0xe8c7, + 0xe7c8: 0xe8c8, + 0xe7c9: 0xe8c9, + 0xe7ca: 0xe8ca, + 0xe7cb: 0xe8cb, + 0xe7cc: 0xe8cc, + 0xe7cd: 0xe8cd, + 0xe7ce: 0xe8ce, + 0xe7cf: 0xe8cf, + 0xe7d0: 0xe8d0, + 0xe7d1: 0xe8d1, + 0xe7d2: 0xe8d2, + 0xe7d3: 0xe8d3, + 0xe7d4: 0xe8d4, + 0xe7d5: 0xe8d5, + 0xe7d6: 0xe8d6, + 0xe7d7: 0xe8d7, + 0xe7d8: 0xe8d8, + 0xe7d9: 0xe8d9, + 0xe7da: 0xe8da, + 0xe7db: 0xe8db, + 0xe7dc: 0xe8dc, + 0xe7dd: 0xe8dd, + 0xe7de: 0xe8de, + 0xe7df: 0xe8df, + 0xe7e0: 0xe8e0, + 0xe7e1: 0xe8e1, + 0xe7e2: 0xe8e2, + 0xe7e3: 0xe8e3, + 0xe7e4: 0xe8e4, + 0xe7e5: 0xe8e5, + 0xe7e6: 0xe8e6, + 0xe7e7: 0xe8e7, + 0xe7e8: 0xe8e8, + 0xe7e9: 0xe8e9, + 0xe7ea: 0xe8ea, + 0xe7eb: 0xe8eb, + 0xe7ec: 0xe8ec, + 0xe7ed: 0xe8ed, + 0xe7ee: 0xe8ee, + 0xe7ef: 0xe8ef, + }, + "Powerline Symbols": { + 0xe0a0: 0xe0a0, + 0xe0a1: 0xe0a1, + 0xe0a2: 0xe0a2, + 0xe0b0: 0xe0b0, + 0xe0b1: 0xe0b1, + 0xe0b2: 0xe0b2, + 0xe0b3: 0xe0b3, + }, + "Powerline Extra Symbols": { + 0xe0a3: 0xe0a3, + 0xe0b4: 0xe0b4, + 0xe0b5: 0xe0b5, + 0xe0b6: 0xe0b6, + 0xe0b7: 0xe0b7, + 0xe0b8: 0xe0b8, + 0xe0b9: 0xe0b9, + 0xe0ba: 0xe0ba, + 0xe0bb: 0xe0bb, + 0xe0bc: 0xe0bc, + 0xe0bd: 0xe0bd, + 0xe0be: 0xe0be, + 0xe0bf: 0xe0bf, + 0xe0c0: 0xe0c0, + 0xe0c1: 0xe0c1, + 0xe0c2: 0xe0c2, + 0xe0c3: 0xe0c3, + 0xe0c4: 0xe0c4, + 0xe0c5: 0xe0c5, + 0xe0c6: 0xe0c6, + 0xe0c7: 0xe0c7, + 0xe0c8: 0xe0c8, + 0xe0ca: 0xe0ca, + 0xe0cc: 0xe0cc, + 0xe0cd: 0xe0cd, + 0xe0ce: 0xe0ce, + 0xe0cf: 0xe0cf, + 0xe0d0: 0xe0d0, + 0xe0d1: 0xe0d1, + 0xe0d2: 0xe0d2, + 0xe0d4: 0xe0d4, + 0xe0d6: 0xe0d6, + 0xe0d7: 0xe0d7, + 0x2630: 0x2630, + }, + "Pomicons": { + 0xe000: 0xe000, + 0xe001: 0xe001, + 0xe002: 0xe002, + 0xe003: 0xe003, + 0xe004: 0xe004, + 0xe005: 0xe005, + 0xe006: 0xe006, + 0xe007: 0xe007, + 0xe008: 0xe008, + 0xe009: 0xe009, + 0xe00a: 0xe00a, + }, + "Font Awesome": { + 0xed00: 0xed00, + 0xed01: 0xed01, + 0xed02: 0xed02, + 0xed03: 0xed03, + 0xed04: 0xed04, + 0xed05: 0xed05, + 0xed06: 0xed06, + 0xed07: 0xed07, + 0xed08: 0xed08, + 0xed09: 0xed09, + 0xed0a: 0xed0a, + 0xed0b: 0xed0b, + 0xed0c: 0xed0c, + 0xed0d: 0xed0d, + 0xed0e: 0xed0e, + 0xed0f: 0xed0f, + 0xed10: 0xed10, + 0xed11: 0xed11, + 0xed12: 0xed12, + 0xed13: 0xed13, + 0xed14: 0xed14, + 0xed15: 0xed15, + 0xed16: 0xed16, + 0xed17: 0xed17, + 0xed18: 0xed18, + 0xed19: 0xed19, + 0xed1a: 0xed1a, + 0xed1b: 0xed1b, + 0xed1c: 0xed1c, + 0xed1d: 0xed1d, + 0xed1e: 0xed1e, + 0xed1f: 0xed1f, + 0xed20: 0xed20, + 0xed21: 0xed21, + 0xed22: 0xed22, + 0xed23: 0xed23, + 0xed24: 0xed24, + 0xed25: 0xed25, + 0xed26: 0xed26, + 0xed27: 0xed27, + 0xed28: 0xed28, + 0xed29: 0xed29, + 0xed2a: 0xed2a, + 0xed2b: 0xed2b, + 0xed2c: 0xed2c, + 0xed2d: 0xed2d, + 0xed2e: 0xed2e, + 0xed2f: 0xed2f, + 0xed30: 0xed30, + 0xed31: 0xed31, + 0xed32: 0xed32, + 0xed33: 0xed33, + 0xed34: 0xed34, + 0xed35: 0xed35, + 0xed36: 0xed36, + 0xed37: 0xed37, + 0xed38: 0xed38, + 0xed39: 0xed39, + 0xed3a: 0xed3a, + 0xed3b: 0xed3b, + 0xed3c: 0xed3c, + 0xed3d: 0xed3d, + 0xed3e: 0xed3e, + 0xed3f: 0xed3f, + 0xed40: 0xed40, + 0xed41: 0xed41, + 0xed42: 0xed42, + 0xed43: 0xed43, + 0xed44: 0xed44, + 0xed45: 0xed45, + 0xed46: 0xed46, + 0xed47: 0xed47, + 0xed48: 0xed48, + 0xed49: 0xed49, + 0xed4a: 0xed4a, + 0xed4b: 0xed4b, + 0xed4c: 0xed4c, + 0xed4d: 0xed4d, + 0xed4e: 0xed4e, + 0xed4f: 0xed4f, + 0xed50: 0xed50, + 0xed51: 0xed51, + 0xed52: 0xed52, + 0xed53: 0xed53, + 0xed54: 0xed54, + 0xed55: 0xed55, + 0xed56: 0xed56, + 0xed57: 0xed57, + 0xed58: 0xed58, + 0xed59: 0xed59, + 0xed5a: 0xed5a, + 0xed5b: 0xed5b, + 0xed5c: 0xed5c, + 0xed5d: 0xed5d, + 0xed5e: 0xed5e, + 0xed5f: 0xed5f, + 0xed60: 0xed60, + 0xed61: 0xed61, + 0xed62: 0xed62, + 0xed63: 0xed63, + 0xed64: 0xed64, + 0xed65: 0xed65, + 0xed66: 0xed66, + 0xed67: 0xed67, + 0xed68: 0xed68, + 0xed69: 0xed69, + 0xed6a: 0xed6a, + 0xed6b: 0xed6b, + 0xed6c: 0xed6c, + 0xed6d: 0xed6d, + 0xed6e: 0xed6e, + 0xed6f: 0xed6f, + 0xed70: 0xed70, + 0xed71: 0xed71, + 0xed72: 0xed72, + 0xed73: 0xed73, + 0xed74: 0xed74, + 0xed75: 0xed75, + 0xed76: 0xed76, + 0xed77: 0xed77, + 0xed78: 0xed78, + 0xed79: 0xed79, + 0xed7a: 0xed7a, + 0xed7b: 0xed7b, + 0xed7c: 0xed7c, + 0xed7d: 0xed7d, + 0xed7e: 0xed7e, + 0xed7f: 0xed7f, + 0xed80: 0xed80, + 0xed81: 0xed81, + 0xed82: 0xed82, + 0xed83: 0xed83, + 0xed84: 0xed84, + 0xed85: 0xed85, + 0xed86: 0xed86, + 0xed87: 0xed87, + 0xed88: 0xed88, + 0xed89: 0xed89, + 0xed8a: 0xed8a, + 0xed8b: 0xed8b, + 0xed8c: 0xed8c, + 0xed8d: 0xed8d, + 0xed8e: 0xed8e, + 0xed8f: 0xed8f, + 0xed90: 0xed90, + 0xed91: 0xed91, + 0xed92: 0xed92, + 0xed93: 0xed93, + 0xed94: 0xed94, + 0xed95: 0xed95, + 0xed96: 0xed96, + 0xed97: 0xed97, + 0xed98: 0xed98, + 0xed99: 0xed99, + 0xed9a: 0xed9a, + 0xed9b: 0xed9b, + 0xed9c: 0xed9c, + 0xed9d: 0xed9d, + 0xed9e: 0xed9e, + 0xed9f: 0xed9f, + 0xeda0: 0xeda0, + 0xeda1: 0xeda1, + 0xeda2: 0xeda2, + 0xeda3: 0xeda3, + 0xeda4: 0xeda4, + 0xeda5: 0xeda5, + 0xeda6: 0xeda6, + 0xeda7: 0xeda7, + 0xeda8: 0xeda8, + 0xeda9: 0xeda9, + 0xedaa: 0xedaa, + 0xedab: 0xedab, + 0xedac: 0xedac, + 0xedad: 0xedad, + 0xedae: 0xedae, + 0xedaf: 0xedaf, + 0xedb0: 0xedb0, + 0xedb1: 0xedb1, + 0xedb2: 0xedb2, + 0xedb3: 0xedb3, + 0xedb4: 0xedb4, + 0xedb5: 0xedb5, + 0xedb6: 0xedb6, + 0xedb7: 0xedb7, + 0xedb8: 0xedb8, + 0xedb9: 0xedb9, + 0xedba: 0xedba, + 0xedbb: 0xedbb, + 0xedbc: 0xedbc, + 0xedbd: 0xedbd, + 0xedbe: 0xedbe, + 0xedbf: 0xedbf, + 0xedc0: 0xedc0, + 0xedc1: 0xedc1, + 0xedc2: 0xedc2, + 0xedc3: 0xedc3, + 0xedc4: 0xedc4, + 0xedc5: 0xedc5, + 0xedc6: 0xedc6, + 0xedc7: 0xedc7, + 0xedc8: 0xedc8, + 0xedc9: 0xedc9, + 0xedca: 0xedca, + 0xedcb: 0xedcb, + 0xedcc: 0xedcc, + 0xedcd: 0xedcd, + 0xedce: 0xedce, + 0xedcf: 0xedcf, + 0xedd0: 0xedd0, + 0xedd1: 0xedd1, + 0xedd2: 0xedd2, + 0xedd3: 0xedd3, + 0xedd4: 0xedd4, + 0xedd5: 0xedd5, + 0xedd6: 0xedd6, + 0xedd7: 0xedd7, + 0xedd8: 0xedd8, + 0xedd9: 0xedd9, + 0xedda: 0xedda, + 0xeddb: 0xeddb, + 0xeddc: 0xeddc, + 0xeddd: 0xeddd, + 0xedde: 0xedde, + 0xeddf: 0xeddf, + 0xede0: 0xede0, + 0xede1: 0xede1, + 0xede2: 0xede2, + 0xede3: 0xede3, + 0xede4: 0xede4, + 0xede5: 0xede5, + 0xede6: 0xede6, + 0xede7: 0xede7, + 0xede8: 0xede8, + 0xede9: 0xede9, + 0xedea: 0xedea, + 0xedeb: 0xedeb, + 0xedec: 0xedec, + 0xeded: 0xeded, + 0xedee: 0xedee, + 0xedef: 0xedef, + 0xedf0: 0xedf0, + 0xedf1: 0xedf1, + 0xedf2: 0xedf2, + 0xedf3: 0xedf3, + 0xedf4: 0xedf4, + 0xedf5: 0xedf5, + 0xedf6: 0xedf6, + 0xedf7: 0xedf7, + 0xedf8: 0xedf8, + 0xedf9: 0xedf9, + 0xedfa: 0xedfa, + 0xedfb: 0xedfb, + 0xedfc: 0xedfc, + 0xedfd: 0xedfd, + 0xedfe: 0xedfe, + 0xedff: 0xedff, + 0xee0c: 0xee0c, + 0xee0d: 0xee0d, + 0xee0e: 0xee0e, + 0xee0f: 0xee0f, + 0xee10: 0xee10, + 0xee11: 0xee11, + 0xee12: 0xee12, + 0xee13: 0xee13, + 0xee14: 0xee14, + 0xee15: 0xee15, + 0xee16: 0xee16, + 0xee17: 0xee17, + 0xee18: 0xee18, + 0xee19: 0xee19, + 0xee1a: 0xee1a, + 0xee1b: 0xee1b, + 0xee1c: 0xee1c, + 0xee1d: 0xee1d, + 0xee1e: 0xee1e, + 0xee1f: 0xee1f, + 0xee20: 0xee20, + 0xee21: 0xee21, + 0xee22: 0xee22, + 0xee23: 0xee23, + 0xee24: 0xee24, + 0xee25: 0xee25, + 0xee26: 0xee26, + 0xee27: 0xee27, + 0xee28: 0xee28, + 0xee29: 0xee29, + 0xee2a: 0xee2a, + 0xee2b: 0xee2b, + 0xee2c: 0xee2c, + 0xee2d: 0xee2d, + 0xee2e: 0xee2e, + 0xee2f: 0xee2f, + 0xee30: 0xee30, + 0xee31: 0xee31, + 0xee32: 0xee32, + 0xee33: 0xee33, + 0xee34: 0xee34, + 0xee35: 0xee35, + 0xee36: 0xee36, + 0xee37: 0xee37, + 0xee38: 0xee38, + 0xee39: 0xee39, + 0xee3a: 0xee3a, + 0xee3b: 0xee3b, + 0xee3c: 0xee3c, + 0xee3d: 0xee3d, + 0xee3e: 0xee3e, + 0xee3f: 0xee3f, + 0xee40: 0xee40, + 0xee41: 0xee41, + 0xee42: 0xee42, + 0xee43: 0xee43, + 0xee44: 0xee44, + 0xee45: 0xee45, + 0xee46: 0xee46, + 0xee47: 0xee47, + 0xee48: 0xee48, + 0xee49: 0xee49, + 0xee4a: 0xee4a, + 0xee4b: 0xee4b, + 0xee4c: 0xee4c, + 0xee4d: 0xee4d, + 0xee4e: 0xee4e, + 0xee4f: 0xee4f, + 0xee50: 0xee50, + 0xee51: 0xee51, + 0xee52: 0xee52, + 0xee53: 0xee53, + 0xee54: 0xee54, + 0xee55: 0xee55, + 0xee56: 0xee56, + 0xee57: 0xee57, + 0xee58: 0xee58, + 0xee59: 0xee59, + 0xee5a: 0xee5a, + 0xee5b: 0xee5b, + 0xee5c: 0xee5c, + 0xee5d: 0xee5d, + 0xee5e: 0xee5e, + 0xee5f: 0xee5f, + 0xee60: 0xee60, + 0xee61: 0xee61, + 0xee62: 0xee62, + 0xee63: 0xee63, + 0xee64: 0xee64, + 0xee65: 0xee65, + 0xee66: 0xee66, + 0xee67: 0xee67, + 0xee68: 0xee68, + 0xee69: 0xee69, + 0xee6a: 0xee6a, + 0xee6b: 0xee6b, + 0xee6c: 0xee6c, + 0xee6d: 0xee6d, + 0xee6e: 0xee6e, + 0xee6f: 0xee6f, + 0xee70: 0xee70, + 0xee71: 0xee71, + 0xee72: 0xee72, + 0xee73: 0xee73, + 0xee74: 0xee74, + 0xee75: 0xee75, + 0xee76: 0xee76, + 0xee77: 0xee77, + 0xee78: 0xee78, + 0xee79: 0xee79, + 0xee7a: 0xee7a, + 0xee7b: 0xee7b, + 0xee7c: 0xee7c, + 0xee7d: 0xee7d, + 0xee7e: 0xee7e, + 0xee7f: 0xee7f, + 0xee80: 0xee80, + 0xee81: 0xee81, + 0xee82: 0xee82, + 0xee83: 0xee83, + 0xee84: 0xee84, + 0xee85: 0xee85, + 0xee86: 0xee86, + 0xee87: 0xee87, + 0xee88: 0xee88, + 0xee89: 0xee89, + 0xee8a: 0xee8a, + 0xee8b: 0xee8b, + 0xee8c: 0xee8c, + 0xee8d: 0xee8d, + 0xee8e: 0xee8e, + 0xee8f: 0xee8f, + 0xee90: 0xee90, + 0xee91: 0xee91, + 0xee92: 0xee92, + 0xee93: 0xee93, + 0xee94: 0xee94, + 0xee95: 0xee95, + 0xee96: 0xee96, + 0xee97: 0xee97, + 0xee98: 0xee98, + 0xee99: 0xee99, + 0xee9a: 0xee9a, + 0xee9b: 0xee9b, + 0xee9c: 0xee9c, + 0xee9d: 0xee9d, + 0xee9e: 0xee9e, + 0xee9f: 0xee9f, + 0xeea0: 0xeea0, + 0xeea1: 0xeea1, + 0xeea2: 0xeea2, + 0xeea3: 0xeea3, + 0xeea4: 0xeea4, + 0xeea5: 0xeea5, + 0xeea6: 0xeea6, + 0xeea7: 0xeea7, + 0xeea8: 0xeea8, + 0xeea9: 0xeea9, + 0xeeaa: 0xeeaa, + 0xeeab: 0xeeab, + 0xeeac: 0xeeac, + 0xeead: 0xeead, + 0xeeae: 0xeeae, + 0xeeaf: 0xeeaf, + 0xeeb0: 0xeeb0, + 0xeeb1: 0xeeb1, + 0xeeb2: 0xeeb2, + 0xeeb3: 0xeeb3, + 0xeeb4: 0xeeb4, + 0xeeb5: 0xeeb5, + 0xeeb6: 0xeeb6, + 0xeeb7: 0xeeb7, + 0xeeb8: 0xeeb8, + 0xeeb9: 0xeeb9, + 0xeeba: 0xeeba, + 0xeebb: 0xeebb, + 0xeebc: 0xeebc, + 0xeebd: 0xeebd, + 0xeebe: 0xeebe, + 0xeebf: 0xeebf, + 0xeec0: 0xeec0, + 0xeec1: 0xeec1, + 0xeec2: 0xeec2, + 0xeec3: 0xeec3, + 0xeec4: 0xeec4, + 0xeec5: 0xeec5, + 0xeec6: 0xeec6, + 0xeec7: 0xeec7, + 0xeec8: 0xeec8, + 0xeec9: 0xeec9, + 0xeeca: 0xeeca, + 0xeecb: 0xeecb, + 0xeecc: 0xeecc, + 0xeecd: 0xeecd, + 0xeece: 0xeece, + 0xeecf: 0xeecf, + 0xeed0: 0xeed0, + 0xeed1: 0xeed1, + 0xeed2: 0xeed2, + 0xeed3: 0xeed3, + 0xeed4: 0xeed4, + 0xeed5: 0xeed5, + 0xeed6: 0xeed6, + 0xeed7: 0xeed7, + 0xeed8: 0xeed8, + 0xeed9: 0xeed9, + 0xeeda: 0xeeda, + 0xeedb: 0xeedb, + 0xeedc: 0xeedc, + 0xeedd: 0xeedd, + 0xeede: 0xeede, + 0xeedf: 0xeedf, + 0xeee0: 0xeee0, + 0xeee1: 0xeee1, + 0xeee2: 0xeee2, + 0xeee3: 0xeee3, + 0xeee4: 0xeee4, + 0xeee5: 0xeee5, + 0xeee6: 0xeee6, + 0xeee7: 0xeee7, + 0xeee8: 0xeee8, + 0xeee9: 0xeee9, + 0xeeea: 0xeeea, + 0xeeeb: 0xeeeb, + 0xeeec: 0xeeec, + 0xeeed: 0xeeed, + 0xeeee: 0xeeee, + 0xeeef: 0xeeef, + 0xeef0: 0xeef0, + 0xeef1: 0xeef1, + 0xeef2: 0xeef2, + 0xeef3: 0xeef3, + 0xeef4: 0xeef4, + 0xeef5: 0xeef5, + 0xeef6: 0xeef6, + 0xeef7: 0xeef7, + 0xeef8: 0xeef8, + 0xeef9: 0xeef9, + 0xeefa: 0xeefa, + 0xeefb: 0xeefb, + 0xeefc: 0xeefc, + 0xeefd: 0xeefd, + 0xeefe: 0xeefe, + 0xeeff: 0xeeff, + 0xef00: 0xef00, + 0xef01: 0xef01, + 0xef02: 0xef02, + 0xef03: 0xef03, + 0xef04: 0xef04, + 0xef05: 0xef05, + 0xef06: 0xef06, + 0xef07: 0xef07, + 0xef08: 0xef08, + 0xef09: 0xef09, + 0xef0a: 0xef0a, + 0xef0b: 0xef0b, + 0xef0c: 0xef0c, + 0xef0d: 0xef0d, + 0xef0e: 0xef0e, + 0xef0f: 0xef0f, + 0xef10: 0xef10, + 0xef11: 0xef11, + 0xef12: 0xef12, + 0xef13: 0xef13, + 0xef14: 0xef14, + 0xef15: 0xef15, + 0xef16: 0xef16, + 0xef17: 0xef17, + 0xef18: 0xef18, + 0xef19: 0xef19, + 0xef1a: 0xef1a, + 0xef1b: 0xef1b, + 0xef1c: 0xef1c, + 0xef1d: 0xef1d, + 0xef1e: 0xef1e, + 0xef1f: 0xef1f, + 0xef20: 0xef20, + 0xef21: 0xef21, + 0xef22: 0xef22, + 0xef23: 0xef23, + 0xef24: 0xef24, + 0xef25: 0xef25, + 0xef26: 0xef26, + 0xef27: 0xef27, + 0xef28: 0xef28, + 0xef29: 0xef29, + 0xef2a: 0xef2a, + 0xef2b: 0xef2b, + 0xef2c: 0xef2c, + 0xef2d: 0xef2d, + 0xef2e: 0xef2e, + 0xef2f: 0xef2f, + 0xef30: 0xef30, + 0xef31: 0xef31, + 0xef32: 0xef32, + 0xef33: 0xef33, + 0xef34: 0xef34, + 0xef35: 0xef35, + 0xef36: 0xef36, + 0xef37: 0xef37, + 0xef38: 0xef38, + 0xef39: 0xef39, + 0xef3a: 0xef3a, + 0xef3b: 0xef3b, + 0xef3c: 0xef3c, + 0xef3d: 0xef3d, + 0xef3e: 0xef3e, + 0xef3f: 0xef3f, + 0xef40: 0xef40, + 0xef41: 0xef41, + 0xef42: 0xef42, + 0xef43: 0xef43, + 0xef44: 0xef44, + 0xef45: 0xef45, + 0xef46: 0xef46, + 0xef47: 0xef47, + 0xef48: 0xef48, + 0xef49: 0xef49, + 0xef4a: 0xef4a, + 0xef4b: 0xef4b, + 0xef4c: 0xef4c, + 0xef4d: 0xef4d, + 0xef4e: 0xef4e, + 0xef4f: 0xef4f, + 0xef50: 0xef50, + 0xef51: 0xef51, + 0xef52: 0xef52, + 0xef53: 0xef53, + 0xef54: 0xef54, + 0xef55: 0xef55, + 0xef56: 0xef56, + 0xef57: 0xef57, + 0xef58: 0xef58, + 0xef59: 0xef59, + 0xef5a: 0xef5a, + 0xef5b: 0xef5b, + 0xef5c: 0xef5c, + 0xef5d: 0xef5d, + 0xef5e: 0xef5e, + 0xef5f: 0xef5f, + 0xef60: 0xef60, + 0xef61: 0xef61, + 0xef62: 0xef62, + 0xef63: 0xef63, + 0xef64: 0xef64, + 0xef65: 0xef65, + 0xef66: 0xef66, + 0xef67: 0xef67, + 0xef68: 0xef68, + 0xef69: 0xef69, + 0xef6a: 0xef6a, + 0xef6b: 0xef6b, + 0xef6c: 0xef6c, + 0xef6d: 0xef6d, + 0xef6e: 0xef6e, + 0xef6f: 0xef6f, + 0xef70: 0xef70, + 0xef71: 0xef71, + 0xef72: 0xef72, + 0xef73: 0xef73, + 0xef74: 0xef74, + 0xef75: 0xef75, + 0xef76: 0xef76, + 0xef77: 0xef77, + 0xef78: 0xef78, + 0xef79: 0xef79, + 0xef7a: 0xef7a, + 0xef7b: 0xef7b, + 0xef7c: 0xef7c, + 0xef7d: 0xef7d, + 0xef7e: 0xef7e, + 0xef7f: 0xef7f, + 0xef80: 0xef80, + 0xef81: 0xef81, + 0xef82: 0xef82, + 0xef83: 0xef83, + 0xef84: 0xef84, + 0xef85: 0xef85, + 0xef86: 0xef86, + 0xef87: 0xef87, + 0xef88: 0xef88, + 0xef89: 0xef89, + 0xef8a: 0xef8a, + 0xef8b: 0xef8b, + 0xef8c: 0xef8c, + 0xef8d: 0xef8d, + 0xef8e: 0xef8e, + 0xef8f: 0xef8f, + 0xef90: 0xef90, + 0xef91: 0xef91, + 0xef92: 0xef92, + 0xef93: 0xef93, + 0xef94: 0xef94, + 0xef95: 0xef95, + 0xef96: 0xef96, + 0xef97: 0xef97, + 0xef98: 0xef98, + 0xef99: 0xef99, + 0xef9a: 0xef9a, + 0xef9b: 0xef9b, + 0xef9c: 0xef9c, + 0xef9d: 0xef9d, + 0xef9e: 0xef9e, + 0xef9f: 0xef9f, + 0xefa0: 0xefa0, + 0xefa1: 0xefa1, + 0xefa2: 0xefa2, + 0xefa3: 0xefa3, + 0xefa4: 0xefa4, + 0xefa5: 0xefa5, + 0xefa6: 0xefa6, + 0xefa7: 0xefa7, + 0xefa8: 0xefa8, + 0xefa9: 0xefa9, + 0xefaa: 0xefaa, + 0xefab: 0xefab, + 0xefac: 0xefac, + 0xefad: 0xefad, + 0xefae: 0xefae, + 0xefaf: 0xefaf, + 0xefb0: 0xefb0, + 0xefb1: 0xefb1, + 0xefb2: 0xefb2, + 0xefb3: 0xefb3, + 0xefb4: 0xefb4, + 0xefb5: 0xefb5, + 0xefb6: 0xefb6, + 0xefb7: 0xefb7, + 0xefb8: 0xefb8, + 0xefb9: 0xefb9, + 0xefba: 0xefba, + 0xefbb: 0xefbb, + 0xefbc: 0xefbc, + 0xefbd: 0xefbd, + 0xefbe: 0xefbe, + 0xefbf: 0xefbf, + 0xefc0: 0xefc0, + 0xefc1: 0xefc1, + 0xefc2: 0xefc2, + 0xefc3: 0xefc3, + 0xefc4: 0xefc4, + 0xefc5: 0xefc5, + 0xefc6: 0xefc6, + 0xefc7: 0xefc7, + 0xefc8: 0xefc8, + 0xefc9: 0xefc9, + 0xefca: 0xefca, + 0xefcb: 0xefcb, + 0xefcc: 0xefcc, + 0xefcd: 0xefcd, + 0xefce: 0xefce, + 0xf000: 0xf000, + 0xf001: 0xf001, + 0xf002: 0xf002, + 0xf003: 0xf003, + 0xf004: 0xf004, + 0xf005: 0xf005, + 0xf006: 0xf006, + 0xf007: 0xf007, + 0xf008: 0xf008, + 0xf009: 0xf009, + 0xf00a: 0xf00a, + 0xf00b: 0xf00b, + 0xf00c: 0xf00c, + 0xf00d: 0xf00d, + 0xf00e: 0xf00e, + 0xf00f: 0xf00f, + 0xf010: 0xf010, + 0xf011: 0xf011, + 0xf012: 0xf012, + 0xf013: 0xf013, + 0xf014: 0xf014, + 0xf015: 0xf015, + 0xf016: 0xf016, + 0xf017: 0xf017, + 0xf018: 0xf018, + 0xf019: 0xf019, + 0xf01a: 0xf01a, + 0xf01b: 0xf01b, + 0xf01c: 0xf01c, + 0xf01d: 0xf01d, + 0xf01e: 0xf01e, + 0xf01f: 0xf01f, + 0xf020: 0xf020, + 0xf021: 0xf021, + 0xf022: 0xf022, + 0xf023: 0xf023, + 0xf024: 0xf024, + 0xf025: 0xf025, + 0xf026: 0xf026, + 0xf027: 0xf027, + 0xf028: 0xf028, + 0xf029: 0xf029, + 0xf02a: 0xf02a, + 0xf02b: 0xf02b, + 0xf02c: 0xf02c, + 0xf02d: 0xf02d, + 0xf02e: 0xf02e, + 0xf02f: 0xf02f, + 0xf030: 0xf030, + 0xf031: 0xf031, + 0xf032: 0xf032, + 0xf033: 0xf033, + 0xf034: 0xf034, + 0xf035: 0xf035, + 0xf036: 0xf036, + 0xf037: 0xf037, + 0xf038: 0xf038, + 0xf039: 0xf039, + 0xf03a: 0xf03a, + 0xf03b: 0xf03b, + 0xf03c: 0xf03c, + 0xf03d: 0xf03d, + 0xf03e: 0xf03e, + 0xf03f: 0xf03f, + 0xf040: 0xf040, + 0xf041: 0xf041, + 0xf042: 0xf042, + 0xf043: 0xf043, + 0xf044: 0xf044, + 0xf045: 0xf045, + 0xf046: 0xf046, + 0xf047: 0xf047, + 0xf048: 0xf048, + 0xf049: 0xf049, + 0xf04a: 0xf04a, + 0xf04b: 0xf04b, + 0xf04c: 0xf04c, + 0xf04d: 0xf04d, + 0xf04e: 0xf04e, + 0xf04f: 0xf04f, + 0xf050: 0xf050, + 0xf051: 0xf051, + 0xf052: 0xf052, + 0xf053: 0xf053, + 0xf054: 0xf054, + 0xf055: 0xf055, + 0xf056: 0xf056, + 0xf057: 0xf057, + 0xf058: 0xf058, + 0xf059: 0xf059, + 0xf05a: 0xf05a, + 0xf05b: 0xf05b, + 0xf05c: 0xf05c, + 0xf05d: 0xf05d, + 0xf05e: 0xf05e, + 0xf05f: 0xf05f, + 0xf060: 0xf060, + 0xf061: 0xf061, + 0xf062: 0xf062, + 0xf063: 0xf063, + 0xf064: 0xf064, + 0xf065: 0xf065, + 0xf066: 0xf066, + 0xf067: 0xf067, + 0xf068: 0xf068, + 0xf069: 0xf069, + 0xf06a: 0xf06a, + 0xf06b: 0xf06b, + 0xf06c: 0xf06c, + 0xf06d: 0xf06d, + 0xf06e: 0xf06e, + 0xf06f: 0xf06f, + 0xf070: 0xf070, + 0xf071: 0xf071, + 0xf072: 0xf072, + 0xf073: 0xf073, + 0xf074: 0xf074, + 0xf075: 0xf075, + 0xf076: 0xf076, + 0xf077: 0xf077, + 0xf078: 0xf078, + 0xf079: 0xf079, + 0xf07a: 0xf07a, + 0xf07b: 0xf07b, + 0xf07c: 0xf07c, + 0xf07d: 0xf07d, + 0xf07e: 0xf07e, + 0xf07f: 0xf07f, + 0xf080: 0xf080, + 0xf081: 0xf081, + 0xf082: 0xf082, + 0xf083: 0xf083, + 0xf084: 0xf084, + 0xf085: 0xf085, + 0xf086: 0xf086, + 0xf087: 0xf087, + 0xf088: 0xf088, + 0xf089: 0xf089, + 0xf08a: 0xf08a, + 0xf08b: 0xf08b, + 0xf08c: 0xf08c, + 0xf08d: 0xf08d, + 0xf08e: 0xf08e, + 0xf08f: 0xf08f, + 0xf090: 0xf090, + 0xf091: 0xf091, + 0xf092: 0xf092, + 0xf093: 0xf093, + 0xf094: 0xf094, + 0xf095: 0xf095, + 0xf096: 0xf096, + 0xf097: 0xf097, + 0xf098: 0xf098, + 0xf099: 0xf099, + 0xf09a: 0xf09a, + 0xf09b: 0xf09b, + 0xf09c: 0xf09c, + 0xf09d: 0xf09d, + 0xf09e: 0xf09e, + 0xf09f: 0xf09f, + 0xf0a0: 0xf0a0, + 0xf0a1: 0xf0a1, + 0xf0a2: 0xf0a2, + 0xf0a3: 0xf0a3, + 0xf0a4: 0xf0a4, + 0xf0a5: 0xf0a5, + 0xf0a6: 0xf0a6, + 0xf0a7: 0xf0a7, + 0xf0a8: 0xf0a8, + 0xf0a9: 0xf0a9, + 0xf0aa: 0xf0aa, + 0xf0ab: 0xf0ab, + 0xf0ac: 0xf0ac, + 0xf0ad: 0xf0ad, + 0xf0ae: 0xf0ae, + 0xf0af: 0xf0af, + 0xf0b0: 0xf0b0, + 0xf0b1: 0xf0b1, + 0xf0b2: 0xf0b2, + 0xf0b3: 0xf0b3, + 0xf0b4: 0xf0b4, + 0xf0b5: 0xf0b5, + 0xf0b6: 0xf0b6, + 0xf0b7: 0xf0b7, + 0xf0b8: 0xf0b8, + 0xf0b9: 0xf0b9, + 0xf0ba: 0xf0ba, + 0xf0bb: 0xf0bb, + 0xf0bc: 0xf0bc, + 0xf0bd: 0xf0bd, + 0xf0be: 0xf0be, + 0xf0bf: 0xf0bf, + 0xf0c0: 0xf0c0, + 0xf0c1: 0xf0c1, + 0xf0c2: 0xf0c2, + 0xf0c3: 0xf0c3, + 0xf0c4: 0xf0c4, + 0xf0c5: 0xf0c5, + 0xf0c6: 0xf0c6, + 0xf0c7: 0xf0c7, + 0xf0c8: 0xf0c8, + 0xf0c9: 0xf0c9, + 0xf0ca: 0xf0ca, + 0xf0cb: 0xf0cb, + 0xf0cc: 0xf0cc, + 0xf0cd: 0xf0cd, + 0xf0ce: 0xf0ce, + 0xf0cf: 0xf0cf, + 0xf0d0: 0xf0d0, + 0xf0d1: 0xf0d1, + 0xf0d2: 0xf0d2, + 0xf0d3: 0xf0d3, + 0xf0d4: 0xf0d4, + 0xf0d5: 0xf0d5, + 0xf0d6: 0xf0d6, + 0xf0d7: 0xf0d7, + 0xf0d8: 0xf0d8, + 0xf0d9: 0xf0d9, + 0xf0da: 0xf0da, + 0xf0db: 0xf0db, + 0xf0dc: 0xf0dc, + 0xf0dd: 0xf0dd, + 0xf0de: 0xf0de, + 0xf0df: 0xf0df, + 0xf0e0: 0xf0e0, + 0xf0e1: 0xf0e1, + 0xf0e2: 0xf0e2, + 0xf0e3: 0xf0e3, + 0xf0e4: 0xf0e4, + 0xf0e5: 0xf0e5, + 0xf0e6: 0xf0e6, + 0xf0e7: 0xf0e7, + 0xf0e8: 0xf0e8, + 0xf0e9: 0xf0e9, + 0xf0ea: 0xf0ea, + 0xf0eb: 0xf0eb, + 0xf0ec: 0xf0ec, + 0xf0ed: 0xf0ed, + 0xf0ee: 0xf0ee, + 0xf0ef: 0xf0ef, + 0xf0f0: 0xf0f0, + 0xf0f1: 0xf0f1, + 0xf0f2: 0xf0f2, + 0xf0f3: 0xf0f3, + 0xf0f4: 0xf0f4, + 0xf0f5: 0xf0f5, + 0xf0f6: 0xf0f6, + 0xf0f7: 0xf0f7, + 0xf0f8: 0xf0f8, + 0xf0f9: 0xf0f9, + 0xf0fa: 0xf0fa, + 0xf0fb: 0xf0fb, + 0xf0fc: 0xf0fc, + 0xf0fd: 0xf0fd, + 0xf0fe: 0xf0fe, + 0xf0ff: 0xf0ff, + 0xf100: 0xf100, + 0xf101: 0xf101, + 0xf102: 0xf102, + 0xf103: 0xf103, + 0xf104: 0xf104, + 0xf105: 0xf105, + 0xf106: 0xf106, + 0xf107: 0xf107, + 0xf108: 0xf108, + 0xf109: 0xf109, + 0xf10a: 0xf10a, + 0xf10b: 0xf10b, + 0xf10c: 0xf10c, + 0xf10d: 0xf10d, + 0xf10e: 0xf10e, + 0xf10f: 0xf10f, + 0xf110: 0xf110, + 0xf111: 0xf111, + 0xf112: 0xf112, + 0xf113: 0xf113, + 0xf114: 0xf114, + 0xf115: 0xf115, + 0xf116: 0xf116, + 0xf117: 0xf117, + 0xf118: 0xf118, + 0xf119: 0xf119, + 0xf11a: 0xf11a, + 0xf11b: 0xf11b, + 0xf11c: 0xf11c, + 0xf11d: 0xf11d, + 0xf11e: 0xf11e, + 0xf11f: 0xf11f, + 0xf120: 0xf120, + 0xf121: 0xf121, + 0xf122: 0xf122, + 0xf123: 0xf123, + 0xf124: 0xf124, + 0xf125: 0xf125, + 0xf126: 0xf126, + 0xf127: 0xf127, + 0xf128: 0xf128, + 0xf129: 0xf129, + 0xf12a: 0xf12a, + 0xf12b: 0xf12b, + 0xf12c: 0xf12c, + 0xf12d: 0xf12d, + 0xf12e: 0xf12e, + 0xf12f: 0xf12f, + 0xf130: 0xf130, + 0xf131: 0xf131, + 0xf132: 0xf132, + 0xf133: 0xf133, + 0xf134: 0xf134, + 0xf135: 0xf135, + 0xf136: 0xf136, + 0xf137: 0xf137, + 0xf138: 0xf138, + 0xf139: 0xf139, + 0xf13a: 0xf13a, + 0xf13b: 0xf13b, + 0xf13c: 0xf13c, + 0xf13d: 0xf13d, + 0xf13e: 0xf13e, + 0xf13f: 0xf13f, + 0xf140: 0xf140, + 0xf141: 0xf141, + 0xf142: 0xf142, + 0xf143: 0xf143, + 0xf144: 0xf144, + 0xf145: 0xf145, + 0xf146: 0xf146, + 0xf147: 0xf147, + 0xf148: 0xf148, + 0xf149: 0xf149, + 0xf14a: 0xf14a, + 0xf14b: 0xf14b, + 0xf14c: 0xf14c, + 0xf14d: 0xf14d, + 0xf14e: 0xf14e, + 0xf14f: 0xf14f, + 0xf150: 0xf150, + 0xf151: 0xf151, + 0xf152: 0xf152, + 0xf153: 0xf153, + 0xf154: 0xf154, + 0xf155: 0xf155, + 0xf156: 0xf156, + 0xf157: 0xf157, + 0xf158: 0xf158, + 0xf159: 0xf159, + 0xf15a: 0xf15a, + 0xf15b: 0xf15b, + 0xf15c: 0xf15c, + 0xf15d: 0xf15d, + 0xf15e: 0xf15e, + 0xf15f: 0xf15f, + 0xf160: 0xf160, + 0xf161: 0xf161, + 0xf162: 0xf162, + 0xf163: 0xf163, + 0xf164: 0xf164, + 0xf165: 0xf165, + 0xf166: 0xf166, + 0xf167: 0xf167, + 0xf168: 0xf168, + 0xf169: 0xf169, + 0xf16a: 0xf16a, + 0xf16b: 0xf16b, + 0xf16c: 0xf16c, + 0xf16d: 0xf16d, + 0xf16e: 0xf16e, + 0xf16f: 0xf16f, + 0xf170: 0xf170, + 0xf171: 0xf171, + 0xf172: 0xf172, + 0xf173: 0xf173, + 0xf174: 0xf174, + 0xf175: 0xf175, + 0xf176: 0xf176, + 0xf177: 0xf177, + 0xf178: 0xf178, + 0xf179: 0xf179, + 0xf17a: 0xf17a, + 0xf17b: 0xf17b, + 0xf17c: 0xf17c, + 0xf17d: 0xf17d, + 0xf17e: 0xf17e, + 0xf17f: 0xf17f, + 0xf180: 0xf180, + 0xf181: 0xf181, + 0xf182: 0xf182, + 0xf183: 0xf183, + 0xf184: 0xf184, + 0xf185: 0xf185, + 0xf186: 0xf186, + 0xf187: 0xf187, + 0xf188: 0xf188, + 0xf189: 0xf189, + 0xf18a: 0xf18a, + 0xf18b: 0xf18b, + 0xf18c: 0xf18c, + 0xf18d: 0xf18d, + 0xf18e: 0xf18e, + 0xf18f: 0xf18f, + 0xf190: 0xf190, + 0xf191: 0xf191, + 0xf192: 0xf192, + 0xf193: 0xf193, + 0xf194: 0xf194, + 0xf195: 0xf195, + 0xf196: 0xf196, + 0xf197: 0xf197, + 0xf198: 0xf198, + 0xf199: 0xf199, + 0xf19a: 0xf19a, + 0xf19b: 0xf19b, + 0xf19c: 0xf19c, + 0xf19d: 0xf19d, + 0xf19e: 0xf19e, + 0xf19f: 0xf19f, + 0xf1a0: 0xf1a0, + 0xf1a1: 0xf1a1, + 0xf1a2: 0xf1a2, + 0xf1a3: 0xf1a3, + 0xf1a4: 0xf1a4, + 0xf1a5: 0xf1a5, + 0xf1a6: 0xf1a6, + 0xf1a7: 0xf1a7, + 0xf1a8: 0xf1a8, + 0xf1a9: 0xf1a9, + 0xf1aa: 0xf1aa, + 0xf1ab: 0xf1ab, + 0xf1ac: 0xf1ac, + 0xf1ad: 0xf1ad, + 0xf1ae: 0xf1ae, + 0xf1af: 0xf1af, + 0xf1b0: 0xf1b0, + 0xf1b1: 0xf1b1, + 0xf1b2: 0xf1b2, + 0xf1b3: 0xf1b3, + 0xf1b4: 0xf1b4, + 0xf1b5: 0xf1b5, + 0xf1b6: 0xf1b6, + 0xf1b7: 0xf1b7, + 0xf1b8: 0xf1b8, + 0xf1b9: 0xf1b9, + 0xf1ba: 0xf1ba, + 0xf1bb: 0xf1bb, + 0xf1bc: 0xf1bc, + 0xf1bd: 0xf1bd, + 0xf1be: 0xf1be, + 0xf1bf: 0xf1bf, + 0xf1c0: 0xf1c0, + 0xf1c1: 0xf1c1, + 0xf1c2: 0xf1c2, + 0xf1c3: 0xf1c3, + 0xf1c4: 0xf1c4, + 0xf1c5: 0xf1c5, + 0xf1c6: 0xf1c6, + 0xf1c7: 0xf1c7, + 0xf1c8: 0xf1c8, + 0xf1c9: 0xf1c9, + 0xf1ca: 0xf1ca, + 0xf1cb: 0xf1cb, + 0xf1cc: 0xf1cc, + 0xf1cd: 0xf1cd, + 0xf1ce: 0xf1ce, + 0xf1cf: 0xf1cf, + 0xf1d0: 0xf1d0, + 0xf1d1: 0xf1d1, + 0xf1d2: 0xf1d2, + 0xf1d3: 0xf1d3, + 0xf1d4: 0xf1d4, + 0xf1d5: 0xf1d5, + 0xf1d6: 0xf1d6, + 0xf1d7: 0xf1d7, + 0xf1d8: 0xf1d8, + 0xf1d9: 0xf1d9, + 0xf1da: 0xf1da, + 0xf1db: 0xf1db, + 0xf1dc: 0xf1dc, + 0xf1dd: 0xf1dd, + 0xf1de: 0xf1de, + 0xf1df: 0xf1df, + 0xf1e0: 0xf1e0, + 0xf1e1: 0xf1e1, + 0xf1e2: 0xf1e2, + 0xf1e3: 0xf1e3, + 0xf1e4: 0xf1e4, + 0xf1e5: 0xf1e5, + 0xf1e6: 0xf1e6, + 0xf1e7: 0xf1e7, + 0xf1e8: 0xf1e8, + 0xf1e9: 0xf1e9, + 0xf1ea: 0xf1ea, + 0xf1eb: 0xf1eb, + 0xf1ec: 0xf1ec, + 0xf1ed: 0xf1ed, + 0xf1ee: 0xf1ee, + 0xf1ef: 0xf1ef, + 0xf1f0: 0xf1f0, + 0xf1f1: 0xf1f1, + 0xf1f2: 0xf1f2, + 0xf1f3: 0xf1f3, + 0xf1f4: 0xf1f4, + 0xf1f5: 0xf1f5, + 0xf1f6: 0xf1f6, + 0xf1f7: 0xf1f7, + 0xf1f8: 0xf1f8, + 0xf1f9: 0xf1f9, + 0xf1fa: 0xf1fa, + 0xf1fb: 0xf1fb, + 0xf1fc: 0xf1fc, + 0xf1fd: 0xf1fd, + 0xf1fe: 0xf1fe, + 0xf1ff: 0xf1ff, + 0xf200: 0xf200, + 0xf201: 0xf201, + 0xf202: 0xf202, + 0xf203: 0xf203, + 0xf204: 0xf204, + 0xf205: 0xf205, + 0xf206: 0xf206, + 0xf207: 0xf207, + 0xf208: 0xf208, + 0xf209: 0xf209, + 0xf20a: 0xf20a, + 0xf20b: 0xf20b, + 0xf20c: 0xf20c, + 0xf20d: 0xf20d, + 0xf20e: 0xf20e, + 0xf20f: 0xf20f, + 0xf210: 0xf210, + 0xf211: 0xf211, + 0xf212: 0xf212, + 0xf213: 0xf213, + 0xf214: 0xf214, + 0xf215: 0xf215, + 0xf216: 0xf216, + 0xf217: 0xf217, + 0xf218: 0xf218, + 0xf219: 0xf219, + 0xf21a: 0xf21a, + 0xf21b: 0xf21b, + 0xf21c: 0xf21c, + 0xf21d: 0xf21d, + 0xf21e: 0xf21e, + 0xf21f: 0xf21f, + 0xf220: 0xf220, + 0xf221: 0xf221, + 0xf222: 0xf222, + 0xf223: 0xf223, + 0xf224: 0xf224, + 0xf225: 0xf225, + 0xf226: 0xf226, + 0xf227: 0xf227, + 0xf228: 0xf228, + 0xf229: 0xf229, + 0xf22a: 0xf22a, + 0xf22b: 0xf22b, + 0xf22c: 0xf22c, + 0xf22d: 0xf22d, + 0xf22e: 0xf22e, + 0xf22f: 0xf22f, + 0xf230: 0xf230, + 0xf231: 0xf231, + 0xf232: 0xf232, + 0xf233: 0xf233, + 0xf234: 0xf234, + 0xf235: 0xf235, + 0xf236: 0xf236, + 0xf237: 0xf237, + 0xf238: 0xf238, + 0xf239: 0xf239, + 0xf23a: 0xf23a, + 0xf23b: 0xf23b, + 0xf23c: 0xf23c, + 0xf23d: 0xf23d, + 0xf23e: 0xf23e, + 0xf23f: 0xf23f, + 0xf240: 0xf240, + 0xf241: 0xf241, + 0xf242: 0xf242, + 0xf243: 0xf243, + 0xf244: 0xf244, + 0xf245: 0xf245, + 0xf246: 0xf246, + 0xf247: 0xf247, + 0xf248: 0xf248, + 0xf249: 0xf249, + 0xf24a: 0xf24a, + 0xf24b: 0xf24b, + 0xf24c: 0xf24c, + 0xf24d: 0xf24d, + 0xf24e: 0xf24e, + 0xf24f: 0xf24f, + 0xf250: 0xf250, + 0xf251: 0xf251, + 0xf252: 0xf252, + 0xf253: 0xf253, + 0xf254: 0xf254, + 0xf255: 0xf255, + 0xf256: 0xf256, + 0xf257: 0xf257, + 0xf258: 0xf258, + 0xf259: 0xf259, + 0xf25a: 0xf25a, + 0xf25b: 0xf25b, + 0xf25c: 0xf25c, + 0xf25d: 0xf25d, + 0xf25e: 0xf25e, + 0xf25f: 0xf25f, + 0xf260: 0xf260, + 0xf261: 0xf261, + 0xf262: 0xf262, + 0xf263: 0xf263, + 0xf264: 0xf264, + 0xf265: 0xf265, + 0xf266: 0xf266, + 0xf267: 0xf267, + 0xf268: 0xf268, + 0xf269: 0xf269, + 0xf26a: 0xf26a, + 0xf26b: 0xf26b, + 0xf26c: 0xf26c, + 0xf26d: 0xf26d, + 0xf26e: 0xf26e, + 0xf26f: 0xf26f, + 0xf270: 0xf270, + 0xf271: 0xf271, + 0xf272: 0xf272, + 0xf273: 0xf273, + 0xf274: 0xf274, + 0xf275: 0xf275, + 0xf276: 0xf276, + 0xf277: 0xf277, + 0xf278: 0xf278, + 0xf279: 0xf279, + 0xf27a: 0xf27a, + 0xf27b: 0xf27b, + 0xf27c: 0xf27c, + 0xf27d: 0xf27d, + 0xf27e: 0xf27e, + 0xf27f: 0xf27f, + 0xf280: 0xf280, + 0xf281: 0xf281, + 0xf282: 0xf282, + 0xf283: 0xf283, + 0xf284: 0xf284, + 0xf285: 0xf285, + 0xf286: 0xf286, + 0xf287: 0xf287, + 0xf288: 0xf288, + 0xf289: 0xf289, + 0xf28a: 0xf28a, + 0xf28b: 0xf28b, + 0xf28c: 0xf28c, + 0xf28d: 0xf28d, + 0xf28e: 0xf28e, + 0xf28f: 0xf28f, + 0xf290: 0xf290, + 0xf291: 0xf291, + 0xf292: 0xf292, + 0xf293: 0xf293, + 0xf294: 0xf294, + 0xf295: 0xf295, + 0xf296: 0xf296, + 0xf297: 0xf297, + 0xf298: 0xf298, + 0xf299: 0xf299, + 0xf29a: 0xf29a, + 0xf29b: 0xf29b, + 0xf29c: 0xf29c, + 0xf29d: 0xf29d, + 0xf29e: 0xf29e, + 0xf29f: 0xf29f, + 0xf2a0: 0xf2a0, + 0xf2a1: 0xf2a1, + 0xf2a2: 0xf2a2, + 0xf2a3: 0xf2a3, + 0xf2a4: 0xf2a4, + 0xf2a5: 0xf2a5, + 0xf2a6: 0xf2a6, + 0xf2a7: 0xf2a7, + 0xf2a8: 0xf2a8, + 0xf2a9: 0xf2a9, + 0xf2aa: 0xf2aa, + 0xf2ab: 0xf2ab, + 0xf2ac: 0xf2ac, + 0xf2ad: 0xf2ad, + 0xf2ae: 0xf2ae, + 0xf2af: 0xf2af, + 0xf2b0: 0xf2b0, + 0xf2b1: 0xf2b1, + 0xf2b2: 0xf2b2, + 0xf2b3: 0xf2b3, + 0xf2b4: 0xf2b4, + 0xf2b5: 0xf2b5, + 0xf2b6: 0xf2b6, + 0xf2b7: 0xf2b7, + 0xf2b8: 0xf2b8, + 0xf2b9: 0xf2b9, + 0xf2ba: 0xf2ba, + 0xf2bb: 0xf2bb, + 0xf2bc: 0xf2bc, + 0xf2bd: 0xf2bd, + 0xf2be: 0xf2be, + 0xf2bf: 0xf2bf, + 0xf2c0: 0xf2c0, + 0xf2c1: 0xf2c1, + 0xf2c2: 0xf2c2, + 0xf2c3: 0xf2c3, + 0xf2c4: 0xf2c4, + 0xf2c5: 0xf2c5, + 0xf2c6: 0xf2c6, + 0xf2c7: 0xf2c7, + 0xf2c8: 0xf2c8, + 0xf2c9: 0xf2c9, + 0xf2ca: 0xf2ca, + 0xf2cb: 0xf2cb, + 0xf2cc: 0xf2cc, + 0xf2cd: 0xf2cd, + 0xf2ce: 0xf2ce, + 0xf2cf: 0xf2cf, + 0xf2d0: 0xf2d0, + 0xf2d1: 0xf2d1, + 0xf2d2: 0xf2d2, + 0xf2d3: 0xf2d3, + 0xf2d4: 0xf2d4, + 0xf2d5: 0xf2d5, + 0xf2d6: 0xf2d6, + 0xf2d7: 0xf2d7, + 0xf2d8: 0xf2d8, + 0xf2d9: 0xf2d9, + 0xf2da: 0xf2da, + 0xf2db: 0xf2db, + 0xf2dc: 0xf2dc, + 0xf2dd: 0xf2dd, + 0xf2de: 0xf2de, + 0xf2df: 0xf2df, + 0xf2e0: 0xf2e0, + 0xf2e1: 0xf2e1, + 0xf2e2: 0xf2e2, + 0xf2e3: 0xf2e3, + 0xf2e4: 0xf2e4, + 0xf2e5: 0xf2e5, + 0xf2e6: 0xf2e6, + 0xf2e7: 0xf2e7, + 0xf2e8: 0xf2e8, + 0xf2e9: 0xf2e9, + 0xf2ea: 0xf2ea, + 0xf2eb: 0xf2eb, + 0xf2ec: 0xf2ec, + 0xf2ed: 0xf2ed, + 0xf2ee: 0xf2ee, + 0xf2ef: 0xf2ef, + 0xf2f0: 0xf2f0, + 0xf2f1: 0xf2f1, + 0xf2f2: 0xf2f2, + 0xf2f3: 0xf2f3, + 0xf2f4: 0xf2f4, + 0xf2f5: 0xf2f5, + 0xf2f6: 0xf2f6, + 0xf2f7: 0xf2f7, + 0xf2f8: 0xf2f8, + 0xf2f9: 0xf2f9, + 0xf2fa: 0xf2fa, + 0xf2fb: 0xf2fb, + 0xf2fc: 0xf2fc, + 0xf2fd: 0xf2fd, + 0xf2fe: 0xf2fe, + 0xf2ff: 0xf2ff, + }, + "Font Awesome Extension": { + 0xe000: 0xe200, + 0xe001: 0xe201, + 0xe002: 0xe202, + 0xe003: 0xe203, + 0xe004: 0xe204, + 0xe005: 0xe205, + 0xe006: 0xe206, + 0xe007: 0xe207, + 0xe008: 0xe208, + 0xe009: 0xe209, + 0xe00a: 0xe20a, + 0xe00b: 0xe20b, + 0xe00c: 0xe20c, + 0xe00d: 0xe20d, + 0xe00e: 0xe20e, + 0xe00f: 0xe20f, + 0xe010: 0xe210, + 0xe011: 0xe211, + 0xe012: 0xe212, + 0xe013: 0xe213, + 0xe014: 0xe214, + 0xe015: 0xe215, + 0xe016: 0xe216, + 0xe017: 0xe217, + 0xe018: 0xe218, + 0xe019: 0xe219, + 0xe01a: 0xe21a, + 0xe01b: 0xe21b, + 0xe01c: 0xe21c, + 0xe01d: 0xe21d, + 0xe01e: 0xe21e, + 0xe01f: 0xe21f, + 0xe020: 0xe220, + 0xe021: 0xe221, + 0xe022: 0xe222, + 0xe023: 0xe223, + 0xe024: 0xe224, + 0xe025: 0xe225, + 0xe026: 0xe226, + 0xe027: 0xe227, + 0xe028: 0xe228, + 0xe029: 0xe229, + 0xe02a: 0xe22a, + 0xe02b: 0xe22b, + 0xe02c: 0xe22c, + 0xe02d: 0xe22d, + 0xe02e: 0xe22e, + 0xe02f: 0xe22f, + 0xe030: 0xe230, + 0xe031: 0xe231, + 0xe032: 0xe232, + 0xe033: 0xe233, + 0xe034: 0xe234, + 0xe035: 0xe235, + 0xe036: 0xe236, + 0xe037: 0xe237, + 0xe038: 0xe238, + 0xe039: 0xe239, + 0xe03a: 0xe23a, + 0xe03b: 0xe23b, + 0xe03c: 0xe23c, + 0xe03d: 0xe23d, + 0xe03e: 0xe23e, + 0xe03f: 0xe23f, + 0xe040: 0xe240, + 0xe041: 0xe241, + 0xe042: 0xe242, + 0xe043: 0xe243, + 0xe044: 0xe244, + 0xe045: 0xe245, + 0xe046: 0xe246, + 0xe047: 0xe247, + 0xe048: 0xe248, + 0xe049: 0xe249, + 0xe04a: 0xe24a, + 0xe04b: 0xe24b, + 0xe04c: 0xe24c, + 0xe04d: 0xe24d, + 0xe04e: 0xe24e, + 0xe04f: 0xe24f, + 0xe050: 0xe250, + 0xe051: 0xe251, + 0xe052: 0xe252, + 0xe053: 0xe253, + 0xe054: 0xe254, + 0xe055: 0xe255, + 0xe056: 0xe256, + 0xe057: 0xe257, + 0xe058: 0xe258, + 0xe059: 0xe259, + 0xe05a: 0xe25a, + 0xe05b: 0xe25b, + 0xe05c: 0xe25c, + 0xe05d: 0xe25d, + 0xe05e: 0xe25e, + 0xe05f: 0xe25f, + 0xe060: 0xe260, + 0xe061: 0xe261, + 0xe062: 0xe262, + 0xe063: 0xe263, + 0xe064: 0xe264, + 0xe065: 0xe265, + 0xe066: 0xe266, + 0xe067: 0xe267, + 0xe068: 0xe268, + 0xe069: 0xe269, + 0xe06a: 0xe26a, + 0xe06b: 0xe26b, + 0xe06c: 0xe26c, + 0xe06d: 0xe26d, + 0xe06e: 0xe26e, + 0xe06f: 0xe26f, + 0xe070: 0xe270, + 0xe071: 0xe271, + 0xe072: 0xe272, + 0xe073: 0xe273, + 0xe074: 0xe274, + 0xe075: 0xe275, + 0xe076: 0xe276, + 0xe077: 0xe277, + 0xe078: 0xe278, + 0xe079: 0xe279, + 0xe07a: 0xe27a, + 0xe07b: 0xe27b, + 0xe07c: 0xe27c, + 0xe07d: 0xe27d, + 0xe07e: 0xe27e, + 0xe07f: 0xe27f, + 0xe080: 0xe280, + 0xe081: 0xe281, + 0xe082: 0xe282, + 0xe083: 0xe283, + 0xe084: 0xe284, + 0xe085: 0xe285, + 0xe086: 0xe286, + 0xe087: 0xe287, + 0xe088: 0xe288, + 0xe089: 0xe289, + 0xe08a: 0xe28a, + 0xe08b: 0xe28b, + 0xe08c: 0xe28c, + 0xe08d: 0xe28d, + 0xe08e: 0xe28e, + 0xe08f: 0xe28f, + 0xe090: 0xe290, + 0xe091: 0xe291, + 0xe092: 0xe292, + 0xe093: 0xe293, + 0xe094: 0xe294, + 0xe095: 0xe295, + 0xe096: 0xe296, + 0xe097: 0xe297, + 0xe098: 0xe298, + 0xe099: 0xe299, + 0xe09a: 0xe29a, + 0xe09b: 0xe29b, + 0xe09c: 0xe29c, + 0xe09d: 0xe29d, + 0xe09e: 0xe29e, + 0xe09f: 0xe29f, + 0xe0a0: 0xe2a0, + 0xe0a1: 0xe2a1, + 0xe0a2: 0xe2a2, + 0xe0a3: 0xe2a3, + 0xe0a4: 0xe2a4, + 0xe0a5: 0xe2a5, + 0xe0a6: 0xe2a6, + 0xe0a7: 0xe2a7, + 0xe0a8: 0xe2a8, + 0xe0a9: 0xe2a9, + }, + "Power Symbols": { + 0x23fb: 0x23fb, + 0x23fc: 0x23fc, + 0x23fd: 0x23fd, + 0x23fe: 0x23fe, + 0x2b58: 0x2b58, + }, + "Material": { + 0xf0001: 0xf0001, + 0xf0002: 0xf0002, + 0xf0003: 0xf0003, + 0xf0004: 0xf0004, + 0xf0005: 0xf0005, + 0xf0006: 0xf0006, + 0xf0007: 0xf0007, + 0xf0008: 0xf0008, + 0xf0009: 0xf0009, + 0xf000a: 0xf000a, + 0xf000b: 0xf000b, + 0xf000c: 0xf000c, + 0xf000d: 0xf000d, + 0xf000e: 0xf000e, + 0xf000f: 0xf000f, + 0xf0010: 0xf0010, + 0xf0011: 0xf0011, + 0xf0012: 0xf0012, + 0xf0013: 0xf0013, + 0xf0014: 0xf0014, + 0xf0015: 0xf0015, + 0xf0016: 0xf0016, + 0xf0017: 0xf0017, + 0xf0018: 0xf0018, + 0xf0019: 0xf0019, + 0xf001a: 0xf001a, + 0xf001b: 0xf001b, + 0xf001c: 0xf001c, + 0xf001d: 0xf001d, + 0xf001e: 0xf001e, + 0xf001f: 0xf001f, + 0xf0020: 0xf0020, + 0xf0021: 0xf0021, + 0xf0022: 0xf0022, + 0xf0023: 0xf0023, + 0xf0024: 0xf0024, + 0xf0025: 0xf0025, + 0xf0026: 0xf0026, + 0xf0027: 0xf0027, + 0xf0028: 0xf0028, + 0xf0029: 0xf0029, + 0xf002a: 0xf002a, + 0xf002b: 0xf002b, + 0xf002c: 0xf002c, + 0xf002d: 0xf002d, + 0xf002e: 0xf002e, + 0xf002f: 0xf002f, + 0xf0030: 0xf0030, + 0xf0031: 0xf0031, + 0xf0032: 0xf0032, + 0xf0033: 0xf0033, + 0xf0034: 0xf0034, + 0xf0035: 0xf0035, + 0xf0036: 0xf0036, + 0xf0037: 0xf0037, + 0xf0038: 0xf0038, + 0xf0039: 0xf0039, + 0xf003a: 0xf003a, + 0xf003b: 0xf003b, + 0xf003c: 0xf003c, + 0xf003d: 0xf003d, + 0xf003e: 0xf003e, + 0xf003f: 0xf003f, + 0xf0040: 0xf0040, + 0xf0041: 0xf0041, + 0xf0042: 0xf0042, + 0xf0043: 0xf0043, + 0xf0044: 0xf0044, + 0xf0045: 0xf0045, + 0xf0046: 0xf0046, + 0xf0047: 0xf0047, + 0xf0048: 0xf0048, + 0xf0049: 0xf0049, + 0xf004a: 0xf004a, + 0xf004b: 0xf004b, + 0xf004c: 0xf004c, + 0xf004d: 0xf004d, + 0xf004e: 0xf004e, + 0xf004f: 0xf004f, + 0xf0050: 0xf0050, + 0xf0051: 0xf0051, + 0xf0052: 0xf0052, + 0xf0053: 0xf0053, + 0xf0054: 0xf0054, + 0xf0055: 0xf0055, + 0xf0056: 0xf0056, + 0xf0057: 0xf0057, + 0xf0058: 0xf0058, + 0xf0059: 0xf0059, + 0xf005a: 0xf005a, + 0xf005b: 0xf005b, + 0xf005c: 0xf005c, + 0xf005d: 0xf005d, + 0xf005e: 0xf005e, + 0xf005f: 0xf005f, + 0xf0060: 0xf0060, + 0xf0061: 0xf0061, + 0xf0062: 0xf0062, + 0xf0063: 0xf0063, + 0xf0064: 0xf0064, + 0xf0065: 0xf0065, + 0xf0066: 0xf0066, + 0xf0067: 0xf0067, + 0xf0068: 0xf0068, + 0xf0069: 0xf0069, + 0xf006a: 0xf006a, + 0xf006b: 0xf006b, + 0xf006c: 0xf006c, + 0xf006d: 0xf006d, + 0xf006e: 0xf006e, + 0xf006f: 0xf006f, + 0xf0070: 0xf0070, + 0xf0071: 0xf0071, + 0xf0072: 0xf0072, + 0xf0073: 0xf0073, + 0xf0074: 0xf0074, + 0xf0075: 0xf0075, + 0xf0076: 0xf0076, + 0xf0077: 0xf0077, + 0xf0078: 0xf0078, + 0xf0079: 0xf0079, + 0xf007a: 0xf007a, + 0xf007b: 0xf007b, + 0xf007c: 0xf007c, + 0xf007d: 0xf007d, + 0xf007e: 0xf007e, + 0xf007f: 0xf007f, + 0xf0080: 0xf0080, + 0xf0081: 0xf0081, + 0xf0082: 0xf0082, + 0xf0083: 0xf0083, + 0xf0084: 0xf0084, + 0xf0085: 0xf0085, + 0xf0086: 0xf0086, + 0xf0087: 0xf0087, + 0xf0088: 0xf0088, + 0xf0089: 0xf0089, + 0xf008a: 0xf008a, + 0xf008b: 0xf008b, + 0xf008c: 0xf008c, + 0xf008d: 0xf008d, + 0xf008e: 0xf008e, + 0xf008f: 0xf008f, + 0xf0090: 0xf0090, + 0xf0091: 0xf0091, + 0xf0092: 0xf0092, + 0xf0093: 0xf0093, + 0xf0094: 0xf0094, + 0xf0095: 0xf0095, + 0xf0096: 0xf0096, + 0xf0097: 0xf0097, + 0xf0098: 0xf0098, + 0xf0099: 0xf0099, + 0xf009a: 0xf009a, + 0xf009b: 0xf009b, + 0xf009c: 0xf009c, + 0xf009d: 0xf009d, + 0xf009e: 0xf009e, + 0xf009f: 0xf009f, + 0xf00a0: 0xf00a0, + 0xf00a1: 0xf00a1, + 0xf00a2: 0xf00a2, + 0xf00a3: 0xf00a3, + 0xf00a4: 0xf00a4, + 0xf00a5: 0xf00a5, + 0xf00a6: 0xf00a6, + 0xf00a7: 0xf00a7, + 0xf00a8: 0xf00a8, + 0xf00a9: 0xf00a9, + 0xf00aa: 0xf00aa, + 0xf00ab: 0xf00ab, + 0xf00ac: 0xf00ac, + 0xf00ad: 0xf00ad, + 0xf00ae: 0xf00ae, + 0xf00af: 0xf00af, + 0xf00b0: 0xf00b0, + 0xf00b1: 0xf00b1, + 0xf00b2: 0xf00b2, + 0xf00b3: 0xf00b3, + 0xf00b4: 0xf00b4, + 0xf00b5: 0xf00b5, + 0xf00b6: 0xf00b6, + 0xf00b7: 0xf00b7, + 0xf00b8: 0xf00b8, + 0xf00b9: 0xf00b9, + 0xf00ba: 0xf00ba, + 0xf00bb: 0xf00bb, + 0xf00bc: 0xf00bc, + 0xf00bd: 0xf00bd, + 0xf00be: 0xf00be, + 0xf00bf: 0xf00bf, + 0xf00c0: 0xf00c0, + 0xf00c1: 0xf00c1, + 0xf00c2: 0xf00c2, + 0xf00c3: 0xf00c3, + 0xf00c4: 0xf00c4, + 0xf00c5: 0xf00c5, + 0xf00c6: 0xf00c6, + 0xf00c7: 0xf00c7, + 0xf00c8: 0xf00c8, + 0xf00c9: 0xf00c9, + 0xf00ca: 0xf00ca, + 0xf00cb: 0xf00cb, + 0xf00cc: 0xf00cc, + 0xf00cd: 0xf00cd, + 0xf00ce: 0xf00ce, + 0xf00cf: 0xf00cf, + 0xf00d0: 0xf00d0, + 0xf00d1: 0xf00d1, + 0xf00d2: 0xf00d2, + 0xf00d3: 0xf00d3, + 0xf00d4: 0xf00d4, + 0xf00d5: 0xf00d5, + 0xf00d6: 0xf00d6, + 0xf00d7: 0xf00d7, + 0xf00d8: 0xf00d8, + 0xf00d9: 0xf00d9, + 0xf00da: 0xf00da, + 0xf00db: 0xf00db, + 0xf00dc: 0xf00dc, + 0xf00dd: 0xf00dd, + 0xf00de: 0xf00de, + 0xf00df: 0xf00df, + 0xf00e0: 0xf00e0, + 0xf00e1: 0xf00e1, + 0xf00e2: 0xf00e2, + 0xf00e3: 0xf00e3, + 0xf00e4: 0xf00e4, + 0xf00e5: 0xf00e5, + 0xf00e6: 0xf00e6, + 0xf00e7: 0xf00e7, + 0xf00e8: 0xf00e8, + 0xf00e9: 0xf00e9, + 0xf00ea: 0xf00ea, + 0xf00eb: 0xf00eb, + 0xf00ec: 0xf00ec, + 0xf00ed: 0xf00ed, + 0xf00ee: 0xf00ee, + 0xf00ef: 0xf00ef, + 0xf00f0: 0xf00f0, + 0xf00f1: 0xf00f1, + 0xf00f2: 0xf00f2, + 0xf00f3: 0xf00f3, + 0xf00f4: 0xf00f4, + 0xf00f5: 0xf00f5, + 0xf00f6: 0xf00f6, + 0xf00f7: 0xf00f7, + 0xf00f8: 0xf00f8, + 0xf00f9: 0xf00f9, + 0xf00fa: 0xf00fa, + 0xf00fb: 0xf00fb, + 0xf00fc: 0xf00fc, + 0xf00fd: 0xf00fd, + 0xf00fe: 0xf00fe, + 0xf00ff: 0xf00ff, + 0xf0100: 0xf0100, + 0xf0101: 0xf0101, + 0xf0102: 0xf0102, + 0xf0103: 0xf0103, + 0xf0104: 0xf0104, + 0xf0105: 0xf0105, + 0xf0106: 0xf0106, + 0xf0107: 0xf0107, + 0xf0108: 0xf0108, + 0xf0109: 0xf0109, + 0xf010a: 0xf010a, + 0xf010b: 0xf010b, + 0xf010c: 0xf010c, + 0xf010d: 0xf010d, + 0xf010e: 0xf010e, + 0xf010f: 0xf010f, + 0xf0110: 0xf0110, + 0xf0111: 0xf0111, + 0xf0112: 0xf0112, + 0xf0113: 0xf0113, + 0xf0114: 0xf0114, + 0xf0115: 0xf0115, + 0xf0116: 0xf0116, + 0xf0117: 0xf0117, + 0xf0118: 0xf0118, + 0xf0119: 0xf0119, + 0xf011a: 0xf011a, + 0xf011b: 0xf011b, + 0xf011c: 0xf011c, + 0xf011d: 0xf011d, + 0xf011e: 0xf011e, + 0xf011f: 0xf011f, + 0xf0120: 0xf0120, + 0xf0121: 0xf0121, + 0xf0122: 0xf0122, + 0xf0123: 0xf0123, + 0xf0124: 0xf0124, + 0xf0125: 0xf0125, + 0xf0126: 0xf0126, + 0xf0127: 0xf0127, + 0xf0128: 0xf0128, + 0xf0129: 0xf0129, + 0xf012a: 0xf012a, + 0xf012b: 0xf012b, + 0xf012c: 0xf012c, + 0xf012d: 0xf012d, + 0xf012e: 0xf012e, + 0xf012f: 0xf012f, + 0xf0130: 0xf0130, + 0xf0131: 0xf0131, + 0xf0132: 0xf0132, + 0xf0133: 0xf0133, + 0xf0134: 0xf0134, + 0xf0135: 0xf0135, + 0xf0136: 0xf0136, + 0xf0137: 0xf0137, + 0xf0138: 0xf0138, + 0xf0139: 0xf0139, + 0xf013a: 0xf013a, + 0xf013b: 0xf013b, + 0xf013c: 0xf013c, + 0xf013d: 0xf013d, + 0xf013e: 0xf013e, + 0xf013f: 0xf013f, + 0xf0140: 0xf0140, + 0xf0141: 0xf0141, + 0xf0142: 0xf0142, + 0xf0143: 0xf0143, + 0xf0144: 0xf0144, + 0xf0145: 0xf0145, + 0xf0146: 0xf0146, + 0xf0147: 0xf0147, + 0xf0148: 0xf0148, + 0xf0149: 0xf0149, + 0xf014a: 0xf014a, + 0xf014b: 0xf014b, + 0xf014c: 0xf014c, + 0xf014d: 0xf014d, + 0xf014e: 0xf014e, + 0xf014f: 0xf014f, + 0xf0150: 0xf0150, + 0xf0151: 0xf0151, + 0xf0152: 0xf0152, + 0xf0153: 0xf0153, + 0xf0154: 0xf0154, + 0xf0155: 0xf0155, + 0xf0156: 0xf0156, + 0xf0157: 0xf0157, + 0xf0158: 0xf0158, + 0xf0159: 0xf0159, + 0xf015a: 0xf015a, + 0xf015b: 0xf015b, + 0xf015c: 0xf015c, + 0xf015d: 0xf015d, + 0xf015e: 0xf015e, + 0xf015f: 0xf015f, + 0xf0160: 0xf0160, + 0xf0161: 0xf0161, + 0xf0162: 0xf0162, + 0xf0163: 0xf0163, + 0xf0164: 0xf0164, + 0xf0165: 0xf0165, + 0xf0166: 0xf0166, + 0xf0167: 0xf0167, + 0xf0168: 0xf0168, + 0xf0169: 0xf0169, + 0xf016a: 0xf016a, + 0xf016b: 0xf016b, + 0xf016c: 0xf016c, + 0xf016d: 0xf016d, + 0xf016e: 0xf016e, + 0xf016f: 0xf016f, + 0xf0170: 0xf0170, + 0xf0171: 0xf0171, + 0xf0172: 0xf0172, + 0xf0173: 0xf0173, + 0xf0174: 0xf0174, + 0xf0175: 0xf0175, + 0xf0176: 0xf0176, + 0xf0177: 0xf0177, + 0xf0178: 0xf0178, + 0xf0179: 0xf0179, + 0xf017a: 0xf017a, + 0xf017b: 0xf017b, + 0xf017c: 0xf017c, + 0xf017d: 0xf017d, + 0xf017e: 0xf017e, + 0xf017f: 0xf017f, + 0xf0180: 0xf0180, + 0xf0181: 0xf0181, + 0xf0182: 0xf0182, + 0xf0183: 0xf0183, + 0xf0184: 0xf0184, + 0xf0185: 0xf0185, + 0xf0186: 0xf0186, + 0xf0187: 0xf0187, + 0xf0188: 0xf0188, + 0xf0189: 0xf0189, + 0xf018a: 0xf018a, + 0xf018b: 0xf018b, + 0xf018c: 0xf018c, + 0xf018d: 0xf018d, + 0xf018e: 0xf018e, + 0xf018f: 0xf018f, + 0xf0190: 0xf0190, + 0xf0191: 0xf0191, + 0xf0192: 0xf0192, + 0xf0193: 0xf0193, + 0xf0194: 0xf0194, + 0xf0195: 0xf0195, + 0xf0196: 0xf0196, + 0xf0197: 0xf0197, + 0xf0198: 0xf0198, + 0xf0199: 0xf0199, + 0xf019a: 0xf019a, + 0xf019b: 0xf019b, + 0xf019c: 0xf019c, + 0xf019d: 0xf019d, + 0xf019e: 0xf019e, + 0xf019f: 0xf019f, + 0xf01a0: 0xf01a0, + 0xf01a1: 0xf01a1, + 0xf01a2: 0xf01a2, + 0xf01a3: 0xf01a3, + 0xf01a4: 0xf01a4, + 0xf01a5: 0xf01a5, + 0xf01a6: 0xf01a6, + 0xf01a7: 0xf01a7, + 0xf01a8: 0xf01a8, + 0xf01a9: 0xf01a9, + 0xf01aa: 0xf01aa, + 0xf01ab: 0xf01ab, + 0xf01ac: 0xf01ac, + 0xf01ad: 0xf01ad, + 0xf01ae: 0xf01ae, + 0xf01af: 0xf01af, + 0xf01b0: 0xf01b0, + 0xf01b1: 0xf01b1, + 0xf01b2: 0xf01b2, + 0xf01b3: 0xf01b3, + 0xf01b4: 0xf01b4, + 0xf01b5: 0xf01b5, + 0xf01b6: 0xf01b6, + 0xf01b7: 0xf01b7, + 0xf01b8: 0xf01b8, + 0xf01b9: 0xf01b9, + 0xf01ba: 0xf01ba, + 0xf01bb: 0xf01bb, + 0xf01bc: 0xf01bc, + 0xf01bd: 0xf01bd, + 0xf01be: 0xf01be, + 0xf01bf: 0xf01bf, + 0xf01c0: 0xf01c0, + 0xf01c1: 0xf01c1, + 0xf01c2: 0xf01c2, + 0xf01c3: 0xf01c3, + 0xf01c4: 0xf01c4, + 0xf01c5: 0xf01c5, + 0xf01c6: 0xf01c6, + 0xf01c7: 0xf01c7, + 0xf01c8: 0xf01c8, + 0xf01c9: 0xf01c9, + 0xf01ca: 0xf01ca, + 0xf01cb: 0xf01cb, + 0xf01cc: 0xf01cc, + 0xf01cd: 0xf01cd, + 0xf01ce: 0xf01ce, + 0xf01cf: 0xf01cf, + 0xf01d0: 0xf01d0, + 0xf01d1: 0xf01d1, + 0xf01d2: 0xf01d2, + 0xf01d3: 0xf01d3, + 0xf01d4: 0xf01d4, + 0xf01d5: 0xf01d5, + 0xf01d6: 0xf01d6, + 0xf01d7: 0xf01d7, + 0xf01d8: 0xf01d8, + 0xf01d9: 0xf01d9, + 0xf01da: 0xf01da, + 0xf01db: 0xf01db, + 0xf01dc: 0xf01dc, + 0xf01dd: 0xf01dd, + 0xf01de: 0xf01de, + 0xf01df: 0xf01df, + 0xf01e0: 0xf01e0, + 0xf01e1: 0xf01e1, + 0xf01e2: 0xf01e2, + 0xf01e3: 0xf01e3, + 0xf01e4: 0xf01e4, + 0xf01e5: 0xf01e5, + 0xf01e6: 0xf01e6, + 0xf01e7: 0xf01e7, + 0xf01e8: 0xf01e8, + 0xf01e9: 0xf01e9, + 0xf01ea: 0xf01ea, + 0xf01eb: 0xf01eb, + 0xf01ec: 0xf01ec, + 0xf01ed: 0xf01ed, + 0xf01ee: 0xf01ee, + 0xf01ef: 0xf01ef, + 0xf01f0: 0xf01f0, + 0xf01f1: 0xf01f1, + 0xf01f2: 0xf01f2, + 0xf01f3: 0xf01f3, + 0xf01f4: 0xf01f4, + 0xf01f5: 0xf01f5, + 0xf01f6: 0xf01f6, + 0xf01f7: 0xf01f7, + 0xf01f8: 0xf01f8, + 0xf01f9: 0xf01f9, + 0xf01fa: 0xf01fa, + 0xf01fb: 0xf01fb, + 0xf01fc: 0xf01fc, + 0xf01fd: 0xf01fd, + 0xf01fe: 0xf01fe, + 0xf01ff: 0xf01ff, + 0xf0200: 0xf0200, + 0xf0201: 0xf0201, + 0xf0202: 0xf0202, + 0xf0203: 0xf0203, + 0xf0204: 0xf0204, + 0xf0205: 0xf0205, + 0xf0206: 0xf0206, + 0xf0207: 0xf0207, + 0xf0208: 0xf0208, + 0xf0209: 0xf0209, + 0xf020a: 0xf020a, + 0xf020b: 0xf020b, + 0xf020c: 0xf020c, + 0xf020d: 0xf020d, + 0xf020e: 0xf020e, + 0xf020f: 0xf020f, + 0xf0210: 0xf0210, + 0xf0211: 0xf0211, + 0xf0212: 0xf0212, + 0xf0213: 0xf0213, + 0xf0214: 0xf0214, + 0xf0215: 0xf0215, + 0xf0216: 0xf0216, + 0xf0217: 0xf0217, + 0xf0218: 0xf0218, + 0xf0219: 0xf0219, + 0xf021a: 0xf021a, + 0xf021b: 0xf021b, + 0xf021c: 0xf021c, + 0xf021d: 0xf021d, + 0xf021e: 0xf021e, + 0xf021f: 0xf021f, + 0xf0220: 0xf0220, + 0xf0221: 0xf0221, + 0xf0222: 0xf0222, + 0xf0223: 0xf0223, + 0xf0224: 0xf0224, + 0xf0225: 0xf0225, + 0xf0226: 0xf0226, + 0xf0227: 0xf0227, + 0xf0228: 0xf0228, + 0xf0229: 0xf0229, + 0xf022a: 0xf022a, + 0xf022b: 0xf022b, + 0xf022c: 0xf022c, + 0xf022d: 0xf022d, + 0xf022e: 0xf022e, + 0xf022f: 0xf022f, + 0xf0230: 0xf0230, + 0xf0231: 0xf0231, + 0xf0232: 0xf0232, + 0xf0233: 0xf0233, + 0xf0234: 0xf0234, + 0xf0235: 0xf0235, + 0xf0236: 0xf0236, + 0xf0237: 0xf0237, + 0xf0238: 0xf0238, + 0xf0239: 0xf0239, + 0xf023a: 0xf023a, + 0xf023b: 0xf023b, + 0xf023c: 0xf023c, + 0xf023d: 0xf023d, + 0xf023e: 0xf023e, + 0xf023f: 0xf023f, + 0xf0240: 0xf0240, + 0xf0241: 0xf0241, + 0xf0242: 0xf0242, + 0xf0243: 0xf0243, + 0xf0244: 0xf0244, + 0xf0245: 0xf0245, + 0xf0246: 0xf0246, + 0xf0247: 0xf0247, + 0xf0248: 0xf0248, + 0xf0249: 0xf0249, + 0xf024a: 0xf024a, + 0xf024b: 0xf024b, + 0xf024c: 0xf024c, + 0xf024d: 0xf024d, + 0xf024e: 0xf024e, + 0xf024f: 0xf024f, + 0xf0250: 0xf0250, + 0xf0251: 0xf0251, + 0xf0252: 0xf0252, + 0xf0253: 0xf0253, + 0xf0254: 0xf0254, + 0xf0255: 0xf0255, + 0xf0256: 0xf0256, + 0xf0257: 0xf0257, + 0xf0258: 0xf0258, + 0xf0259: 0xf0259, + 0xf025a: 0xf025a, + 0xf025b: 0xf025b, + 0xf025c: 0xf025c, + 0xf025d: 0xf025d, + 0xf025e: 0xf025e, + 0xf025f: 0xf025f, + 0xf0260: 0xf0260, + 0xf0261: 0xf0261, + 0xf0262: 0xf0262, + 0xf0263: 0xf0263, + 0xf0264: 0xf0264, + 0xf0265: 0xf0265, + 0xf0266: 0xf0266, + 0xf0267: 0xf0267, + 0xf0268: 0xf0268, + 0xf0269: 0xf0269, + 0xf026a: 0xf026a, + 0xf026b: 0xf026b, + 0xf026c: 0xf026c, + 0xf026d: 0xf026d, + 0xf026e: 0xf026e, + 0xf026f: 0xf026f, + 0xf0270: 0xf0270, + 0xf0271: 0xf0271, + 0xf0272: 0xf0272, + 0xf0273: 0xf0273, + 0xf0274: 0xf0274, + 0xf0275: 0xf0275, + 0xf0276: 0xf0276, + 0xf0277: 0xf0277, + 0xf0278: 0xf0278, + 0xf0279: 0xf0279, + 0xf027a: 0xf027a, + 0xf027b: 0xf027b, + 0xf027c: 0xf027c, + 0xf027d: 0xf027d, + 0xf027e: 0xf027e, + 0xf027f: 0xf027f, + 0xf0280: 0xf0280, + 0xf0281: 0xf0281, + 0xf0282: 0xf0282, + 0xf0283: 0xf0283, + 0xf0284: 0xf0284, + 0xf0285: 0xf0285, + 0xf0286: 0xf0286, + 0xf0287: 0xf0287, + 0xf0288: 0xf0288, + 0xf0289: 0xf0289, + 0xf028a: 0xf028a, + 0xf028b: 0xf028b, + 0xf028c: 0xf028c, + 0xf028d: 0xf028d, + 0xf028e: 0xf028e, + 0xf028f: 0xf028f, + 0xf0290: 0xf0290, + 0xf0291: 0xf0291, + 0xf0292: 0xf0292, + 0xf0293: 0xf0293, + 0xf0294: 0xf0294, + 0xf0295: 0xf0295, + 0xf0296: 0xf0296, + 0xf0297: 0xf0297, + 0xf0298: 0xf0298, + 0xf0299: 0xf0299, + 0xf029a: 0xf029a, + 0xf029b: 0xf029b, + 0xf029c: 0xf029c, + 0xf029d: 0xf029d, + 0xf029e: 0xf029e, + 0xf029f: 0xf029f, + 0xf02a0: 0xf02a0, + 0xf02a1: 0xf02a1, + 0xf02a2: 0xf02a2, + 0xf02a3: 0xf02a3, + 0xf02a4: 0xf02a4, + 0xf02a5: 0xf02a5, + 0xf02a6: 0xf02a6, + 0xf02a7: 0xf02a7, + 0xf02a8: 0xf02a8, + 0xf02a9: 0xf02a9, + 0xf02aa: 0xf02aa, + 0xf02ab: 0xf02ab, + 0xf02ac: 0xf02ac, + 0xf02ad: 0xf02ad, + 0xf02ae: 0xf02ae, + 0xf02af: 0xf02af, + 0xf02b0: 0xf02b0, + 0xf02b1: 0xf02b1, + 0xf02b2: 0xf02b2, + 0xf02b3: 0xf02b3, + 0xf02b4: 0xf02b4, + 0xf02b5: 0xf02b5, + 0xf02b6: 0xf02b6, + 0xf02b7: 0xf02b7, + 0xf02b8: 0xf02b8, + 0xf02b9: 0xf02b9, + 0xf02ba: 0xf02ba, + 0xf02bb: 0xf02bb, + 0xf02bc: 0xf02bc, + 0xf02bd: 0xf02bd, + 0xf02be: 0xf02be, + 0xf02bf: 0xf02bf, + 0xf02c0: 0xf02c0, + 0xf02c1: 0xf02c1, + 0xf02c2: 0xf02c2, + 0xf02c3: 0xf02c3, + 0xf02c4: 0xf02c4, + 0xf02c5: 0xf02c5, + 0xf02c6: 0xf02c6, + 0xf02c7: 0xf02c7, + 0xf02c8: 0xf02c8, + 0xf02c9: 0xf02c9, + 0xf02ca: 0xf02ca, + 0xf02cb: 0xf02cb, + 0xf02cc: 0xf02cc, + 0xf02cd: 0xf02cd, + 0xf02ce: 0xf02ce, + 0xf02cf: 0xf02cf, + 0xf02d0: 0xf02d0, + 0xf02d1: 0xf02d1, + 0xf02d2: 0xf02d2, + 0xf02d3: 0xf02d3, + 0xf02d4: 0xf02d4, + 0xf02d5: 0xf02d5, + 0xf02d6: 0xf02d6, + 0xf02d7: 0xf02d7, + 0xf02d8: 0xf02d8, + 0xf02d9: 0xf02d9, + 0xf02da: 0xf02da, + 0xf02db: 0xf02db, + 0xf02dc: 0xf02dc, + 0xf02dd: 0xf02dd, + 0xf02de: 0xf02de, + 0xf02df: 0xf02df, + 0xf02e0: 0xf02e0, + 0xf02e1: 0xf02e1, + 0xf02e2: 0xf02e2, + 0xf02e3: 0xf02e3, + 0xf02e4: 0xf02e4, + 0xf02e5: 0xf02e5, + 0xf02e6: 0xf02e6, + 0xf02e7: 0xf02e7, + 0xf02e8: 0xf02e8, + 0xf02e9: 0xf02e9, + 0xf02ea: 0xf02ea, + 0xf02eb: 0xf02eb, + 0xf02ec: 0xf02ec, + 0xf02ed: 0xf02ed, + 0xf02ee: 0xf02ee, + 0xf02ef: 0xf02ef, + 0xf02f0: 0xf02f0, + 0xf02f1: 0xf02f1, + 0xf02f2: 0xf02f2, + 0xf02f3: 0xf02f3, + 0xf02f4: 0xf02f4, + 0xf02f5: 0xf02f5, + 0xf02f6: 0xf02f6, + 0xf02f7: 0xf02f7, + 0xf02f8: 0xf02f8, + 0xf02f9: 0xf02f9, + 0xf02fa: 0xf02fa, + 0xf02fb: 0xf02fb, + 0xf02fc: 0xf02fc, + 0xf02fd: 0xf02fd, + 0xf02fe: 0xf02fe, + 0xf02ff: 0xf02ff, + 0xf0300: 0xf0300, + 0xf0301: 0xf0301, + 0xf0302: 0xf0302, + 0xf0303: 0xf0303, + 0xf0304: 0xf0304, + 0xf0305: 0xf0305, + 0xf0306: 0xf0306, + 0xf0307: 0xf0307, + 0xf0308: 0xf0308, + 0xf0309: 0xf0309, + 0xf030a: 0xf030a, + 0xf030b: 0xf030b, + 0xf030c: 0xf030c, + 0xf030d: 0xf030d, + 0xf030e: 0xf030e, + 0xf030f: 0xf030f, + 0xf0310: 0xf0310, + 0xf0311: 0xf0311, + 0xf0312: 0xf0312, + 0xf0313: 0xf0313, + 0xf0314: 0xf0314, + 0xf0315: 0xf0315, + 0xf0316: 0xf0316, + 0xf0317: 0xf0317, + 0xf0318: 0xf0318, + 0xf0319: 0xf0319, + 0xf031a: 0xf031a, + 0xf031b: 0xf031b, + 0xf031c: 0xf031c, + 0xf031d: 0xf031d, + 0xf031e: 0xf031e, + 0xf031f: 0xf031f, + 0xf0320: 0xf0320, + 0xf0321: 0xf0321, + 0xf0322: 0xf0322, + 0xf0323: 0xf0323, + 0xf0324: 0xf0324, + 0xf0325: 0xf0325, + 0xf0326: 0xf0326, + 0xf0327: 0xf0327, + 0xf0328: 0xf0328, + 0xf0329: 0xf0329, + 0xf032a: 0xf032a, + 0xf032b: 0xf032b, + 0xf032c: 0xf032c, + 0xf032d: 0xf032d, + 0xf032e: 0xf032e, + 0xf032f: 0xf032f, + 0xf0330: 0xf0330, + 0xf0331: 0xf0331, + 0xf0332: 0xf0332, + 0xf0333: 0xf0333, + 0xf0334: 0xf0334, + 0xf0335: 0xf0335, + 0xf0336: 0xf0336, + 0xf0337: 0xf0337, + 0xf0338: 0xf0338, + 0xf0339: 0xf0339, + 0xf033a: 0xf033a, + 0xf033b: 0xf033b, + 0xf033c: 0xf033c, + 0xf033d: 0xf033d, + 0xf033e: 0xf033e, + 0xf033f: 0xf033f, + 0xf0340: 0xf0340, + 0xf0341: 0xf0341, + 0xf0342: 0xf0342, + 0xf0343: 0xf0343, + 0xf0344: 0xf0344, + 0xf0345: 0xf0345, + 0xf0346: 0xf0346, + 0xf0347: 0xf0347, + 0xf0348: 0xf0348, + 0xf0349: 0xf0349, + 0xf034a: 0xf034a, + 0xf034b: 0xf034b, + 0xf034c: 0xf034c, + 0xf034d: 0xf034d, + 0xf034e: 0xf034e, + 0xf034f: 0xf034f, + 0xf0350: 0xf0350, + 0xf0351: 0xf0351, + 0xf0352: 0xf0352, + 0xf0353: 0xf0353, + 0xf0354: 0xf0354, + 0xf0355: 0xf0355, + 0xf0356: 0xf0356, + 0xf0357: 0xf0357, + 0xf0358: 0xf0358, + 0xf0359: 0xf0359, + 0xf035a: 0xf035a, + 0xf035b: 0xf035b, + 0xf035c: 0xf035c, + 0xf035d: 0xf035d, + 0xf035e: 0xf035e, + 0xf035f: 0xf035f, + 0xf0360: 0xf0360, + 0xf0361: 0xf0361, + 0xf0362: 0xf0362, + 0xf0363: 0xf0363, + 0xf0364: 0xf0364, + 0xf0365: 0xf0365, + 0xf0366: 0xf0366, + 0xf0367: 0xf0367, + 0xf0368: 0xf0368, + 0xf0369: 0xf0369, + 0xf036a: 0xf036a, + 0xf036b: 0xf036b, + 0xf036c: 0xf036c, + 0xf036d: 0xf036d, + 0xf036e: 0xf036e, + 0xf036f: 0xf036f, + 0xf0370: 0xf0370, + 0xf0371: 0xf0371, + 0xf0372: 0xf0372, + 0xf0373: 0xf0373, + 0xf0374: 0xf0374, + 0xf0375: 0xf0375, + 0xf0376: 0xf0376, + 0xf0377: 0xf0377, + 0xf0378: 0xf0378, + 0xf0379: 0xf0379, + 0xf037a: 0xf037a, + 0xf037b: 0xf037b, + 0xf037c: 0xf037c, + 0xf037d: 0xf037d, + 0xf037e: 0xf037e, + 0xf037f: 0xf037f, + 0xf0380: 0xf0380, + 0xf0381: 0xf0381, + 0xf0382: 0xf0382, + 0xf0383: 0xf0383, + 0xf0384: 0xf0384, + 0xf0385: 0xf0385, + 0xf0386: 0xf0386, + 0xf0387: 0xf0387, + 0xf0388: 0xf0388, + 0xf0389: 0xf0389, + 0xf038a: 0xf038a, + 0xf038b: 0xf038b, + 0xf038c: 0xf038c, + 0xf038d: 0xf038d, + 0xf038e: 0xf038e, + 0xf038f: 0xf038f, + 0xf0390: 0xf0390, + 0xf0391: 0xf0391, + 0xf0392: 0xf0392, + 0xf0393: 0xf0393, + 0xf0394: 0xf0394, + 0xf0395: 0xf0395, + 0xf0396: 0xf0396, + 0xf0397: 0xf0397, + 0xf0398: 0xf0398, + 0xf0399: 0xf0399, + 0xf039a: 0xf039a, + 0xf039b: 0xf039b, + 0xf039c: 0xf039c, + 0xf039d: 0xf039d, + 0xf039e: 0xf039e, + 0xf039f: 0xf039f, + 0xf03a0: 0xf03a0, + 0xf03a1: 0xf03a1, + 0xf03a2: 0xf03a2, + 0xf03a3: 0xf03a3, + 0xf03a4: 0xf03a4, + 0xf03a5: 0xf03a5, + 0xf03a6: 0xf03a6, + 0xf03a7: 0xf03a7, + 0xf03a8: 0xf03a8, + 0xf03a9: 0xf03a9, + 0xf03aa: 0xf03aa, + 0xf03ab: 0xf03ab, + 0xf03ac: 0xf03ac, + 0xf03ad: 0xf03ad, + 0xf03ae: 0xf03ae, + 0xf03af: 0xf03af, + 0xf03b0: 0xf03b0, + 0xf03b1: 0xf03b1, + 0xf03b2: 0xf03b2, + 0xf03b3: 0xf03b3, + 0xf03b4: 0xf03b4, + 0xf03b5: 0xf03b5, + 0xf03b6: 0xf03b6, + 0xf03b7: 0xf03b7, + 0xf03b8: 0xf03b8, + 0xf03b9: 0xf03b9, + 0xf03ba: 0xf03ba, + 0xf03bb: 0xf03bb, + 0xf03bc: 0xf03bc, + 0xf03bd: 0xf03bd, + 0xf03be: 0xf03be, + 0xf03bf: 0xf03bf, + 0xf03c0: 0xf03c0, + 0xf03c1: 0xf03c1, + 0xf03c2: 0xf03c2, + 0xf03c3: 0xf03c3, + 0xf03c4: 0xf03c4, + 0xf03c5: 0xf03c5, + 0xf03c6: 0xf03c6, + 0xf03c7: 0xf03c7, + 0xf03c8: 0xf03c8, + 0xf03c9: 0xf03c9, + 0xf03ca: 0xf03ca, + 0xf03cb: 0xf03cb, + 0xf03cc: 0xf03cc, + 0xf03cd: 0xf03cd, + 0xf03ce: 0xf03ce, + 0xf03cf: 0xf03cf, + 0xf03d0: 0xf03d0, + 0xf03d1: 0xf03d1, + 0xf03d2: 0xf03d2, + 0xf03d3: 0xf03d3, + 0xf03d4: 0xf03d4, + 0xf03d5: 0xf03d5, + 0xf03d6: 0xf03d6, + 0xf03d7: 0xf03d7, + 0xf03d8: 0xf03d8, + 0xf03d9: 0xf03d9, + 0xf03da: 0xf03da, + 0xf03db: 0xf03db, + 0xf03dc: 0xf03dc, + 0xf03dd: 0xf03dd, + 0xf03de: 0xf03de, + 0xf03df: 0xf03df, + 0xf03e0: 0xf03e0, + 0xf03e1: 0xf03e1, + 0xf03e2: 0xf03e2, + 0xf03e3: 0xf03e3, + 0xf03e4: 0xf03e4, + 0xf03e5: 0xf03e5, + 0xf03e6: 0xf03e6, + 0xf03e7: 0xf03e7, + 0xf03e8: 0xf03e8, + 0xf03e9: 0xf03e9, + 0xf03ea: 0xf03ea, + 0xf03eb: 0xf03eb, + 0xf03ec: 0xf03ec, + 0xf03ed: 0xf03ed, + 0xf03ee: 0xf03ee, + 0xf03ef: 0xf03ef, + 0xf03f0: 0xf03f0, + 0xf03f1: 0xf03f1, + 0xf03f2: 0xf03f2, + 0xf03f3: 0xf03f3, + 0xf03f4: 0xf03f4, + 0xf03f5: 0xf03f5, + 0xf03f6: 0xf03f6, + 0xf03f7: 0xf03f7, + 0xf03f8: 0xf03f8, + 0xf03f9: 0xf03f9, + 0xf03fa: 0xf03fa, + 0xf03fb: 0xf03fb, + 0xf03fc: 0xf03fc, + 0xf03fd: 0xf03fd, + 0xf03fe: 0xf03fe, + 0xf03ff: 0xf03ff, + 0xf0400: 0xf0400, + 0xf0401: 0xf0401, + 0xf0402: 0xf0402, + 0xf0403: 0xf0403, + 0xf0404: 0xf0404, + 0xf0405: 0xf0405, + 0xf0406: 0xf0406, + 0xf0407: 0xf0407, + 0xf0408: 0xf0408, + 0xf0409: 0xf0409, + 0xf040a: 0xf040a, + 0xf040b: 0xf040b, + 0xf040c: 0xf040c, + 0xf040d: 0xf040d, + 0xf040e: 0xf040e, + 0xf040f: 0xf040f, + 0xf0410: 0xf0410, + 0xf0411: 0xf0411, + 0xf0412: 0xf0412, + 0xf0413: 0xf0413, + 0xf0414: 0xf0414, + 0xf0415: 0xf0415, + 0xf0416: 0xf0416, + 0xf0417: 0xf0417, + 0xf0418: 0xf0418, + 0xf0419: 0xf0419, + 0xf041a: 0xf041a, + 0xf041b: 0xf041b, + 0xf041c: 0xf041c, + 0xf041d: 0xf041d, + 0xf041e: 0xf041e, + 0xf041f: 0xf041f, + 0xf0420: 0xf0420, + 0xf0421: 0xf0421, + 0xf0422: 0xf0422, + 0xf0423: 0xf0423, + 0xf0424: 0xf0424, + 0xf0425: 0xf0425, + 0xf0426: 0xf0426, + 0xf0427: 0xf0427, + 0xf0428: 0xf0428, + 0xf0429: 0xf0429, + 0xf042a: 0xf042a, + 0xf042b: 0xf042b, + 0xf042c: 0xf042c, + 0xf042d: 0xf042d, + 0xf042e: 0xf042e, + 0xf042f: 0xf042f, + 0xf0430: 0xf0430, + 0xf0431: 0xf0431, + 0xf0432: 0xf0432, + 0xf0433: 0xf0433, + 0xf0434: 0xf0434, + 0xf0435: 0xf0435, + 0xf0436: 0xf0436, + 0xf0437: 0xf0437, + 0xf0438: 0xf0438, + 0xf0439: 0xf0439, + 0xf043a: 0xf043a, + 0xf043b: 0xf043b, + 0xf043c: 0xf043c, + 0xf043d: 0xf043d, + 0xf043e: 0xf043e, + 0xf043f: 0xf043f, + 0xf0440: 0xf0440, + 0xf0441: 0xf0441, + 0xf0442: 0xf0442, + 0xf0443: 0xf0443, + 0xf0444: 0xf0444, + 0xf0445: 0xf0445, + 0xf0446: 0xf0446, + 0xf0447: 0xf0447, + 0xf0448: 0xf0448, + 0xf0449: 0xf0449, + 0xf044a: 0xf044a, + 0xf044b: 0xf044b, + 0xf044c: 0xf044c, + 0xf044d: 0xf044d, + 0xf044e: 0xf044e, + 0xf044f: 0xf044f, + 0xf0450: 0xf0450, + 0xf0451: 0xf0451, + 0xf0452: 0xf0452, + 0xf0453: 0xf0453, + 0xf0454: 0xf0454, + 0xf0455: 0xf0455, + 0xf0456: 0xf0456, + 0xf0457: 0xf0457, + 0xf0458: 0xf0458, + 0xf0459: 0xf0459, + 0xf045a: 0xf045a, + 0xf045b: 0xf045b, + 0xf045c: 0xf045c, + 0xf045d: 0xf045d, + 0xf045e: 0xf045e, + 0xf045f: 0xf045f, + 0xf0460: 0xf0460, + 0xf0461: 0xf0461, + 0xf0462: 0xf0462, + 0xf0463: 0xf0463, + 0xf0464: 0xf0464, + 0xf0465: 0xf0465, + 0xf0466: 0xf0466, + 0xf0467: 0xf0467, + 0xf0468: 0xf0468, + 0xf0469: 0xf0469, + 0xf046a: 0xf046a, + 0xf046b: 0xf046b, + 0xf046c: 0xf046c, + 0xf046d: 0xf046d, + 0xf046e: 0xf046e, + 0xf046f: 0xf046f, + 0xf0470: 0xf0470, + 0xf0471: 0xf0471, + 0xf0472: 0xf0472, + 0xf0473: 0xf0473, + 0xf0474: 0xf0474, + 0xf0475: 0xf0475, + 0xf0476: 0xf0476, + 0xf0477: 0xf0477, + 0xf0478: 0xf0478, + 0xf0479: 0xf0479, + 0xf047a: 0xf047a, + 0xf047b: 0xf047b, + 0xf047c: 0xf047c, + 0xf047d: 0xf047d, + 0xf047e: 0xf047e, + 0xf047f: 0xf047f, + 0xf0480: 0xf0480, + 0xf0481: 0xf0481, + 0xf0482: 0xf0482, + 0xf0483: 0xf0483, + 0xf0484: 0xf0484, + 0xf0485: 0xf0485, + 0xf0486: 0xf0486, + 0xf0487: 0xf0487, + 0xf0488: 0xf0488, + 0xf0489: 0xf0489, + 0xf048a: 0xf048a, + 0xf048b: 0xf048b, + 0xf048c: 0xf048c, + 0xf048d: 0xf048d, + 0xf048e: 0xf048e, + 0xf048f: 0xf048f, + 0xf0490: 0xf0490, + 0xf0491: 0xf0491, + 0xf0492: 0xf0492, + 0xf0493: 0xf0493, + 0xf0494: 0xf0494, + 0xf0495: 0xf0495, + 0xf0496: 0xf0496, + 0xf0497: 0xf0497, + 0xf0498: 0xf0498, + 0xf0499: 0xf0499, + 0xf049a: 0xf049a, + 0xf049b: 0xf049b, + 0xf049c: 0xf049c, + 0xf049d: 0xf049d, + 0xf049e: 0xf049e, + 0xf049f: 0xf049f, + 0xf04a0: 0xf04a0, + 0xf04a1: 0xf04a1, + 0xf04a2: 0xf04a2, + 0xf04a3: 0xf04a3, + 0xf04a4: 0xf04a4, + 0xf04a5: 0xf04a5, + 0xf04a6: 0xf04a6, + 0xf04a7: 0xf04a7, + 0xf04a8: 0xf04a8, + 0xf04a9: 0xf04a9, + 0xf04aa: 0xf04aa, + 0xf04ab: 0xf04ab, + 0xf04ac: 0xf04ac, + 0xf04ad: 0xf04ad, + 0xf04ae: 0xf04ae, + 0xf04af: 0xf04af, + 0xf04b0: 0xf04b0, + 0xf04b1: 0xf04b1, + 0xf04b2: 0xf04b2, + 0xf04b3: 0xf04b3, + 0xf04b4: 0xf04b4, + 0xf04b5: 0xf04b5, + 0xf04b6: 0xf04b6, + 0xf04b7: 0xf04b7, + 0xf04b8: 0xf04b8, + 0xf04b9: 0xf04b9, + 0xf04ba: 0xf04ba, + 0xf04bb: 0xf04bb, + 0xf04bc: 0xf04bc, + 0xf04bd: 0xf04bd, + 0xf04be: 0xf04be, + 0xf04bf: 0xf04bf, + 0xf04c0: 0xf04c0, + 0xf04c1: 0xf04c1, + 0xf04c2: 0xf04c2, + 0xf04c3: 0xf04c3, + 0xf04c4: 0xf04c4, + 0xf04c5: 0xf04c5, + 0xf04c6: 0xf04c6, + 0xf04c7: 0xf04c7, + 0xf04c8: 0xf04c8, + 0xf04c9: 0xf04c9, + 0xf04ca: 0xf04ca, + 0xf04cb: 0xf04cb, + 0xf04cc: 0xf04cc, + 0xf04cd: 0xf04cd, + 0xf04ce: 0xf04ce, + 0xf04cf: 0xf04cf, + 0xf04d0: 0xf04d0, + 0xf04d1: 0xf04d1, + 0xf04d2: 0xf04d2, + 0xf04d3: 0xf04d3, + 0xf04d4: 0xf04d4, + 0xf04d5: 0xf04d5, + 0xf04d6: 0xf04d6, + 0xf04d7: 0xf04d7, + 0xf04d8: 0xf04d8, + 0xf04d9: 0xf04d9, + 0xf04da: 0xf04da, + 0xf04db: 0xf04db, + 0xf04dc: 0xf04dc, + 0xf04dd: 0xf04dd, + 0xf04de: 0xf04de, + 0xf04df: 0xf04df, + 0xf04e0: 0xf04e0, + 0xf04e1: 0xf04e1, + 0xf04e2: 0xf04e2, + 0xf04e3: 0xf04e3, + 0xf04e4: 0xf04e4, + 0xf04e5: 0xf04e5, + 0xf04e6: 0xf04e6, + 0xf04e7: 0xf04e7, + 0xf04e8: 0xf04e8, + 0xf04e9: 0xf04e9, + 0xf04ea: 0xf04ea, + 0xf04eb: 0xf04eb, + 0xf04ec: 0xf04ec, + 0xf04ed: 0xf04ed, + 0xf04ee: 0xf04ee, + 0xf04ef: 0xf04ef, + 0xf04f0: 0xf04f0, + 0xf04f1: 0xf04f1, + 0xf04f2: 0xf04f2, + 0xf04f3: 0xf04f3, + 0xf04f4: 0xf04f4, + 0xf04f5: 0xf04f5, + 0xf04f6: 0xf04f6, + 0xf04f7: 0xf04f7, + 0xf04f8: 0xf04f8, + 0xf04f9: 0xf04f9, + 0xf04fa: 0xf04fa, + 0xf04fb: 0xf04fb, + 0xf04fc: 0xf04fc, + 0xf04fd: 0xf04fd, + 0xf04fe: 0xf04fe, + 0xf04ff: 0xf04ff, + 0xf0500: 0xf0500, + 0xf0501: 0xf0501, + 0xf0502: 0xf0502, + 0xf0503: 0xf0503, + 0xf0504: 0xf0504, + 0xf0505: 0xf0505, + 0xf0506: 0xf0506, + 0xf0507: 0xf0507, + 0xf0508: 0xf0508, + 0xf0509: 0xf0509, + 0xf050a: 0xf050a, + 0xf050b: 0xf050b, + 0xf050c: 0xf050c, + 0xf050d: 0xf050d, + 0xf050e: 0xf050e, + 0xf050f: 0xf050f, + 0xf0510: 0xf0510, + 0xf0511: 0xf0511, + 0xf0512: 0xf0512, + 0xf0513: 0xf0513, + 0xf0514: 0xf0514, + 0xf0515: 0xf0515, + 0xf0516: 0xf0516, + 0xf0517: 0xf0517, + 0xf0518: 0xf0518, + 0xf0519: 0xf0519, + 0xf051a: 0xf051a, + 0xf051b: 0xf051b, + 0xf051c: 0xf051c, + 0xf051d: 0xf051d, + 0xf051e: 0xf051e, + 0xf051f: 0xf051f, + 0xf0520: 0xf0520, + 0xf0521: 0xf0521, + 0xf0522: 0xf0522, + 0xf0523: 0xf0523, + 0xf0524: 0xf0524, + 0xf0525: 0xf0525, + 0xf0526: 0xf0526, + 0xf0527: 0xf0527, + 0xf0528: 0xf0528, + 0xf0529: 0xf0529, + 0xf052a: 0xf052a, + 0xf052b: 0xf052b, + 0xf052c: 0xf052c, + 0xf052d: 0xf052d, + 0xf052e: 0xf052e, + 0xf052f: 0xf052f, + 0xf0530: 0xf0530, + 0xf0531: 0xf0531, + 0xf0532: 0xf0532, + 0xf0533: 0xf0533, + 0xf0534: 0xf0534, + 0xf0535: 0xf0535, + 0xf0536: 0xf0536, + 0xf0537: 0xf0537, + 0xf0538: 0xf0538, + 0xf0539: 0xf0539, + 0xf053a: 0xf053a, + 0xf053b: 0xf053b, + 0xf053c: 0xf053c, + 0xf053d: 0xf053d, + 0xf053e: 0xf053e, + 0xf053f: 0xf053f, + 0xf0540: 0xf0540, + 0xf0541: 0xf0541, + 0xf0542: 0xf0542, + 0xf0543: 0xf0543, + 0xf0544: 0xf0544, + 0xf0545: 0xf0545, + 0xf0546: 0xf0546, + 0xf0547: 0xf0547, + 0xf0548: 0xf0548, + 0xf0549: 0xf0549, + 0xf054a: 0xf054a, + 0xf054b: 0xf054b, + 0xf054c: 0xf054c, + 0xf054d: 0xf054d, + 0xf054e: 0xf054e, + 0xf054f: 0xf054f, + 0xf0550: 0xf0550, + 0xf0551: 0xf0551, + 0xf0552: 0xf0552, + 0xf0553: 0xf0553, + 0xf0554: 0xf0554, + 0xf0555: 0xf0555, + 0xf0556: 0xf0556, + 0xf0557: 0xf0557, + 0xf0558: 0xf0558, + 0xf0559: 0xf0559, + 0xf055a: 0xf055a, + 0xf055b: 0xf055b, + 0xf055c: 0xf055c, + 0xf055d: 0xf055d, + 0xf055e: 0xf055e, + 0xf055f: 0xf055f, + 0xf0560: 0xf0560, + 0xf0561: 0xf0561, + 0xf0562: 0xf0562, + 0xf0563: 0xf0563, + 0xf0564: 0xf0564, + 0xf0565: 0xf0565, + 0xf0566: 0xf0566, + 0xf0567: 0xf0567, + 0xf0568: 0xf0568, + 0xf0569: 0xf0569, + 0xf056a: 0xf056a, + 0xf056b: 0xf056b, + 0xf056c: 0xf056c, + 0xf056d: 0xf056d, + 0xf056e: 0xf056e, + 0xf056f: 0xf056f, + 0xf0570: 0xf0570, + 0xf0571: 0xf0571, + 0xf0572: 0xf0572, + 0xf0573: 0xf0573, + 0xf0574: 0xf0574, + 0xf0575: 0xf0575, + 0xf0576: 0xf0576, + 0xf0577: 0xf0577, + 0xf0578: 0xf0578, + 0xf0579: 0xf0579, + 0xf057a: 0xf057a, + 0xf057b: 0xf057b, + 0xf057c: 0xf057c, + 0xf057d: 0xf057d, + 0xf057e: 0xf057e, + 0xf057f: 0xf057f, + 0xf0580: 0xf0580, + 0xf0581: 0xf0581, + 0xf0582: 0xf0582, + 0xf0583: 0xf0583, + 0xf0584: 0xf0584, + 0xf0585: 0xf0585, + 0xf0586: 0xf0586, + 0xf0587: 0xf0587, + 0xf0588: 0xf0588, + 0xf0589: 0xf0589, + 0xf058a: 0xf058a, + 0xf058b: 0xf058b, + 0xf058c: 0xf058c, + 0xf058d: 0xf058d, + 0xf058e: 0xf058e, + 0xf058f: 0xf058f, + 0xf0590: 0xf0590, + 0xf0591: 0xf0591, + 0xf0592: 0xf0592, + 0xf0593: 0xf0593, + 0xf0594: 0xf0594, + 0xf0595: 0xf0595, + 0xf0596: 0xf0596, + 0xf0597: 0xf0597, + 0xf0598: 0xf0598, + 0xf0599: 0xf0599, + 0xf059a: 0xf059a, + 0xf059b: 0xf059b, + 0xf059c: 0xf059c, + 0xf059d: 0xf059d, + 0xf059e: 0xf059e, + 0xf059f: 0xf059f, + 0xf05a0: 0xf05a0, + 0xf05a1: 0xf05a1, + 0xf05a2: 0xf05a2, + 0xf05a3: 0xf05a3, + 0xf05a4: 0xf05a4, + 0xf05a5: 0xf05a5, + 0xf05a6: 0xf05a6, + 0xf05a7: 0xf05a7, + 0xf05a8: 0xf05a8, + 0xf05a9: 0xf05a9, + 0xf05aa: 0xf05aa, + 0xf05ab: 0xf05ab, + 0xf05ac: 0xf05ac, + 0xf05ad: 0xf05ad, + 0xf05ae: 0xf05ae, + 0xf05af: 0xf05af, + 0xf05b0: 0xf05b0, + 0xf05b1: 0xf05b1, + 0xf05b2: 0xf05b2, + 0xf05b3: 0xf05b3, + 0xf05b4: 0xf05b4, + 0xf05b5: 0xf05b5, + 0xf05b6: 0xf05b6, + 0xf05b7: 0xf05b7, + 0xf05b8: 0xf05b8, + 0xf05b9: 0xf05b9, + 0xf05ba: 0xf05ba, + 0xf05bb: 0xf05bb, + 0xf05bc: 0xf05bc, + 0xf05bd: 0xf05bd, + 0xf05be: 0xf05be, + 0xf05bf: 0xf05bf, + 0xf05c0: 0xf05c0, + 0xf05c1: 0xf05c1, + 0xf05c2: 0xf05c2, + 0xf05c3: 0xf05c3, + 0xf05c4: 0xf05c4, + 0xf05c5: 0xf05c5, + 0xf05c6: 0xf05c6, + 0xf05c7: 0xf05c7, + 0xf05c8: 0xf05c8, + 0xf05c9: 0xf05c9, + 0xf05ca: 0xf05ca, + 0xf05cb: 0xf05cb, + 0xf05cc: 0xf05cc, + 0xf05cd: 0xf05cd, + 0xf05ce: 0xf05ce, + 0xf05cf: 0xf05cf, + 0xf05d0: 0xf05d0, + 0xf05d1: 0xf05d1, + 0xf05d2: 0xf05d2, + 0xf05d3: 0xf05d3, + 0xf05d4: 0xf05d4, + 0xf05d5: 0xf05d5, + 0xf05d6: 0xf05d6, + 0xf05d7: 0xf05d7, + 0xf05d8: 0xf05d8, + 0xf05d9: 0xf05d9, + 0xf05da: 0xf05da, + 0xf05db: 0xf05db, + 0xf05dc: 0xf05dc, + 0xf05dd: 0xf05dd, + 0xf05de: 0xf05de, + 0xf05df: 0xf05df, + 0xf05e0: 0xf05e0, + 0xf05e1: 0xf05e1, + 0xf05e2: 0xf05e2, + 0xf05e3: 0xf05e3, + 0xf05e4: 0xf05e4, + 0xf05e5: 0xf05e5, + 0xf05e6: 0xf05e6, + 0xf05e7: 0xf05e7, + 0xf05e8: 0xf05e8, + 0xf05e9: 0xf05e9, + 0xf05ea: 0xf05ea, + 0xf05eb: 0xf05eb, + 0xf05ec: 0xf05ec, + 0xf05ed: 0xf05ed, + 0xf05ee: 0xf05ee, + 0xf05ef: 0xf05ef, + 0xf05f0: 0xf05f0, + 0xf05f1: 0xf05f1, + 0xf05f2: 0xf05f2, + 0xf05f3: 0xf05f3, + 0xf05f4: 0xf05f4, + 0xf05f5: 0xf05f5, + 0xf05f6: 0xf05f6, + 0xf05f7: 0xf05f7, + 0xf05f8: 0xf05f8, + 0xf05f9: 0xf05f9, + 0xf05fa: 0xf05fa, + 0xf05fb: 0xf05fb, + 0xf05fc: 0xf05fc, + 0xf05fd: 0xf05fd, + 0xf05fe: 0xf05fe, + 0xf05ff: 0xf05ff, + 0xf0600: 0xf0600, + 0xf0601: 0xf0601, + 0xf0602: 0xf0602, + 0xf0603: 0xf0603, + 0xf0604: 0xf0604, + 0xf0605: 0xf0605, + 0xf0606: 0xf0606, + 0xf0607: 0xf0607, + 0xf0608: 0xf0608, + 0xf0609: 0xf0609, + 0xf060a: 0xf060a, + 0xf060b: 0xf060b, + 0xf060c: 0xf060c, + 0xf060d: 0xf060d, + 0xf060e: 0xf060e, + 0xf060f: 0xf060f, + 0xf0610: 0xf0610, + 0xf0611: 0xf0611, + 0xf0612: 0xf0612, + 0xf0613: 0xf0613, + 0xf0614: 0xf0614, + 0xf0615: 0xf0615, + 0xf0616: 0xf0616, + 0xf0617: 0xf0617, + 0xf0618: 0xf0618, + 0xf0619: 0xf0619, + 0xf061a: 0xf061a, + 0xf061b: 0xf061b, + 0xf061c: 0xf061c, + 0xf061d: 0xf061d, + 0xf061e: 0xf061e, + 0xf061f: 0xf061f, + 0xf0620: 0xf0620, + 0xf0621: 0xf0621, + 0xf0622: 0xf0622, + 0xf0623: 0xf0623, + 0xf0624: 0xf0624, + 0xf0625: 0xf0625, + 0xf0626: 0xf0626, + 0xf0627: 0xf0627, + 0xf0628: 0xf0628, + 0xf0629: 0xf0629, + 0xf062a: 0xf062a, + 0xf062b: 0xf062b, + 0xf062c: 0xf062c, + 0xf062d: 0xf062d, + 0xf062e: 0xf062e, + 0xf062f: 0xf062f, + 0xf0630: 0xf0630, + 0xf0631: 0xf0631, + 0xf0632: 0xf0632, + 0xf0633: 0xf0633, + 0xf0634: 0xf0634, + 0xf0635: 0xf0635, + 0xf0636: 0xf0636, + 0xf0637: 0xf0637, + 0xf0638: 0xf0638, + 0xf0639: 0xf0639, + 0xf063a: 0xf063a, + 0xf063b: 0xf063b, + 0xf063c: 0xf063c, + 0xf063d: 0xf063d, + 0xf063e: 0xf063e, + 0xf063f: 0xf063f, + 0xf0640: 0xf0640, + 0xf0641: 0xf0641, + 0xf0642: 0xf0642, + 0xf0643: 0xf0643, + 0xf0644: 0xf0644, + 0xf0645: 0xf0645, + 0xf0646: 0xf0646, + 0xf0647: 0xf0647, + 0xf0648: 0xf0648, + 0xf0649: 0xf0649, + 0xf064a: 0xf064a, + 0xf064b: 0xf064b, + 0xf064c: 0xf064c, + 0xf064d: 0xf064d, + 0xf064e: 0xf064e, + 0xf064f: 0xf064f, + 0xf0650: 0xf0650, + 0xf0651: 0xf0651, + 0xf0652: 0xf0652, + 0xf0653: 0xf0653, + 0xf0654: 0xf0654, + 0xf0655: 0xf0655, + 0xf0656: 0xf0656, + 0xf0657: 0xf0657, + 0xf0658: 0xf0658, + 0xf0659: 0xf0659, + 0xf065a: 0xf065a, + 0xf065b: 0xf065b, + 0xf065c: 0xf065c, + 0xf065d: 0xf065d, + 0xf065e: 0xf065e, + 0xf065f: 0xf065f, + 0xf0660: 0xf0660, + 0xf0661: 0xf0661, + 0xf0662: 0xf0662, + 0xf0663: 0xf0663, + 0xf0664: 0xf0664, + 0xf0665: 0xf0665, + 0xf0666: 0xf0666, + 0xf0667: 0xf0667, + 0xf0668: 0xf0668, + 0xf0669: 0xf0669, + 0xf066a: 0xf066a, + 0xf066b: 0xf066b, + 0xf066c: 0xf066c, + 0xf066d: 0xf066d, + 0xf066e: 0xf066e, + 0xf066f: 0xf066f, + 0xf0670: 0xf0670, + 0xf0671: 0xf0671, + 0xf0672: 0xf0672, + 0xf0673: 0xf0673, + 0xf0674: 0xf0674, + 0xf0675: 0xf0675, + 0xf0676: 0xf0676, + 0xf0677: 0xf0677, + 0xf0678: 0xf0678, + 0xf0679: 0xf0679, + 0xf067a: 0xf067a, + 0xf067b: 0xf067b, + 0xf067c: 0xf067c, + 0xf067d: 0xf067d, + 0xf067e: 0xf067e, + 0xf067f: 0xf067f, + 0xf0680: 0xf0680, + 0xf0681: 0xf0681, + 0xf0682: 0xf0682, + 0xf0683: 0xf0683, + 0xf0684: 0xf0684, + 0xf0685: 0xf0685, + 0xf0686: 0xf0686, + 0xf0687: 0xf0687, + 0xf0688: 0xf0688, + 0xf0689: 0xf0689, + 0xf068a: 0xf068a, + 0xf068b: 0xf068b, + 0xf068c: 0xf068c, + 0xf068d: 0xf068d, + 0xf068e: 0xf068e, + 0xf068f: 0xf068f, + 0xf0690: 0xf0690, + 0xf0691: 0xf0691, + 0xf0692: 0xf0692, + 0xf0693: 0xf0693, + 0xf0694: 0xf0694, + 0xf0695: 0xf0695, + 0xf0696: 0xf0696, + 0xf0697: 0xf0697, + 0xf0698: 0xf0698, + 0xf0699: 0xf0699, + 0xf069a: 0xf069a, + 0xf069b: 0xf069b, + 0xf069c: 0xf069c, + 0xf069d: 0xf069d, + 0xf069e: 0xf069e, + 0xf069f: 0xf069f, + 0xf06a0: 0xf06a0, + 0xf06a1: 0xf06a1, + 0xf06a2: 0xf06a2, + 0xf06a3: 0xf06a3, + 0xf06a4: 0xf06a4, + 0xf06a5: 0xf06a5, + 0xf06a6: 0xf06a6, + 0xf06a7: 0xf06a7, + 0xf06a8: 0xf06a8, + 0xf06a9: 0xf06a9, + 0xf06aa: 0xf06aa, + 0xf06ab: 0xf06ab, + 0xf06ac: 0xf06ac, + 0xf06ad: 0xf06ad, + 0xf06ae: 0xf06ae, + 0xf06af: 0xf06af, + 0xf06b0: 0xf06b0, + 0xf06b1: 0xf06b1, + 0xf06b2: 0xf06b2, + 0xf06b3: 0xf06b3, + 0xf06b4: 0xf06b4, + 0xf06b5: 0xf06b5, + 0xf06b6: 0xf06b6, + 0xf06b7: 0xf06b7, + 0xf06b8: 0xf06b8, + 0xf06b9: 0xf06b9, + 0xf06ba: 0xf06ba, + 0xf06bb: 0xf06bb, + 0xf06bc: 0xf06bc, + 0xf06bd: 0xf06bd, + 0xf06be: 0xf06be, + 0xf06bf: 0xf06bf, + 0xf06c0: 0xf06c0, + 0xf06c1: 0xf06c1, + 0xf06c2: 0xf06c2, + 0xf06c3: 0xf06c3, + 0xf06c4: 0xf06c4, + 0xf06c5: 0xf06c5, + 0xf06c6: 0xf06c6, + 0xf06c7: 0xf06c7, + 0xf06c8: 0xf06c8, + 0xf06c9: 0xf06c9, + 0xf06ca: 0xf06ca, + 0xf06cb: 0xf06cb, + 0xf06cc: 0xf06cc, + 0xf06cd: 0xf06cd, + 0xf06ce: 0xf06ce, + 0xf06cf: 0xf06cf, + 0xf06d0: 0xf06d0, + 0xf06d1: 0xf06d1, + 0xf06d2: 0xf06d2, + 0xf06d3: 0xf06d3, + 0xf06d4: 0xf06d4, + 0xf06d5: 0xf06d5, + 0xf06d6: 0xf06d6, + 0xf06d7: 0xf06d7, + 0xf06d8: 0xf06d8, + 0xf06d9: 0xf06d9, + 0xf06da: 0xf06da, + 0xf06db: 0xf06db, + 0xf06dc: 0xf06dc, + 0xf06dd: 0xf06dd, + 0xf06de: 0xf06de, + 0xf06df: 0xf06df, + 0xf06e0: 0xf06e0, + 0xf06e1: 0xf06e1, + 0xf06e2: 0xf06e2, + 0xf06e3: 0xf06e3, + 0xf06e4: 0xf06e4, + 0xf06e5: 0xf06e5, + 0xf06e6: 0xf06e6, + 0xf06e7: 0xf06e7, + 0xf06e8: 0xf06e8, + 0xf06e9: 0xf06e9, + 0xf06ea: 0xf06ea, + 0xf06eb: 0xf06eb, + 0xf06ec: 0xf06ec, + 0xf06ed: 0xf06ed, + 0xf06ee: 0xf06ee, + 0xf06ef: 0xf06ef, + 0xf06f0: 0xf06f0, + 0xf06f1: 0xf06f1, + 0xf06f2: 0xf06f2, + 0xf06f3: 0xf06f3, + 0xf06f4: 0xf06f4, + 0xf06f5: 0xf06f5, + 0xf06f6: 0xf06f6, + 0xf06f7: 0xf06f7, + 0xf06f8: 0xf06f8, + 0xf06f9: 0xf06f9, + 0xf06fa: 0xf06fa, + 0xf06fb: 0xf06fb, + 0xf06fc: 0xf06fc, + 0xf06fd: 0xf06fd, + 0xf06fe: 0xf06fe, + 0xf06ff: 0xf06ff, + 0xf0700: 0xf0700, + 0xf0701: 0xf0701, + 0xf0702: 0xf0702, + 0xf0703: 0xf0703, + 0xf0704: 0xf0704, + 0xf0705: 0xf0705, + 0xf0706: 0xf0706, + 0xf0707: 0xf0707, + 0xf0708: 0xf0708, + 0xf0709: 0xf0709, + 0xf070a: 0xf070a, + 0xf070b: 0xf070b, + 0xf070c: 0xf070c, + 0xf070d: 0xf070d, + 0xf070e: 0xf070e, + 0xf070f: 0xf070f, + 0xf0710: 0xf0710, + 0xf0711: 0xf0711, + 0xf0712: 0xf0712, + 0xf0713: 0xf0713, + 0xf0714: 0xf0714, + 0xf0715: 0xf0715, + 0xf0716: 0xf0716, + 0xf0717: 0xf0717, + 0xf0718: 0xf0718, + 0xf0719: 0xf0719, + 0xf071a: 0xf071a, + 0xf071b: 0xf071b, + 0xf071c: 0xf071c, + 0xf071d: 0xf071d, + 0xf071e: 0xf071e, + 0xf071f: 0xf071f, + 0xf0720: 0xf0720, + 0xf0721: 0xf0721, + 0xf0722: 0xf0722, + 0xf0723: 0xf0723, + 0xf0724: 0xf0724, + 0xf0725: 0xf0725, + 0xf0726: 0xf0726, + 0xf0727: 0xf0727, + 0xf0728: 0xf0728, + 0xf0729: 0xf0729, + 0xf072a: 0xf072a, + 0xf072b: 0xf072b, + 0xf072c: 0xf072c, + 0xf072d: 0xf072d, + 0xf072e: 0xf072e, + 0xf072f: 0xf072f, + 0xf0730: 0xf0730, + 0xf0731: 0xf0731, + 0xf0732: 0xf0732, + 0xf0733: 0xf0733, + 0xf0734: 0xf0734, + 0xf0735: 0xf0735, + 0xf0736: 0xf0736, + 0xf0737: 0xf0737, + 0xf0738: 0xf0738, + 0xf0739: 0xf0739, + 0xf073a: 0xf073a, + 0xf073b: 0xf073b, + 0xf073c: 0xf073c, + 0xf073d: 0xf073d, + 0xf073e: 0xf073e, + 0xf073f: 0xf073f, + 0xf0740: 0xf0740, + 0xf0741: 0xf0741, + 0xf0742: 0xf0742, + 0xf0743: 0xf0743, + 0xf0744: 0xf0744, + 0xf0745: 0xf0745, + 0xf0746: 0xf0746, + 0xf0747: 0xf0747, + 0xf0748: 0xf0748, + 0xf0749: 0xf0749, + 0xf074a: 0xf074a, + 0xf074b: 0xf074b, + 0xf074c: 0xf074c, + 0xf074d: 0xf074d, + 0xf074e: 0xf074e, + 0xf074f: 0xf074f, + 0xf0750: 0xf0750, + 0xf0751: 0xf0751, + 0xf0752: 0xf0752, + 0xf0753: 0xf0753, + 0xf0754: 0xf0754, + 0xf0755: 0xf0755, + 0xf0756: 0xf0756, + 0xf0757: 0xf0757, + 0xf0758: 0xf0758, + 0xf0759: 0xf0759, + 0xf075a: 0xf075a, + 0xf075b: 0xf075b, + 0xf075c: 0xf075c, + 0xf075d: 0xf075d, + 0xf075e: 0xf075e, + 0xf075f: 0xf075f, + 0xf0760: 0xf0760, + 0xf0761: 0xf0761, + 0xf0762: 0xf0762, + 0xf0763: 0xf0763, + 0xf0764: 0xf0764, + 0xf0765: 0xf0765, + 0xf0766: 0xf0766, + 0xf0767: 0xf0767, + 0xf0768: 0xf0768, + 0xf0769: 0xf0769, + 0xf076a: 0xf076a, + 0xf076b: 0xf076b, + 0xf076c: 0xf076c, + 0xf076d: 0xf076d, + 0xf076e: 0xf076e, + 0xf076f: 0xf076f, + 0xf0770: 0xf0770, + 0xf0771: 0xf0771, + 0xf0772: 0xf0772, + 0xf0773: 0xf0773, + 0xf0774: 0xf0774, + 0xf0775: 0xf0775, + 0xf0776: 0xf0776, + 0xf0777: 0xf0777, + 0xf0778: 0xf0778, + 0xf0779: 0xf0779, + 0xf077a: 0xf077a, + 0xf077b: 0xf077b, + 0xf077c: 0xf077c, + 0xf077d: 0xf077d, + 0xf077e: 0xf077e, + 0xf077f: 0xf077f, + 0xf0780: 0xf0780, + 0xf0781: 0xf0781, + 0xf0782: 0xf0782, + 0xf0783: 0xf0783, + 0xf0784: 0xf0784, + 0xf0785: 0xf0785, + 0xf0786: 0xf0786, + 0xf0787: 0xf0787, + 0xf0788: 0xf0788, + 0xf0789: 0xf0789, + 0xf078a: 0xf078a, + 0xf078b: 0xf078b, + 0xf078c: 0xf078c, + 0xf078d: 0xf078d, + 0xf078e: 0xf078e, + 0xf078f: 0xf078f, + 0xf0790: 0xf0790, + 0xf0791: 0xf0791, + 0xf0792: 0xf0792, + 0xf0793: 0xf0793, + 0xf0794: 0xf0794, + 0xf0795: 0xf0795, + 0xf0796: 0xf0796, + 0xf0797: 0xf0797, + 0xf0798: 0xf0798, + 0xf0799: 0xf0799, + 0xf079a: 0xf079a, + 0xf079b: 0xf079b, + 0xf079c: 0xf079c, + 0xf079d: 0xf079d, + 0xf079e: 0xf079e, + 0xf079f: 0xf079f, + 0xf07a0: 0xf07a0, + 0xf07a1: 0xf07a1, + 0xf07a2: 0xf07a2, + 0xf07a3: 0xf07a3, + 0xf07a4: 0xf07a4, + 0xf07a5: 0xf07a5, + 0xf07a6: 0xf07a6, + 0xf07a7: 0xf07a7, + 0xf07a8: 0xf07a8, + 0xf07a9: 0xf07a9, + 0xf07aa: 0xf07aa, + 0xf07ab: 0xf07ab, + 0xf07ac: 0xf07ac, + 0xf07ad: 0xf07ad, + 0xf07ae: 0xf07ae, + 0xf07af: 0xf07af, + 0xf07b0: 0xf07b0, + 0xf07b1: 0xf07b1, + 0xf07b2: 0xf07b2, + 0xf07b3: 0xf07b3, + 0xf07b4: 0xf07b4, + 0xf07b5: 0xf07b5, + 0xf07b6: 0xf07b6, + 0xf07b7: 0xf07b7, + 0xf07b8: 0xf07b8, + 0xf07b9: 0xf07b9, + 0xf07ba: 0xf07ba, + 0xf07bb: 0xf07bb, + 0xf07bc: 0xf07bc, + 0xf07bd: 0xf07bd, + 0xf07be: 0xf07be, + 0xf07bf: 0xf07bf, + 0xf07c0: 0xf07c0, + 0xf07c1: 0xf07c1, + 0xf07c2: 0xf07c2, + 0xf07c3: 0xf07c3, + 0xf07c4: 0xf07c4, + 0xf07c5: 0xf07c5, + 0xf07c6: 0xf07c6, + 0xf07c7: 0xf07c7, + 0xf07c8: 0xf07c8, + 0xf07c9: 0xf07c9, + 0xf07ca: 0xf07ca, + 0xf07cb: 0xf07cb, + 0xf07cc: 0xf07cc, + 0xf07cd: 0xf07cd, + 0xf07ce: 0xf07ce, + 0xf07cf: 0xf07cf, + 0xf07d0: 0xf07d0, + 0xf07d1: 0xf07d1, + 0xf07d2: 0xf07d2, + 0xf07d3: 0xf07d3, + 0xf07d4: 0xf07d4, + 0xf07d5: 0xf07d5, + 0xf07d6: 0xf07d6, + 0xf07d7: 0xf07d7, + 0xf07d8: 0xf07d8, + 0xf07d9: 0xf07d9, + 0xf07da: 0xf07da, + 0xf07db: 0xf07db, + 0xf07dc: 0xf07dc, + 0xf07dd: 0xf07dd, + 0xf07de: 0xf07de, + 0xf07df: 0xf07df, + 0xf07e0: 0xf07e0, + 0xf07e1: 0xf07e1, + 0xf07e2: 0xf07e2, + 0xf07e3: 0xf07e3, + 0xf07e4: 0xf07e4, + 0xf07e5: 0xf07e5, + 0xf07e6: 0xf07e6, + 0xf07e7: 0xf07e7, + 0xf07e8: 0xf07e8, + 0xf07e9: 0xf07e9, + 0xf07ea: 0xf07ea, + 0xf07eb: 0xf07eb, + 0xf07ec: 0xf07ec, + 0xf07ed: 0xf07ed, + 0xf07ee: 0xf07ee, + 0xf07ef: 0xf07ef, + 0xf07f0: 0xf07f0, + 0xf07f1: 0xf07f1, + 0xf07f2: 0xf07f2, + 0xf07f3: 0xf07f3, + 0xf07f4: 0xf07f4, + 0xf07f5: 0xf07f5, + 0xf07f6: 0xf07f6, + 0xf07f7: 0xf07f7, + 0xf07f8: 0xf07f8, + 0xf07f9: 0xf07f9, + 0xf07fa: 0xf07fa, + 0xf07fb: 0xf07fb, + 0xf07fc: 0xf07fc, + 0xf07fd: 0xf07fd, + 0xf07fe: 0xf07fe, + 0xf07ff: 0xf07ff, + 0xf0800: 0xf0800, + 0xf0801: 0xf0801, + 0xf0802: 0xf0802, + 0xf0803: 0xf0803, + 0xf0804: 0xf0804, + 0xf0805: 0xf0805, + 0xf0806: 0xf0806, + 0xf0807: 0xf0807, + 0xf0808: 0xf0808, + 0xf0809: 0xf0809, + 0xf080a: 0xf080a, + 0xf080b: 0xf080b, + 0xf080c: 0xf080c, + 0xf080d: 0xf080d, + 0xf080e: 0xf080e, + 0xf080f: 0xf080f, + 0xf0810: 0xf0810, + 0xf0811: 0xf0811, + 0xf0812: 0xf0812, + 0xf0813: 0xf0813, + 0xf0814: 0xf0814, + 0xf0815: 0xf0815, + 0xf0816: 0xf0816, + 0xf0817: 0xf0817, + 0xf0818: 0xf0818, + 0xf0819: 0xf0819, + 0xf081a: 0xf081a, + 0xf081b: 0xf081b, + 0xf081c: 0xf081c, + 0xf081d: 0xf081d, + 0xf081e: 0xf081e, + 0xf081f: 0xf081f, + 0xf0820: 0xf0820, + 0xf0821: 0xf0821, + 0xf0822: 0xf0822, + 0xf0823: 0xf0823, + 0xf0824: 0xf0824, + 0xf0825: 0xf0825, + 0xf0826: 0xf0826, + 0xf0827: 0xf0827, + 0xf0828: 0xf0828, + 0xf0829: 0xf0829, + 0xf082a: 0xf082a, + 0xf082b: 0xf082b, + 0xf082c: 0xf082c, + 0xf082d: 0xf082d, + 0xf082e: 0xf082e, + 0xf082f: 0xf082f, + 0xf0830: 0xf0830, + 0xf0831: 0xf0831, + 0xf0832: 0xf0832, + 0xf0833: 0xf0833, + 0xf0834: 0xf0834, + 0xf0835: 0xf0835, + 0xf0836: 0xf0836, + 0xf0837: 0xf0837, + 0xf0838: 0xf0838, + 0xf0839: 0xf0839, + 0xf083a: 0xf083a, + 0xf083b: 0xf083b, + 0xf083c: 0xf083c, + 0xf083d: 0xf083d, + 0xf083e: 0xf083e, + 0xf083f: 0xf083f, + 0xf0840: 0xf0840, + 0xf0841: 0xf0841, + 0xf0842: 0xf0842, + 0xf0843: 0xf0843, + 0xf0844: 0xf0844, + 0xf0845: 0xf0845, + 0xf0846: 0xf0846, + 0xf0847: 0xf0847, + 0xf0848: 0xf0848, + 0xf0849: 0xf0849, + 0xf084a: 0xf084a, + 0xf084b: 0xf084b, + 0xf084c: 0xf084c, + 0xf084d: 0xf084d, + 0xf084e: 0xf084e, + 0xf084f: 0xf084f, + 0xf0850: 0xf0850, + 0xf0851: 0xf0851, + 0xf0852: 0xf0852, + 0xf0853: 0xf0853, + 0xf0854: 0xf0854, + 0xf0855: 0xf0855, + 0xf0856: 0xf0856, + 0xf0857: 0xf0857, + 0xf0858: 0xf0858, + 0xf0859: 0xf0859, + 0xf085a: 0xf085a, + 0xf085b: 0xf085b, + 0xf085c: 0xf085c, + 0xf085d: 0xf085d, + 0xf085e: 0xf085e, + 0xf085f: 0xf085f, + 0xf0860: 0xf0860, + 0xf0861: 0xf0861, + 0xf0862: 0xf0862, + 0xf0863: 0xf0863, + 0xf0864: 0xf0864, + 0xf0865: 0xf0865, + 0xf0866: 0xf0866, + 0xf0867: 0xf0867, + 0xf0868: 0xf0868, + 0xf0869: 0xf0869, + 0xf086a: 0xf086a, + 0xf086b: 0xf086b, + 0xf086c: 0xf086c, + 0xf086d: 0xf086d, + 0xf086e: 0xf086e, + 0xf086f: 0xf086f, + 0xf0870: 0xf0870, + 0xf0871: 0xf0871, + 0xf0872: 0xf0872, + 0xf0873: 0xf0873, + 0xf0874: 0xf0874, + 0xf0875: 0xf0875, + 0xf0876: 0xf0876, + 0xf0877: 0xf0877, + 0xf0878: 0xf0878, + 0xf0879: 0xf0879, + 0xf087a: 0xf087a, + 0xf087b: 0xf087b, + 0xf087c: 0xf087c, + 0xf087d: 0xf087d, + 0xf087e: 0xf087e, + 0xf087f: 0xf087f, + 0xf0880: 0xf0880, + 0xf0881: 0xf0881, + 0xf0882: 0xf0882, + 0xf0883: 0xf0883, + 0xf0884: 0xf0884, + 0xf0885: 0xf0885, + 0xf0886: 0xf0886, + 0xf0887: 0xf0887, + 0xf0888: 0xf0888, + 0xf0889: 0xf0889, + 0xf088a: 0xf088a, + 0xf088b: 0xf088b, + 0xf088c: 0xf088c, + 0xf088d: 0xf088d, + 0xf088e: 0xf088e, + 0xf088f: 0xf088f, + 0xf0890: 0xf0890, + 0xf0891: 0xf0891, + 0xf0892: 0xf0892, + 0xf0893: 0xf0893, + 0xf0894: 0xf0894, + 0xf0895: 0xf0895, + 0xf0896: 0xf0896, + 0xf0897: 0xf0897, + 0xf0898: 0xf0898, + 0xf0899: 0xf0899, + 0xf089a: 0xf089a, + 0xf089b: 0xf089b, + 0xf089c: 0xf089c, + 0xf089d: 0xf089d, + 0xf089e: 0xf089e, + 0xf089f: 0xf089f, + 0xf08a0: 0xf08a0, + 0xf08a1: 0xf08a1, + 0xf08a2: 0xf08a2, + 0xf08a3: 0xf08a3, + 0xf08a4: 0xf08a4, + 0xf08a5: 0xf08a5, + 0xf08a6: 0xf08a6, + 0xf08a7: 0xf08a7, + 0xf08a8: 0xf08a8, + 0xf08a9: 0xf08a9, + 0xf08aa: 0xf08aa, + 0xf08ab: 0xf08ab, + 0xf08ac: 0xf08ac, + 0xf08ad: 0xf08ad, + 0xf08ae: 0xf08ae, + 0xf08af: 0xf08af, + 0xf08b0: 0xf08b0, + 0xf08b1: 0xf08b1, + 0xf08b2: 0xf08b2, + 0xf08b3: 0xf08b3, + 0xf08b4: 0xf08b4, + 0xf08b5: 0xf08b5, + 0xf08b6: 0xf08b6, + 0xf08b7: 0xf08b7, + 0xf08b8: 0xf08b8, + 0xf08b9: 0xf08b9, + 0xf08ba: 0xf08ba, + 0xf08bb: 0xf08bb, + 0xf08bc: 0xf08bc, + 0xf08bd: 0xf08bd, + 0xf08be: 0xf08be, + 0xf08bf: 0xf08bf, + 0xf08c0: 0xf08c0, + 0xf08c1: 0xf08c1, + 0xf08c2: 0xf08c2, + 0xf08c3: 0xf08c3, + 0xf08c4: 0xf08c4, + 0xf08c5: 0xf08c5, + 0xf08c6: 0xf08c6, + 0xf08c7: 0xf08c7, + 0xf08c8: 0xf08c8, + 0xf08c9: 0xf08c9, + 0xf08ca: 0xf08ca, + 0xf08cb: 0xf08cb, + 0xf08cc: 0xf08cc, + 0xf08cd: 0xf08cd, + 0xf08ce: 0xf08ce, + 0xf08cf: 0xf08cf, + 0xf08d0: 0xf08d0, + 0xf08d1: 0xf08d1, + 0xf08d2: 0xf08d2, + 0xf08d3: 0xf08d3, + 0xf08d4: 0xf08d4, + 0xf08d5: 0xf08d5, + 0xf08d6: 0xf08d6, + 0xf08d7: 0xf08d7, + 0xf08d8: 0xf08d8, + 0xf08d9: 0xf08d9, + 0xf08da: 0xf08da, + 0xf08db: 0xf08db, + 0xf08dc: 0xf08dc, + 0xf08dd: 0xf08dd, + 0xf08de: 0xf08de, + 0xf08df: 0xf08df, + 0xf08e0: 0xf08e0, + 0xf08e1: 0xf08e1, + 0xf08e2: 0xf08e2, + 0xf08e3: 0xf08e3, + 0xf08e4: 0xf08e4, + 0xf08e5: 0xf08e5, + 0xf08e6: 0xf08e6, + 0xf08e7: 0xf08e7, + 0xf08e8: 0xf08e8, + 0xf08e9: 0xf08e9, + 0xf08ea: 0xf08ea, + 0xf08eb: 0xf08eb, + 0xf08ec: 0xf08ec, + 0xf08ed: 0xf08ed, + 0xf08ee: 0xf08ee, + 0xf08ef: 0xf08ef, + 0xf08f0: 0xf08f0, + 0xf08f1: 0xf08f1, + 0xf08f2: 0xf08f2, + 0xf08f3: 0xf08f3, + 0xf08f4: 0xf08f4, + 0xf08f5: 0xf08f5, + 0xf08f6: 0xf08f6, + 0xf08f7: 0xf08f7, + 0xf08f8: 0xf08f8, + 0xf08f9: 0xf08f9, + 0xf08fa: 0xf08fa, + 0xf08fb: 0xf08fb, + 0xf08fc: 0xf08fc, + 0xf08fd: 0xf08fd, + 0xf08fe: 0xf08fe, + 0xf08ff: 0xf08ff, + 0xf0900: 0xf0900, + 0xf0901: 0xf0901, + 0xf0902: 0xf0902, + 0xf0903: 0xf0903, + 0xf0904: 0xf0904, + 0xf0905: 0xf0905, + 0xf0906: 0xf0906, + 0xf0907: 0xf0907, + 0xf0908: 0xf0908, + 0xf0909: 0xf0909, + 0xf090a: 0xf090a, + 0xf090b: 0xf090b, + 0xf090c: 0xf090c, + 0xf090d: 0xf090d, + 0xf090e: 0xf090e, + 0xf090f: 0xf090f, + 0xf0910: 0xf0910, + 0xf0911: 0xf0911, + 0xf0912: 0xf0912, + 0xf0913: 0xf0913, + 0xf0914: 0xf0914, + 0xf0915: 0xf0915, + 0xf0916: 0xf0916, + 0xf0917: 0xf0917, + 0xf0918: 0xf0918, + 0xf0919: 0xf0919, + 0xf091a: 0xf091a, + 0xf091b: 0xf091b, + 0xf091c: 0xf091c, + 0xf091d: 0xf091d, + 0xf091e: 0xf091e, + 0xf091f: 0xf091f, + 0xf0920: 0xf0920, + 0xf0921: 0xf0921, + 0xf0922: 0xf0922, + 0xf0923: 0xf0923, + 0xf0924: 0xf0924, + 0xf0925: 0xf0925, + 0xf0926: 0xf0926, + 0xf0927: 0xf0927, + 0xf0928: 0xf0928, + 0xf0929: 0xf0929, + 0xf092a: 0xf092a, + 0xf092b: 0xf092b, + 0xf092c: 0xf092c, + 0xf092d: 0xf092d, + 0xf092e: 0xf092e, + 0xf092f: 0xf092f, + 0xf0930: 0xf0930, + 0xf0931: 0xf0931, + 0xf0932: 0xf0932, + 0xf0933: 0xf0933, + 0xf0934: 0xf0934, + 0xf0935: 0xf0935, + 0xf0936: 0xf0936, + 0xf0937: 0xf0937, + 0xf0938: 0xf0938, + 0xf0939: 0xf0939, + 0xf093a: 0xf093a, + 0xf093b: 0xf093b, + 0xf093c: 0xf093c, + 0xf093d: 0xf093d, + 0xf093e: 0xf093e, + 0xf093f: 0xf093f, + 0xf0940: 0xf0940, + 0xf0941: 0xf0941, + 0xf0942: 0xf0942, + 0xf0943: 0xf0943, + 0xf0944: 0xf0944, + 0xf0945: 0xf0945, + 0xf0946: 0xf0946, + 0xf0947: 0xf0947, + 0xf0948: 0xf0948, + 0xf0949: 0xf0949, + 0xf094a: 0xf094a, + 0xf094b: 0xf094b, + 0xf094c: 0xf094c, + 0xf094d: 0xf094d, + 0xf094e: 0xf094e, + 0xf094f: 0xf094f, + 0xf0950: 0xf0950, + 0xf0951: 0xf0951, + 0xf0952: 0xf0952, + 0xf0953: 0xf0953, + 0xf0954: 0xf0954, + 0xf0955: 0xf0955, + 0xf0956: 0xf0956, + 0xf0957: 0xf0957, + 0xf0958: 0xf0958, + 0xf0959: 0xf0959, + 0xf095a: 0xf095a, + 0xf095b: 0xf095b, + 0xf095c: 0xf095c, + 0xf095d: 0xf095d, + 0xf095e: 0xf095e, + 0xf095f: 0xf095f, + 0xf0960: 0xf0960, + 0xf0961: 0xf0961, + 0xf0962: 0xf0962, + 0xf0963: 0xf0963, + 0xf0964: 0xf0964, + 0xf0965: 0xf0965, + 0xf0966: 0xf0966, + 0xf0967: 0xf0967, + 0xf0968: 0xf0968, + 0xf0969: 0xf0969, + 0xf096a: 0xf096a, + 0xf096b: 0xf096b, + 0xf096c: 0xf096c, + 0xf096d: 0xf096d, + 0xf096e: 0xf096e, + 0xf096f: 0xf096f, + 0xf0970: 0xf0970, + 0xf0971: 0xf0971, + 0xf0972: 0xf0972, + 0xf0973: 0xf0973, + 0xf0974: 0xf0974, + 0xf0975: 0xf0975, + 0xf0976: 0xf0976, + 0xf0977: 0xf0977, + 0xf0978: 0xf0978, + 0xf0979: 0xf0979, + 0xf097a: 0xf097a, + 0xf097b: 0xf097b, + 0xf097c: 0xf097c, + 0xf097d: 0xf097d, + 0xf097e: 0xf097e, + 0xf097f: 0xf097f, + 0xf0980: 0xf0980, + 0xf0981: 0xf0981, + 0xf0982: 0xf0982, + 0xf0983: 0xf0983, + 0xf0984: 0xf0984, + 0xf0985: 0xf0985, + 0xf0986: 0xf0986, + 0xf0987: 0xf0987, + 0xf0988: 0xf0988, + 0xf0989: 0xf0989, + 0xf098a: 0xf098a, + 0xf098b: 0xf098b, + 0xf098c: 0xf098c, + 0xf098d: 0xf098d, + 0xf098e: 0xf098e, + 0xf098f: 0xf098f, + 0xf0990: 0xf0990, + 0xf0991: 0xf0991, + 0xf0992: 0xf0992, + 0xf0993: 0xf0993, + 0xf0994: 0xf0994, + 0xf0995: 0xf0995, + 0xf0996: 0xf0996, + 0xf0997: 0xf0997, + 0xf0998: 0xf0998, + 0xf0999: 0xf0999, + 0xf099a: 0xf099a, + 0xf099b: 0xf099b, + 0xf099c: 0xf099c, + 0xf099d: 0xf099d, + 0xf099e: 0xf099e, + 0xf099f: 0xf099f, + 0xf09a0: 0xf09a0, + 0xf09a1: 0xf09a1, + 0xf09a2: 0xf09a2, + 0xf09a3: 0xf09a3, + 0xf09a4: 0xf09a4, + 0xf09a5: 0xf09a5, + 0xf09a6: 0xf09a6, + 0xf09a7: 0xf09a7, + 0xf09a8: 0xf09a8, + 0xf09a9: 0xf09a9, + 0xf09aa: 0xf09aa, + 0xf09ab: 0xf09ab, + 0xf09ac: 0xf09ac, + 0xf09ad: 0xf09ad, + 0xf09ae: 0xf09ae, + 0xf09af: 0xf09af, + 0xf09b0: 0xf09b0, + 0xf09b1: 0xf09b1, + 0xf09b2: 0xf09b2, + 0xf09b3: 0xf09b3, + 0xf09b4: 0xf09b4, + 0xf09b5: 0xf09b5, + 0xf09b6: 0xf09b6, + 0xf09b7: 0xf09b7, + 0xf09b8: 0xf09b8, + 0xf09b9: 0xf09b9, + 0xf09ba: 0xf09ba, + 0xf09bb: 0xf09bb, + 0xf09bc: 0xf09bc, + 0xf09bd: 0xf09bd, + 0xf09be: 0xf09be, + 0xf09bf: 0xf09bf, + 0xf09c0: 0xf09c0, + 0xf09c1: 0xf09c1, + 0xf09c2: 0xf09c2, + 0xf09c3: 0xf09c3, + 0xf09c4: 0xf09c4, + 0xf09c5: 0xf09c5, + 0xf09c6: 0xf09c6, + 0xf09c7: 0xf09c7, + 0xf09c8: 0xf09c8, + 0xf09c9: 0xf09c9, + 0xf09ca: 0xf09ca, + 0xf09cb: 0xf09cb, + 0xf09cc: 0xf09cc, + 0xf09cd: 0xf09cd, + 0xf09ce: 0xf09ce, + 0xf09cf: 0xf09cf, + 0xf09d0: 0xf09d0, + 0xf09d1: 0xf09d1, + 0xf09d2: 0xf09d2, + 0xf09d3: 0xf09d3, + 0xf09d4: 0xf09d4, + 0xf09d5: 0xf09d5, + 0xf09d6: 0xf09d6, + 0xf09d7: 0xf09d7, + 0xf09d8: 0xf09d8, + 0xf09d9: 0xf09d9, + 0xf09da: 0xf09da, + 0xf09db: 0xf09db, + 0xf09dc: 0xf09dc, + 0xf09dd: 0xf09dd, + 0xf09de: 0xf09de, + 0xf09df: 0xf09df, + 0xf09e0: 0xf09e0, + 0xf09e1: 0xf09e1, + 0xf09e2: 0xf09e2, + 0xf09e3: 0xf09e3, + 0xf09e4: 0xf09e4, + 0xf09e5: 0xf09e5, + 0xf09e6: 0xf09e6, + 0xf09e7: 0xf09e7, + 0xf09e8: 0xf09e8, + 0xf09e9: 0xf09e9, + 0xf09ea: 0xf09ea, + 0xf09eb: 0xf09eb, + 0xf09ec: 0xf09ec, + 0xf09ed: 0xf09ed, + 0xf09ee: 0xf09ee, + 0xf09ef: 0xf09ef, + 0xf09f0: 0xf09f0, + 0xf09f1: 0xf09f1, + 0xf09f2: 0xf09f2, + 0xf09f3: 0xf09f3, + 0xf09f4: 0xf09f4, + 0xf09f5: 0xf09f5, + 0xf09f6: 0xf09f6, + 0xf09f7: 0xf09f7, + 0xf09f8: 0xf09f8, + 0xf09f9: 0xf09f9, + 0xf09fa: 0xf09fa, + 0xf09fb: 0xf09fb, + 0xf09fc: 0xf09fc, + 0xf09fd: 0xf09fd, + 0xf09fe: 0xf09fe, + 0xf09ff: 0xf09ff, + 0xf0a00: 0xf0a00, + 0xf0a01: 0xf0a01, + 0xf0a02: 0xf0a02, + 0xf0a03: 0xf0a03, + 0xf0a04: 0xf0a04, + 0xf0a05: 0xf0a05, + 0xf0a06: 0xf0a06, + 0xf0a07: 0xf0a07, + 0xf0a08: 0xf0a08, + 0xf0a09: 0xf0a09, + 0xf0a0a: 0xf0a0a, + 0xf0a0b: 0xf0a0b, + 0xf0a0c: 0xf0a0c, + 0xf0a0d: 0xf0a0d, + 0xf0a0e: 0xf0a0e, + 0xf0a0f: 0xf0a0f, + 0xf0a10: 0xf0a10, + 0xf0a11: 0xf0a11, + 0xf0a12: 0xf0a12, + 0xf0a13: 0xf0a13, + 0xf0a14: 0xf0a14, + 0xf0a15: 0xf0a15, + 0xf0a16: 0xf0a16, + 0xf0a17: 0xf0a17, + 0xf0a18: 0xf0a18, + 0xf0a19: 0xf0a19, + 0xf0a1a: 0xf0a1a, + 0xf0a1b: 0xf0a1b, + 0xf0a1c: 0xf0a1c, + 0xf0a1d: 0xf0a1d, + 0xf0a1e: 0xf0a1e, + 0xf0a1f: 0xf0a1f, + 0xf0a20: 0xf0a20, + 0xf0a21: 0xf0a21, + 0xf0a22: 0xf0a22, + 0xf0a23: 0xf0a23, + 0xf0a24: 0xf0a24, + 0xf0a25: 0xf0a25, + 0xf0a26: 0xf0a26, + 0xf0a27: 0xf0a27, + 0xf0a28: 0xf0a28, + 0xf0a29: 0xf0a29, + 0xf0a2a: 0xf0a2a, + 0xf0a2b: 0xf0a2b, + 0xf0a2c: 0xf0a2c, + 0xf0a2d: 0xf0a2d, + 0xf0a2e: 0xf0a2e, + 0xf0a2f: 0xf0a2f, + 0xf0a30: 0xf0a30, + 0xf0a31: 0xf0a31, + 0xf0a32: 0xf0a32, + 0xf0a33: 0xf0a33, + 0xf0a34: 0xf0a34, + 0xf0a35: 0xf0a35, + 0xf0a36: 0xf0a36, + 0xf0a37: 0xf0a37, + 0xf0a38: 0xf0a38, + 0xf0a39: 0xf0a39, + 0xf0a3a: 0xf0a3a, + 0xf0a3b: 0xf0a3b, + 0xf0a3c: 0xf0a3c, + 0xf0a3d: 0xf0a3d, + 0xf0a3e: 0xf0a3e, + 0xf0a3f: 0xf0a3f, + 0xf0a40: 0xf0a40, + 0xf0a41: 0xf0a41, + 0xf0a42: 0xf0a42, + 0xf0a43: 0xf0a43, + 0xf0a44: 0xf0a44, + 0xf0a45: 0xf0a45, + 0xf0a46: 0xf0a46, + 0xf0a47: 0xf0a47, + 0xf0a48: 0xf0a48, + 0xf0a49: 0xf0a49, + 0xf0a4a: 0xf0a4a, + 0xf0a4b: 0xf0a4b, + 0xf0a4c: 0xf0a4c, + 0xf0a4d: 0xf0a4d, + 0xf0a4e: 0xf0a4e, + 0xf0a4f: 0xf0a4f, + 0xf0a50: 0xf0a50, + 0xf0a51: 0xf0a51, + 0xf0a52: 0xf0a52, + 0xf0a53: 0xf0a53, + 0xf0a54: 0xf0a54, + 0xf0a55: 0xf0a55, + 0xf0a56: 0xf0a56, + 0xf0a57: 0xf0a57, + 0xf0a58: 0xf0a58, + 0xf0a59: 0xf0a59, + 0xf0a5a: 0xf0a5a, + 0xf0a5b: 0xf0a5b, + 0xf0a5c: 0xf0a5c, + 0xf0a5d: 0xf0a5d, + 0xf0a5e: 0xf0a5e, + 0xf0a5f: 0xf0a5f, + 0xf0a60: 0xf0a60, + 0xf0a61: 0xf0a61, + 0xf0a62: 0xf0a62, + 0xf0a63: 0xf0a63, + 0xf0a64: 0xf0a64, + 0xf0a65: 0xf0a65, + 0xf0a66: 0xf0a66, + 0xf0a67: 0xf0a67, + 0xf0a68: 0xf0a68, + 0xf0a69: 0xf0a69, + 0xf0a6a: 0xf0a6a, + 0xf0a6b: 0xf0a6b, + 0xf0a6c: 0xf0a6c, + 0xf0a6d: 0xf0a6d, + 0xf0a6e: 0xf0a6e, + 0xf0a6f: 0xf0a6f, + 0xf0a70: 0xf0a70, + 0xf0a71: 0xf0a71, + 0xf0a72: 0xf0a72, + 0xf0a73: 0xf0a73, + 0xf0a74: 0xf0a74, + 0xf0a75: 0xf0a75, + 0xf0a76: 0xf0a76, + 0xf0a77: 0xf0a77, + 0xf0a78: 0xf0a78, + 0xf0a79: 0xf0a79, + 0xf0a7a: 0xf0a7a, + 0xf0a7b: 0xf0a7b, + 0xf0a7c: 0xf0a7c, + 0xf0a7d: 0xf0a7d, + 0xf0a7e: 0xf0a7e, + 0xf0a7f: 0xf0a7f, + 0xf0a80: 0xf0a80, + 0xf0a81: 0xf0a81, + 0xf0a82: 0xf0a82, + 0xf0a83: 0xf0a83, + 0xf0a84: 0xf0a84, + 0xf0a85: 0xf0a85, + 0xf0a86: 0xf0a86, + 0xf0a87: 0xf0a87, + 0xf0a88: 0xf0a88, + 0xf0a89: 0xf0a89, + 0xf0a8a: 0xf0a8a, + 0xf0a8b: 0xf0a8b, + 0xf0a8c: 0xf0a8c, + 0xf0a8d: 0xf0a8d, + 0xf0a8e: 0xf0a8e, + 0xf0a8f: 0xf0a8f, + 0xf0a90: 0xf0a90, + 0xf0a91: 0xf0a91, + 0xf0a92: 0xf0a92, + 0xf0a93: 0xf0a93, + 0xf0a94: 0xf0a94, + 0xf0a95: 0xf0a95, + 0xf0a96: 0xf0a96, + 0xf0a97: 0xf0a97, + 0xf0a98: 0xf0a98, + 0xf0a99: 0xf0a99, + 0xf0a9a: 0xf0a9a, + 0xf0a9b: 0xf0a9b, + 0xf0a9c: 0xf0a9c, + 0xf0a9d: 0xf0a9d, + 0xf0a9e: 0xf0a9e, + 0xf0a9f: 0xf0a9f, + 0xf0aa0: 0xf0aa0, + 0xf0aa1: 0xf0aa1, + 0xf0aa2: 0xf0aa2, + 0xf0aa3: 0xf0aa3, + 0xf0aa4: 0xf0aa4, + 0xf0aa5: 0xf0aa5, + 0xf0aa6: 0xf0aa6, + 0xf0aa7: 0xf0aa7, + 0xf0aa8: 0xf0aa8, + 0xf0aa9: 0xf0aa9, + 0xf0aaa: 0xf0aaa, + 0xf0aab: 0xf0aab, + 0xf0aac: 0xf0aac, + 0xf0aad: 0xf0aad, + 0xf0aae: 0xf0aae, + 0xf0aaf: 0xf0aaf, + 0xf0ab0: 0xf0ab0, + 0xf0ab1: 0xf0ab1, + 0xf0ab2: 0xf0ab2, + 0xf0ab3: 0xf0ab3, + 0xf0ab4: 0xf0ab4, + 0xf0ab5: 0xf0ab5, + 0xf0ab6: 0xf0ab6, + 0xf0ab7: 0xf0ab7, + 0xf0ab8: 0xf0ab8, + 0xf0ab9: 0xf0ab9, + 0xf0aba: 0xf0aba, + 0xf0abb: 0xf0abb, + 0xf0abc: 0xf0abc, + 0xf0abd: 0xf0abd, + 0xf0abe: 0xf0abe, + 0xf0abf: 0xf0abf, + 0xf0ac0: 0xf0ac0, + 0xf0ac1: 0xf0ac1, + 0xf0ac2: 0xf0ac2, + 0xf0ac3: 0xf0ac3, + 0xf0ac4: 0xf0ac4, + 0xf0ac5: 0xf0ac5, + 0xf0ac6: 0xf0ac6, + 0xf0ac7: 0xf0ac7, + 0xf0ac8: 0xf0ac8, + 0xf0ac9: 0xf0ac9, + 0xf0aca: 0xf0aca, + 0xf0acb: 0xf0acb, + 0xf0acc: 0xf0acc, + 0xf0acd: 0xf0acd, + 0xf0ace: 0xf0ace, + 0xf0acf: 0xf0acf, + 0xf0ad0: 0xf0ad0, + 0xf0ad1: 0xf0ad1, + 0xf0ad2: 0xf0ad2, + 0xf0ad3: 0xf0ad3, + 0xf0ad4: 0xf0ad4, + 0xf0ad5: 0xf0ad5, + 0xf0ad6: 0xf0ad6, + 0xf0ad7: 0xf0ad7, + 0xf0ad8: 0xf0ad8, + 0xf0ad9: 0xf0ad9, + 0xf0ada: 0xf0ada, + 0xf0adb: 0xf0adb, + 0xf0adc: 0xf0adc, + 0xf0add: 0xf0add, + 0xf0ade: 0xf0ade, + 0xf0adf: 0xf0adf, + 0xf0ae0: 0xf0ae0, + 0xf0ae1: 0xf0ae1, + 0xf0ae2: 0xf0ae2, + 0xf0ae3: 0xf0ae3, + 0xf0ae4: 0xf0ae4, + 0xf0ae5: 0xf0ae5, + 0xf0ae6: 0xf0ae6, + 0xf0ae7: 0xf0ae7, + 0xf0ae8: 0xf0ae8, + 0xf0ae9: 0xf0ae9, + 0xf0aea: 0xf0aea, + 0xf0aeb: 0xf0aeb, + 0xf0aec: 0xf0aec, + 0xf0aed: 0xf0aed, + 0xf0aee: 0xf0aee, + 0xf0aef: 0xf0aef, + 0xf0af0: 0xf0af0, + 0xf0af1: 0xf0af1, + 0xf0af2: 0xf0af2, + 0xf0af3: 0xf0af3, + 0xf0af4: 0xf0af4, + 0xf0af5: 0xf0af5, + 0xf0af6: 0xf0af6, + 0xf0af7: 0xf0af7, + 0xf0af8: 0xf0af8, + 0xf0af9: 0xf0af9, + 0xf0afa: 0xf0afa, + 0xf0afb: 0xf0afb, + 0xf0afc: 0xf0afc, + 0xf0afd: 0xf0afd, + 0xf0afe: 0xf0afe, + 0xf0aff: 0xf0aff, + 0xf0b00: 0xf0b00, + 0xf0b01: 0xf0b01, + 0xf0b02: 0xf0b02, + 0xf0b03: 0xf0b03, + 0xf0b04: 0xf0b04, + 0xf0b05: 0xf0b05, + 0xf0b06: 0xf0b06, + 0xf0b07: 0xf0b07, + 0xf0b08: 0xf0b08, + 0xf0b09: 0xf0b09, + 0xf0b0a: 0xf0b0a, + 0xf0b0b: 0xf0b0b, + 0xf0b0c: 0xf0b0c, + 0xf0b0d: 0xf0b0d, + 0xf0b0e: 0xf0b0e, + 0xf0b0f: 0xf0b0f, + 0xf0b10: 0xf0b10, + 0xf0b11: 0xf0b11, + 0xf0b12: 0xf0b12, + 0xf0b13: 0xf0b13, + 0xf0b14: 0xf0b14, + 0xf0b15: 0xf0b15, + 0xf0b16: 0xf0b16, + 0xf0b17: 0xf0b17, + 0xf0b18: 0xf0b18, + 0xf0b19: 0xf0b19, + 0xf0b1a: 0xf0b1a, + 0xf0b1b: 0xf0b1b, + 0xf0b1c: 0xf0b1c, + 0xf0b1d: 0xf0b1d, + 0xf0b1e: 0xf0b1e, + 0xf0b1f: 0xf0b1f, + 0xf0b20: 0xf0b20, + 0xf0b21: 0xf0b21, + 0xf0b22: 0xf0b22, + 0xf0b23: 0xf0b23, + 0xf0b24: 0xf0b24, + 0xf0b25: 0xf0b25, + 0xf0b26: 0xf0b26, + 0xf0b27: 0xf0b27, + 0xf0b28: 0xf0b28, + 0xf0b29: 0xf0b29, + 0xf0b2a: 0xf0b2a, + 0xf0b2b: 0xf0b2b, + 0xf0b2c: 0xf0b2c, + 0xf0b2d: 0xf0b2d, + 0xf0b2e: 0xf0b2e, + 0xf0b2f: 0xf0b2f, + 0xf0b30: 0xf0b30, + 0xf0b31: 0xf0b31, + 0xf0b32: 0xf0b32, + 0xf0b33: 0xf0b33, + 0xf0b34: 0xf0b34, + 0xf0b35: 0xf0b35, + 0xf0b36: 0xf0b36, + 0xf0b37: 0xf0b37, + 0xf0b38: 0xf0b38, + 0xf0b39: 0xf0b39, + 0xf0b3a: 0xf0b3a, + 0xf0b3b: 0xf0b3b, + 0xf0b3c: 0xf0b3c, + 0xf0b3d: 0xf0b3d, + 0xf0b3e: 0xf0b3e, + 0xf0b3f: 0xf0b3f, + 0xf0b40: 0xf0b40, + 0xf0b41: 0xf0b41, + 0xf0b42: 0xf0b42, + 0xf0b43: 0xf0b43, + 0xf0b44: 0xf0b44, + 0xf0b45: 0xf0b45, + 0xf0b46: 0xf0b46, + 0xf0b47: 0xf0b47, + 0xf0b48: 0xf0b48, + 0xf0b49: 0xf0b49, + 0xf0b4a: 0xf0b4a, + 0xf0b4b: 0xf0b4b, + 0xf0b4c: 0xf0b4c, + 0xf0b4d: 0xf0b4d, + 0xf0b4e: 0xf0b4e, + 0xf0b4f: 0xf0b4f, + 0xf0b50: 0xf0b50, + 0xf0b51: 0xf0b51, + 0xf0b52: 0xf0b52, + 0xf0b53: 0xf0b53, + 0xf0b54: 0xf0b54, + 0xf0b55: 0xf0b55, + 0xf0b56: 0xf0b56, + 0xf0b57: 0xf0b57, + 0xf0b58: 0xf0b58, + 0xf0b59: 0xf0b59, + 0xf0b5a: 0xf0b5a, + 0xf0b5b: 0xf0b5b, + 0xf0b5c: 0xf0b5c, + 0xf0b5d: 0xf0b5d, + 0xf0b5e: 0xf0b5e, + 0xf0b5f: 0xf0b5f, + 0xf0b60: 0xf0b60, + 0xf0b61: 0xf0b61, + 0xf0b62: 0xf0b62, + 0xf0b63: 0xf0b63, + 0xf0b64: 0xf0b64, + 0xf0b65: 0xf0b65, + 0xf0b66: 0xf0b66, + 0xf0b67: 0xf0b67, + 0xf0b68: 0xf0b68, + 0xf0b69: 0xf0b69, + 0xf0b6a: 0xf0b6a, + 0xf0b6b: 0xf0b6b, + 0xf0b6c: 0xf0b6c, + 0xf0b6d: 0xf0b6d, + 0xf0b6e: 0xf0b6e, + 0xf0b6f: 0xf0b6f, + 0xf0b70: 0xf0b70, + 0xf0b71: 0xf0b71, + 0xf0b72: 0xf0b72, + 0xf0b73: 0xf0b73, + 0xf0b74: 0xf0b74, + 0xf0b75: 0xf0b75, + 0xf0b76: 0xf0b76, + 0xf0b77: 0xf0b77, + 0xf0b78: 0xf0b78, + 0xf0b79: 0xf0b79, + 0xf0b7a: 0xf0b7a, + 0xf0b7b: 0xf0b7b, + 0xf0b7c: 0xf0b7c, + 0xf0b7d: 0xf0b7d, + 0xf0b7e: 0xf0b7e, + 0xf0b7f: 0xf0b7f, + 0xf0b80: 0xf0b80, + 0xf0b81: 0xf0b81, + 0xf0b82: 0xf0b82, + 0xf0b83: 0xf0b83, + 0xf0b84: 0xf0b84, + 0xf0b85: 0xf0b85, + 0xf0b86: 0xf0b86, + 0xf0b87: 0xf0b87, + 0xf0b88: 0xf0b88, + 0xf0b89: 0xf0b89, + 0xf0b8a: 0xf0b8a, + 0xf0b8b: 0xf0b8b, + 0xf0b8c: 0xf0b8c, + 0xf0b8d: 0xf0b8d, + 0xf0b8e: 0xf0b8e, + 0xf0b8f: 0xf0b8f, + 0xf0b90: 0xf0b90, + 0xf0b91: 0xf0b91, + 0xf0b92: 0xf0b92, + 0xf0b93: 0xf0b93, + 0xf0b94: 0xf0b94, + 0xf0b95: 0xf0b95, + 0xf0b96: 0xf0b96, + 0xf0b97: 0xf0b97, + 0xf0b98: 0xf0b98, + 0xf0b99: 0xf0b99, + 0xf0b9a: 0xf0b9a, + 0xf0b9b: 0xf0b9b, + 0xf0b9c: 0xf0b9c, + 0xf0b9d: 0xf0b9d, + 0xf0b9e: 0xf0b9e, + 0xf0b9f: 0xf0b9f, + 0xf0ba0: 0xf0ba0, + 0xf0ba1: 0xf0ba1, + 0xf0ba2: 0xf0ba2, + 0xf0ba3: 0xf0ba3, + 0xf0ba4: 0xf0ba4, + 0xf0ba5: 0xf0ba5, + 0xf0ba6: 0xf0ba6, + 0xf0ba7: 0xf0ba7, + 0xf0ba8: 0xf0ba8, + 0xf0ba9: 0xf0ba9, + 0xf0baa: 0xf0baa, + 0xf0bab: 0xf0bab, + 0xf0bac: 0xf0bac, + 0xf0bad: 0xf0bad, + 0xf0bae: 0xf0bae, + 0xf0baf: 0xf0baf, + 0xf0bb0: 0xf0bb0, + 0xf0bb1: 0xf0bb1, + 0xf0bb2: 0xf0bb2, + 0xf0bb3: 0xf0bb3, + 0xf0bb4: 0xf0bb4, + 0xf0bb5: 0xf0bb5, + 0xf0bb6: 0xf0bb6, + 0xf0bb7: 0xf0bb7, + 0xf0bb8: 0xf0bb8, + 0xf0bb9: 0xf0bb9, + 0xf0bba: 0xf0bba, + 0xf0bbb: 0xf0bbb, + 0xf0bbc: 0xf0bbc, + 0xf0bbd: 0xf0bbd, + 0xf0bbe: 0xf0bbe, + 0xf0bbf: 0xf0bbf, + 0xf0bc0: 0xf0bc0, + 0xf0bc1: 0xf0bc1, + 0xf0bc2: 0xf0bc2, + 0xf0bc3: 0xf0bc3, + 0xf0bc4: 0xf0bc4, + 0xf0bc5: 0xf0bc5, + 0xf0bc6: 0xf0bc6, + 0xf0bc7: 0xf0bc7, + 0xf0bc8: 0xf0bc8, + 0xf0bc9: 0xf0bc9, + 0xf0bca: 0xf0bca, + 0xf0bcb: 0xf0bcb, + 0xf0bcc: 0xf0bcc, + 0xf0bcd: 0xf0bcd, + 0xf0bce: 0xf0bce, + 0xf0bcf: 0xf0bcf, + 0xf0bd0: 0xf0bd0, + 0xf0bd1: 0xf0bd1, + 0xf0bd2: 0xf0bd2, + 0xf0bd3: 0xf0bd3, + 0xf0bd4: 0xf0bd4, + 0xf0bd5: 0xf0bd5, + 0xf0bd6: 0xf0bd6, + 0xf0bd7: 0xf0bd7, + 0xf0bd8: 0xf0bd8, + 0xf0bd9: 0xf0bd9, + 0xf0bda: 0xf0bda, + 0xf0bdb: 0xf0bdb, + 0xf0bdc: 0xf0bdc, + 0xf0bdd: 0xf0bdd, + 0xf0bde: 0xf0bde, + 0xf0bdf: 0xf0bdf, + 0xf0be0: 0xf0be0, + 0xf0be1: 0xf0be1, + 0xf0be2: 0xf0be2, + 0xf0be3: 0xf0be3, + 0xf0be4: 0xf0be4, + 0xf0be5: 0xf0be5, + 0xf0be6: 0xf0be6, + 0xf0be7: 0xf0be7, + 0xf0be8: 0xf0be8, + 0xf0be9: 0xf0be9, + 0xf0bea: 0xf0bea, + 0xf0beb: 0xf0beb, + 0xf0bec: 0xf0bec, + 0xf0bed: 0xf0bed, + 0xf0bee: 0xf0bee, + 0xf0bef: 0xf0bef, + 0xf0bf0: 0xf0bf0, + 0xf0bf1: 0xf0bf1, + 0xf0bf2: 0xf0bf2, + 0xf0bf3: 0xf0bf3, + 0xf0bf4: 0xf0bf4, + 0xf0bf5: 0xf0bf5, + 0xf0bf6: 0xf0bf6, + 0xf0bf7: 0xf0bf7, + 0xf0bf8: 0xf0bf8, + 0xf0bf9: 0xf0bf9, + 0xf0bfa: 0xf0bfa, + 0xf0bfb: 0xf0bfb, + 0xf0bfc: 0xf0bfc, + 0xf0bfd: 0xf0bfd, + 0xf0bfe: 0xf0bfe, + 0xf0bff: 0xf0bff, + 0xf0c00: 0xf0c00, + 0xf0c01: 0xf0c01, + 0xf0c02: 0xf0c02, + 0xf0c03: 0xf0c03, + 0xf0c04: 0xf0c04, + 0xf0c05: 0xf0c05, + 0xf0c06: 0xf0c06, + 0xf0c07: 0xf0c07, + 0xf0c08: 0xf0c08, + 0xf0c09: 0xf0c09, + 0xf0c0a: 0xf0c0a, + 0xf0c0b: 0xf0c0b, + 0xf0c0c: 0xf0c0c, + 0xf0c0d: 0xf0c0d, + 0xf0c0e: 0xf0c0e, + 0xf0c0f: 0xf0c0f, + 0xf0c10: 0xf0c10, + 0xf0c11: 0xf0c11, + 0xf0c12: 0xf0c12, + 0xf0c13: 0xf0c13, + 0xf0c14: 0xf0c14, + 0xf0c15: 0xf0c15, + 0xf0c16: 0xf0c16, + 0xf0c17: 0xf0c17, + 0xf0c18: 0xf0c18, + 0xf0c19: 0xf0c19, + 0xf0c1a: 0xf0c1a, + 0xf0c1b: 0xf0c1b, + 0xf0c1c: 0xf0c1c, + 0xf0c1d: 0xf0c1d, + 0xf0c1e: 0xf0c1e, + 0xf0c1f: 0xf0c1f, + 0xf0c20: 0xf0c20, + 0xf0c21: 0xf0c21, + 0xf0c22: 0xf0c22, + 0xf0c23: 0xf0c23, + 0xf0c24: 0xf0c24, + 0xf0c25: 0xf0c25, + 0xf0c26: 0xf0c26, + 0xf0c27: 0xf0c27, + 0xf0c28: 0xf0c28, + 0xf0c29: 0xf0c29, + 0xf0c2a: 0xf0c2a, + 0xf0c2b: 0xf0c2b, + 0xf0c2c: 0xf0c2c, + 0xf0c2d: 0xf0c2d, + 0xf0c2e: 0xf0c2e, + 0xf0c2f: 0xf0c2f, + 0xf0c30: 0xf0c30, + 0xf0c31: 0xf0c31, + 0xf0c32: 0xf0c32, + 0xf0c33: 0xf0c33, + 0xf0c34: 0xf0c34, + 0xf0c35: 0xf0c35, + 0xf0c36: 0xf0c36, + 0xf0c37: 0xf0c37, + 0xf0c38: 0xf0c38, + 0xf0c39: 0xf0c39, + 0xf0c3a: 0xf0c3a, + 0xf0c3b: 0xf0c3b, + 0xf0c3c: 0xf0c3c, + 0xf0c3d: 0xf0c3d, + 0xf0c3e: 0xf0c3e, + 0xf0c3f: 0xf0c3f, + 0xf0c40: 0xf0c40, + 0xf0c41: 0xf0c41, + 0xf0c42: 0xf0c42, + 0xf0c43: 0xf0c43, + 0xf0c44: 0xf0c44, + 0xf0c45: 0xf0c45, + 0xf0c46: 0xf0c46, + 0xf0c47: 0xf0c47, + 0xf0c48: 0xf0c48, + 0xf0c49: 0xf0c49, + 0xf0c4a: 0xf0c4a, + 0xf0c4b: 0xf0c4b, + 0xf0c4c: 0xf0c4c, + 0xf0c4d: 0xf0c4d, + 0xf0c4e: 0xf0c4e, + 0xf0c4f: 0xf0c4f, + 0xf0c50: 0xf0c50, + 0xf0c51: 0xf0c51, + 0xf0c52: 0xf0c52, + 0xf0c53: 0xf0c53, + 0xf0c54: 0xf0c54, + 0xf0c55: 0xf0c55, + 0xf0c56: 0xf0c56, + 0xf0c57: 0xf0c57, + 0xf0c58: 0xf0c58, + 0xf0c59: 0xf0c59, + 0xf0c5a: 0xf0c5a, + 0xf0c5b: 0xf0c5b, + 0xf0c5c: 0xf0c5c, + 0xf0c5d: 0xf0c5d, + 0xf0c5e: 0xf0c5e, + 0xf0c5f: 0xf0c5f, + 0xf0c60: 0xf0c60, + 0xf0c61: 0xf0c61, + 0xf0c62: 0xf0c62, + 0xf0c63: 0xf0c63, + 0xf0c64: 0xf0c64, + 0xf0c65: 0xf0c65, + 0xf0c66: 0xf0c66, + 0xf0c67: 0xf0c67, + 0xf0c68: 0xf0c68, + 0xf0c69: 0xf0c69, + 0xf0c6a: 0xf0c6a, + 0xf0c6b: 0xf0c6b, + 0xf0c6c: 0xf0c6c, + 0xf0c6d: 0xf0c6d, + 0xf0c6e: 0xf0c6e, + 0xf0c6f: 0xf0c6f, + 0xf0c70: 0xf0c70, + 0xf0c71: 0xf0c71, + 0xf0c72: 0xf0c72, + 0xf0c73: 0xf0c73, + 0xf0c74: 0xf0c74, + 0xf0c75: 0xf0c75, + 0xf0c76: 0xf0c76, + 0xf0c77: 0xf0c77, + 0xf0c78: 0xf0c78, + 0xf0c79: 0xf0c79, + 0xf0c7a: 0xf0c7a, + 0xf0c7b: 0xf0c7b, + 0xf0c7c: 0xf0c7c, + 0xf0c7d: 0xf0c7d, + 0xf0c7e: 0xf0c7e, + 0xf0c7f: 0xf0c7f, + 0xf0c80: 0xf0c80, + 0xf0c81: 0xf0c81, + 0xf0c82: 0xf0c82, + 0xf0c83: 0xf0c83, + 0xf0c84: 0xf0c84, + 0xf0c85: 0xf0c85, + 0xf0c86: 0xf0c86, + 0xf0c87: 0xf0c87, + 0xf0c88: 0xf0c88, + 0xf0c89: 0xf0c89, + 0xf0c8a: 0xf0c8a, + 0xf0c8b: 0xf0c8b, + 0xf0c8c: 0xf0c8c, + 0xf0c8d: 0xf0c8d, + 0xf0c8e: 0xf0c8e, + 0xf0c8f: 0xf0c8f, + 0xf0c90: 0xf0c90, + 0xf0c91: 0xf0c91, + 0xf0c92: 0xf0c92, + 0xf0c93: 0xf0c93, + 0xf0c94: 0xf0c94, + 0xf0c95: 0xf0c95, + 0xf0c96: 0xf0c96, + 0xf0c97: 0xf0c97, + 0xf0c98: 0xf0c98, + 0xf0c99: 0xf0c99, + 0xf0c9a: 0xf0c9a, + 0xf0c9b: 0xf0c9b, + 0xf0c9c: 0xf0c9c, + 0xf0c9d: 0xf0c9d, + 0xf0c9e: 0xf0c9e, + 0xf0c9f: 0xf0c9f, + 0xf0ca0: 0xf0ca0, + 0xf0ca1: 0xf0ca1, + 0xf0ca2: 0xf0ca2, + 0xf0ca3: 0xf0ca3, + 0xf0ca4: 0xf0ca4, + 0xf0ca5: 0xf0ca5, + 0xf0ca6: 0xf0ca6, + 0xf0ca7: 0xf0ca7, + 0xf0ca8: 0xf0ca8, + 0xf0ca9: 0xf0ca9, + 0xf0caa: 0xf0caa, + 0xf0cab: 0xf0cab, + 0xf0cac: 0xf0cac, + 0xf0cad: 0xf0cad, + 0xf0cae: 0xf0cae, + 0xf0caf: 0xf0caf, + 0xf0cb0: 0xf0cb0, + 0xf0cb1: 0xf0cb1, + 0xf0cb2: 0xf0cb2, + 0xf0cb3: 0xf0cb3, + 0xf0cb4: 0xf0cb4, + 0xf0cb5: 0xf0cb5, + 0xf0cb6: 0xf0cb6, + 0xf0cb7: 0xf0cb7, + 0xf0cb8: 0xf0cb8, + 0xf0cb9: 0xf0cb9, + 0xf0cba: 0xf0cba, + 0xf0cbb: 0xf0cbb, + 0xf0cbc: 0xf0cbc, + 0xf0cbd: 0xf0cbd, + 0xf0cbe: 0xf0cbe, + 0xf0cbf: 0xf0cbf, + 0xf0cc0: 0xf0cc0, + 0xf0cc1: 0xf0cc1, + 0xf0cc2: 0xf0cc2, + 0xf0cc3: 0xf0cc3, + 0xf0cc4: 0xf0cc4, + 0xf0cc5: 0xf0cc5, + 0xf0cc6: 0xf0cc6, + 0xf0cc7: 0xf0cc7, + 0xf0cc8: 0xf0cc8, + 0xf0cc9: 0xf0cc9, + 0xf0cca: 0xf0cca, + 0xf0ccb: 0xf0ccb, + 0xf0ccc: 0xf0ccc, + 0xf0ccd: 0xf0ccd, + 0xf0cce: 0xf0cce, + 0xf0ccf: 0xf0ccf, + 0xf0cd0: 0xf0cd0, + 0xf0cd1: 0xf0cd1, + 0xf0cd2: 0xf0cd2, + 0xf0cd3: 0xf0cd3, + 0xf0cd4: 0xf0cd4, + 0xf0cd5: 0xf0cd5, + 0xf0cd6: 0xf0cd6, + 0xf0cd7: 0xf0cd7, + 0xf0cd8: 0xf0cd8, + 0xf0cd9: 0xf0cd9, + 0xf0cda: 0xf0cda, + 0xf0cdb: 0xf0cdb, + 0xf0cdc: 0xf0cdc, + 0xf0cdd: 0xf0cdd, + 0xf0cde: 0xf0cde, + 0xf0cdf: 0xf0cdf, + 0xf0ce0: 0xf0ce0, + 0xf0ce1: 0xf0ce1, + 0xf0ce2: 0xf0ce2, + 0xf0ce3: 0xf0ce3, + 0xf0ce4: 0xf0ce4, + 0xf0ce5: 0xf0ce5, + 0xf0ce6: 0xf0ce6, + 0xf0ce7: 0xf0ce7, + 0xf0ce8: 0xf0ce8, + 0xf0ce9: 0xf0ce9, + 0xf0cea: 0xf0cea, + 0xf0ceb: 0xf0ceb, + 0xf0cec: 0xf0cec, + 0xf0ced: 0xf0ced, + 0xf0cee: 0xf0cee, + 0xf0cef: 0xf0cef, + 0xf0cf0: 0xf0cf0, + 0xf0cf1: 0xf0cf1, + 0xf0cf2: 0xf0cf2, + 0xf0cf3: 0xf0cf3, + 0xf0cf4: 0xf0cf4, + 0xf0cf5: 0xf0cf5, + 0xf0cf6: 0xf0cf6, + 0xf0cf7: 0xf0cf7, + 0xf0cf8: 0xf0cf8, + 0xf0cf9: 0xf0cf9, + 0xf0cfa: 0xf0cfa, + 0xf0cfb: 0xf0cfb, + 0xf0cfc: 0xf0cfc, + 0xf0cfd: 0xf0cfd, + 0xf0cfe: 0xf0cfe, + 0xf0cff: 0xf0cff, + 0xf0d00: 0xf0d00, + 0xf0d01: 0xf0d01, + 0xf0d02: 0xf0d02, + 0xf0d03: 0xf0d03, + 0xf0d04: 0xf0d04, + 0xf0d05: 0xf0d05, + 0xf0d06: 0xf0d06, + 0xf0d07: 0xf0d07, + 0xf0d08: 0xf0d08, + 0xf0d09: 0xf0d09, + 0xf0d0a: 0xf0d0a, + 0xf0d0b: 0xf0d0b, + 0xf0d0c: 0xf0d0c, + 0xf0d0d: 0xf0d0d, + 0xf0d0e: 0xf0d0e, + 0xf0d0f: 0xf0d0f, + 0xf0d10: 0xf0d10, + 0xf0d11: 0xf0d11, + 0xf0d12: 0xf0d12, + 0xf0d13: 0xf0d13, + 0xf0d14: 0xf0d14, + 0xf0d15: 0xf0d15, + 0xf0d16: 0xf0d16, + 0xf0d17: 0xf0d17, + 0xf0d18: 0xf0d18, + 0xf0d19: 0xf0d19, + 0xf0d1a: 0xf0d1a, + 0xf0d1b: 0xf0d1b, + 0xf0d1c: 0xf0d1c, + 0xf0d1d: 0xf0d1d, + 0xf0d1e: 0xf0d1e, + 0xf0d1f: 0xf0d1f, + 0xf0d20: 0xf0d20, + 0xf0d21: 0xf0d21, + 0xf0d22: 0xf0d22, + 0xf0d23: 0xf0d23, + 0xf0d24: 0xf0d24, + 0xf0d25: 0xf0d25, + 0xf0d26: 0xf0d26, + 0xf0d27: 0xf0d27, + 0xf0d28: 0xf0d28, + 0xf0d29: 0xf0d29, + 0xf0d2a: 0xf0d2a, + 0xf0d2b: 0xf0d2b, + 0xf0d2c: 0xf0d2c, + 0xf0d2d: 0xf0d2d, + 0xf0d2e: 0xf0d2e, + 0xf0d2f: 0xf0d2f, + 0xf0d30: 0xf0d30, + 0xf0d31: 0xf0d31, + 0xf0d32: 0xf0d32, + 0xf0d33: 0xf0d33, + 0xf0d34: 0xf0d34, + 0xf0d35: 0xf0d35, + 0xf0d36: 0xf0d36, + 0xf0d37: 0xf0d37, + 0xf0d38: 0xf0d38, + 0xf0d39: 0xf0d39, + 0xf0d3a: 0xf0d3a, + 0xf0d3b: 0xf0d3b, + 0xf0d3c: 0xf0d3c, + 0xf0d3d: 0xf0d3d, + 0xf0d3e: 0xf0d3e, + 0xf0d3f: 0xf0d3f, + 0xf0d40: 0xf0d40, + 0xf0d41: 0xf0d41, + 0xf0d42: 0xf0d42, + 0xf0d43: 0xf0d43, + 0xf0d44: 0xf0d44, + 0xf0d45: 0xf0d45, + 0xf0d46: 0xf0d46, + 0xf0d47: 0xf0d47, + 0xf0d48: 0xf0d48, + 0xf0d49: 0xf0d49, + 0xf0d4a: 0xf0d4a, + 0xf0d4b: 0xf0d4b, + 0xf0d4c: 0xf0d4c, + 0xf0d4d: 0xf0d4d, + 0xf0d4e: 0xf0d4e, + 0xf0d4f: 0xf0d4f, + 0xf0d50: 0xf0d50, + 0xf0d51: 0xf0d51, + 0xf0d52: 0xf0d52, + 0xf0d53: 0xf0d53, + 0xf0d54: 0xf0d54, + 0xf0d55: 0xf0d55, + 0xf0d56: 0xf0d56, + 0xf0d57: 0xf0d57, + 0xf0d58: 0xf0d58, + 0xf0d59: 0xf0d59, + 0xf0d5a: 0xf0d5a, + 0xf0d5b: 0xf0d5b, + 0xf0d5c: 0xf0d5c, + 0xf0d5d: 0xf0d5d, + 0xf0d5e: 0xf0d5e, + 0xf0d5f: 0xf0d5f, + 0xf0d60: 0xf0d60, + 0xf0d61: 0xf0d61, + 0xf0d62: 0xf0d62, + 0xf0d63: 0xf0d63, + 0xf0d64: 0xf0d64, + 0xf0d65: 0xf0d65, + 0xf0d66: 0xf0d66, + 0xf0d67: 0xf0d67, + 0xf0d68: 0xf0d68, + 0xf0d69: 0xf0d69, + 0xf0d6a: 0xf0d6a, + 0xf0d6b: 0xf0d6b, + 0xf0d6c: 0xf0d6c, + 0xf0d6d: 0xf0d6d, + 0xf0d6e: 0xf0d6e, + 0xf0d6f: 0xf0d6f, + 0xf0d70: 0xf0d70, + 0xf0d71: 0xf0d71, + 0xf0d72: 0xf0d72, + 0xf0d73: 0xf0d73, + 0xf0d74: 0xf0d74, + 0xf0d75: 0xf0d75, + 0xf0d76: 0xf0d76, + 0xf0d77: 0xf0d77, + 0xf0d78: 0xf0d78, + 0xf0d79: 0xf0d79, + 0xf0d7a: 0xf0d7a, + 0xf0d7b: 0xf0d7b, + 0xf0d7c: 0xf0d7c, + 0xf0d7d: 0xf0d7d, + 0xf0d7e: 0xf0d7e, + 0xf0d7f: 0xf0d7f, + 0xf0d80: 0xf0d80, + 0xf0d81: 0xf0d81, + 0xf0d82: 0xf0d82, + 0xf0d83: 0xf0d83, + 0xf0d84: 0xf0d84, + 0xf0d85: 0xf0d85, + 0xf0d86: 0xf0d86, + 0xf0d87: 0xf0d87, + 0xf0d88: 0xf0d88, + 0xf0d89: 0xf0d89, + 0xf0d8a: 0xf0d8a, + 0xf0d8b: 0xf0d8b, + 0xf0d8c: 0xf0d8c, + 0xf0d8d: 0xf0d8d, + 0xf0d8e: 0xf0d8e, + 0xf0d8f: 0xf0d8f, + 0xf0d90: 0xf0d90, + 0xf0d91: 0xf0d91, + 0xf0d92: 0xf0d92, + 0xf0d93: 0xf0d93, + 0xf0d94: 0xf0d94, + 0xf0d95: 0xf0d95, + 0xf0d96: 0xf0d96, + 0xf0d97: 0xf0d97, + 0xf0d98: 0xf0d98, + 0xf0d99: 0xf0d99, + 0xf0d9a: 0xf0d9a, + 0xf0d9b: 0xf0d9b, + 0xf0d9c: 0xf0d9c, + 0xf0d9d: 0xf0d9d, + 0xf0d9e: 0xf0d9e, + 0xf0d9f: 0xf0d9f, + 0xf0da0: 0xf0da0, + 0xf0da1: 0xf0da1, + 0xf0da2: 0xf0da2, + 0xf0da3: 0xf0da3, + 0xf0da4: 0xf0da4, + 0xf0da5: 0xf0da5, + 0xf0da6: 0xf0da6, + 0xf0da7: 0xf0da7, + 0xf0da8: 0xf0da8, + 0xf0da9: 0xf0da9, + 0xf0daa: 0xf0daa, + 0xf0dab: 0xf0dab, + 0xf0dac: 0xf0dac, + 0xf0dad: 0xf0dad, + 0xf0dae: 0xf0dae, + 0xf0daf: 0xf0daf, + 0xf0db0: 0xf0db0, + 0xf0db1: 0xf0db1, + 0xf0db2: 0xf0db2, + 0xf0db3: 0xf0db3, + 0xf0db4: 0xf0db4, + 0xf0db5: 0xf0db5, + 0xf0db6: 0xf0db6, + 0xf0db7: 0xf0db7, + 0xf0db8: 0xf0db8, + 0xf0db9: 0xf0db9, + 0xf0dba: 0xf0dba, + 0xf0dbb: 0xf0dbb, + 0xf0dbc: 0xf0dbc, + 0xf0dbd: 0xf0dbd, + 0xf0dbe: 0xf0dbe, + 0xf0dbf: 0xf0dbf, + 0xf0dc0: 0xf0dc0, + 0xf0dc1: 0xf0dc1, + 0xf0dc2: 0xf0dc2, + 0xf0dc3: 0xf0dc3, + 0xf0dc4: 0xf0dc4, + 0xf0dc5: 0xf0dc5, + 0xf0dc6: 0xf0dc6, + 0xf0dc7: 0xf0dc7, + 0xf0dc8: 0xf0dc8, + 0xf0dc9: 0xf0dc9, + 0xf0dca: 0xf0dca, + 0xf0dcb: 0xf0dcb, + 0xf0dcc: 0xf0dcc, + 0xf0dcd: 0xf0dcd, + 0xf0dce: 0xf0dce, + 0xf0dcf: 0xf0dcf, + 0xf0dd0: 0xf0dd0, + 0xf0dd1: 0xf0dd1, + 0xf0dd2: 0xf0dd2, + 0xf0dd3: 0xf0dd3, + 0xf0dd4: 0xf0dd4, + 0xf0dd5: 0xf0dd5, + 0xf0dd6: 0xf0dd6, + 0xf0dd7: 0xf0dd7, + 0xf0dd8: 0xf0dd8, + 0xf0dd9: 0xf0dd9, + 0xf0dda: 0xf0dda, + 0xf0ddb: 0xf0ddb, + 0xf0ddc: 0xf0ddc, + 0xf0ddd: 0xf0ddd, + 0xf0dde: 0xf0dde, + 0xf0ddf: 0xf0ddf, + 0xf0de0: 0xf0de0, + 0xf0de1: 0xf0de1, + 0xf0de2: 0xf0de2, + 0xf0de3: 0xf0de3, + 0xf0de4: 0xf0de4, + 0xf0de5: 0xf0de5, + 0xf0de6: 0xf0de6, + 0xf0de7: 0xf0de7, + 0xf0de8: 0xf0de8, + 0xf0de9: 0xf0de9, + 0xf0dea: 0xf0dea, + 0xf0deb: 0xf0deb, + 0xf0dec: 0xf0dec, + 0xf0ded: 0xf0ded, + 0xf0dee: 0xf0dee, + 0xf0def: 0xf0def, + 0xf0df0: 0xf0df0, + 0xf0df1: 0xf0df1, + 0xf0df2: 0xf0df2, + 0xf0df3: 0xf0df3, + 0xf0df4: 0xf0df4, + 0xf0df5: 0xf0df5, + 0xf0df6: 0xf0df6, + 0xf0df7: 0xf0df7, + 0xf0df8: 0xf0df8, + 0xf0df9: 0xf0df9, + 0xf0dfa: 0xf0dfa, + 0xf0dfb: 0xf0dfb, + 0xf0dfc: 0xf0dfc, + 0xf0dfd: 0xf0dfd, + 0xf0dfe: 0xf0dfe, + 0xf0dff: 0xf0dff, + 0xf0e00: 0xf0e00, + 0xf0e01: 0xf0e01, + 0xf0e02: 0xf0e02, + 0xf0e03: 0xf0e03, + 0xf0e04: 0xf0e04, + 0xf0e05: 0xf0e05, + 0xf0e06: 0xf0e06, + 0xf0e07: 0xf0e07, + 0xf0e08: 0xf0e08, + 0xf0e09: 0xf0e09, + 0xf0e0a: 0xf0e0a, + 0xf0e0b: 0xf0e0b, + 0xf0e0c: 0xf0e0c, + 0xf0e0d: 0xf0e0d, + 0xf0e0e: 0xf0e0e, + 0xf0e0f: 0xf0e0f, + 0xf0e10: 0xf0e10, + 0xf0e11: 0xf0e11, + 0xf0e12: 0xf0e12, + 0xf0e13: 0xf0e13, + 0xf0e14: 0xf0e14, + 0xf0e15: 0xf0e15, + 0xf0e16: 0xf0e16, + 0xf0e17: 0xf0e17, + 0xf0e18: 0xf0e18, + 0xf0e19: 0xf0e19, + 0xf0e1a: 0xf0e1a, + 0xf0e1b: 0xf0e1b, + 0xf0e1c: 0xf0e1c, + 0xf0e1d: 0xf0e1d, + 0xf0e1e: 0xf0e1e, + 0xf0e1f: 0xf0e1f, + 0xf0e20: 0xf0e20, + 0xf0e21: 0xf0e21, + 0xf0e22: 0xf0e22, + 0xf0e23: 0xf0e23, + 0xf0e24: 0xf0e24, + 0xf0e25: 0xf0e25, + 0xf0e26: 0xf0e26, + 0xf0e27: 0xf0e27, + 0xf0e28: 0xf0e28, + 0xf0e29: 0xf0e29, + 0xf0e2a: 0xf0e2a, + 0xf0e2b: 0xf0e2b, + 0xf0e2c: 0xf0e2c, + 0xf0e2d: 0xf0e2d, + 0xf0e2e: 0xf0e2e, + 0xf0e2f: 0xf0e2f, + 0xf0e30: 0xf0e30, + 0xf0e31: 0xf0e31, + 0xf0e32: 0xf0e32, + 0xf0e33: 0xf0e33, + 0xf0e34: 0xf0e34, + 0xf0e35: 0xf0e35, + 0xf0e36: 0xf0e36, + 0xf0e37: 0xf0e37, + 0xf0e38: 0xf0e38, + 0xf0e39: 0xf0e39, + 0xf0e3a: 0xf0e3a, + 0xf0e3b: 0xf0e3b, + 0xf0e3c: 0xf0e3c, + 0xf0e3d: 0xf0e3d, + 0xf0e3e: 0xf0e3e, + 0xf0e3f: 0xf0e3f, + 0xf0e40: 0xf0e40, + 0xf0e41: 0xf0e41, + 0xf0e42: 0xf0e42, + 0xf0e43: 0xf0e43, + 0xf0e44: 0xf0e44, + 0xf0e45: 0xf0e45, + 0xf0e46: 0xf0e46, + 0xf0e47: 0xf0e47, + 0xf0e48: 0xf0e48, + 0xf0e49: 0xf0e49, + 0xf0e4a: 0xf0e4a, + 0xf0e4b: 0xf0e4b, + 0xf0e4c: 0xf0e4c, + 0xf0e4d: 0xf0e4d, + 0xf0e4e: 0xf0e4e, + 0xf0e4f: 0xf0e4f, + 0xf0e50: 0xf0e50, + 0xf0e51: 0xf0e51, + 0xf0e52: 0xf0e52, + 0xf0e53: 0xf0e53, + 0xf0e54: 0xf0e54, + 0xf0e55: 0xf0e55, + 0xf0e56: 0xf0e56, + 0xf0e57: 0xf0e57, + 0xf0e58: 0xf0e58, + 0xf0e59: 0xf0e59, + 0xf0e5a: 0xf0e5a, + 0xf0e5b: 0xf0e5b, + 0xf0e5c: 0xf0e5c, + 0xf0e5d: 0xf0e5d, + 0xf0e5e: 0xf0e5e, + 0xf0e5f: 0xf0e5f, + 0xf0e60: 0xf0e60, + 0xf0e61: 0xf0e61, + 0xf0e62: 0xf0e62, + 0xf0e63: 0xf0e63, + 0xf0e64: 0xf0e64, + 0xf0e65: 0xf0e65, + 0xf0e66: 0xf0e66, + 0xf0e67: 0xf0e67, + 0xf0e68: 0xf0e68, + 0xf0e69: 0xf0e69, + 0xf0e6a: 0xf0e6a, + 0xf0e6b: 0xf0e6b, + 0xf0e6c: 0xf0e6c, + 0xf0e6d: 0xf0e6d, + 0xf0e6e: 0xf0e6e, + 0xf0e6f: 0xf0e6f, + 0xf0e70: 0xf0e70, + 0xf0e71: 0xf0e71, + 0xf0e72: 0xf0e72, + 0xf0e73: 0xf0e73, + 0xf0e74: 0xf0e74, + 0xf0e75: 0xf0e75, + 0xf0e76: 0xf0e76, + 0xf0e77: 0xf0e77, + 0xf0e78: 0xf0e78, + 0xf0e79: 0xf0e79, + 0xf0e7a: 0xf0e7a, + 0xf0e7b: 0xf0e7b, + 0xf0e7c: 0xf0e7c, + 0xf0e7d: 0xf0e7d, + 0xf0e7e: 0xf0e7e, + 0xf0e7f: 0xf0e7f, + 0xf0e80: 0xf0e80, + 0xf0e81: 0xf0e81, + 0xf0e82: 0xf0e82, + 0xf0e83: 0xf0e83, + 0xf0e84: 0xf0e84, + 0xf0e85: 0xf0e85, + 0xf0e86: 0xf0e86, + 0xf0e87: 0xf0e87, + 0xf0e88: 0xf0e88, + 0xf0e89: 0xf0e89, + 0xf0e8a: 0xf0e8a, + 0xf0e8b: 0xf0e8b, + 0xf0e8c: 0xf0e8c, + 0xf0e8d: 0xf0e8d, + 0xf0e8e: 0xf0e8e, + 0xf0e8f: 0xf0e8f, + 0xf0e90: 0xf0e90, + 0xf0e91: 0xf0e91, + 0xf0e92: 0xf0e92, + 0xf0e93: 0xf0e93, + 0xf0e94: 0xf0e94, + 0xf0e95: 0xf0e95, + 0xf0e96: 0xf0e96, + 0xf0e97: 0xf0e97, + 0xf0e98: 0xf0e98, + 0xf0e99: 0xf0e99, + 0xf0e9a: 0xf0e9a, + 0xf0e9b: 0xf0e9b, + 0xf0e9c: 0xf0e9c, + 0xf0e9d: 0xf0e9d, + 0xf0e9e: 0xf0e9e, + 0xf0e9f: 0xf0e9f, + 0xf0ea0: 0xf0ea0, + 0xf0ea1: 0xf0ea1, + 0xf0ea2: 0xf0ea2, + 0xf0ea3: 0xf0ea3, + 0xf0ea4: 0xf0ea4, + 0xf0ea5: 0xf0ea5, + 0xf0ea6: 0xf0ea6, + 0xf0ea7: 0xf0ea7, + 0xf0ea8: 0xf0ea8, + 0xf0ea9: 0xf0ea9, + 0xf0eaa: 0xf0eaa, + 0xf0eab: 0xf0eab, + 0xf0eac: 0xf0eac, + 0xf0ead: 0xf0ead, + 0xf0eae: 0xf0eae, + 0xf0eaf: 0xf0eaf, + 0xf0eb0: 0xf0eb0, + 0xf0eb1: 0xf0eb1, + 0xf0eb2: 0xf0eb2, + 0xf0eb3: 0xf0eb3, + 0xf0eb4: 0xf0eb4, + 0xf0eb5: 0xf0eb5, + 0xf0eb6: 0xf0eb6, + 0xf0eb7: 0xf0eb7, + 0xf0eb8: 0xf0eb8, + 0xf0eb9: 0xf0eb9, + 0xf0eba: 0xf0eba, + 0xf0ebb: 0xf0ebb, + 0xf0ebc: 0xf0ebc, + 0xf0ebd: 0xf0ebd, + 0xf0ebe: 0xf0ebe, + 0xf0ebf: 0xf0ebf, + 0xf0ec0: 0xf0ec0, + 0xf0ec1: 0xf0ec1, + 0xf0ec2: 0xf0ec2, + 0xf0ec3: 0xf0ec3, + 0xf0ec4: 0xf0ec4, + 0xf0ec5: 0xf0ec5, + 0xf0ec6: 0xf0ec6, + 0xf0ec7: 0xf0ec7, + 0xf0ec8: 0xf0ec8, + 0xf0ec9: 0xf0ec9, + 0xf0eca: 0xf0eca, + 0xf0ecb: 0xf0ecb, + 0xf0ecc: 0xf0ecc, + 0xf0ecd: 0xf0ecd, + 0xf0ece: 0xf0ece, + 0xf0ecf: 0xf0ecf, + 0xf0ed0: 0xf0ed0, + 0xf0ed1: 0xf0ed1, + 0xf0ed2: 0xf0ed2, + 0xf0ed3: 0xf0ed3, + 0xf0ed4: 0xf0ed4, + 0xf0ed5: 0xf0ed5, + 0xf0ed6: 0xf0ed6, + 0xf0ed7: 0xf0ed7, + 0xf0ed8: 0xf0ed8, + 0xf0ed9: 0xf0ed9, + 0xf0eda: 0xf0eda, + 0xf0edb: 0xf0edb, + 0xf0edc: 0xf0edc, + 0xf0edd: 0xf0edd, + 0xf0ede: 0xf0ede, + 0xf0edf: 0xf0edf, + 0xf0ee0: 0xf0ee0, + 0xf0ee1: 0xf0ee1, + 0xf0ee2: 0xf0ee2, + 0xf0ee3: 0xf0ee3, + 0xf0ee4: 0xf0ee4, + 0xf0ee5: 0xf0ee5, + 0xf0ee6: 0xf0ee6, + 0xf0ee7: 0xf0ee7, + 0xf0ee8: 0xf0ee8, + 0xf0ee9: 0xf0ee9, + 0xf0eea: 0xf0eea, + 0xf0eeb: 0xf0eeb, + 0xf0eec: 0xf0eec, + 0xf0eed: 0xf0eed, + 0xf0eee: 0xf0eee, + 0xf0eef: 0xf0eef, + 0xf0ef0: 0xf0ef0, + 0xf0ef1: 0xf0ef1, + 0xf0ef2: 0xf0ef2, + 0xf0ef3: 0xf0ef3, + 0xf0ef4: 0xf0ef4, + 0xf0ef5: 0xf0ef5, + 0xf0ef6: 0xf0ef6, + 0xf0ef7: 0xf0ef7, + 0xf0ef8: 0xf0ef8, + 0xf0ef9: 0xf0ef9, + 0xf0efa: 0xf0efa, + 0xf0efb: 0xf0efb, + 0xf0efc: 0xf0efc, + 0xf0efd: 0xf0efd, + 0xf0efe: 0xf0efe, + 0xf0eff: 0xf0eff, + 0xf0f00: 0xf0f00, + 0xf0f01: 0xf0f01, + 0xf0f02: 0xf0f02, + 0xf0f03: 0xf0f03, + 0xf0f04: 0xf0f04, + 0xf0f05: 0xf0f05, + 0xf0f06: 0xf0f06, + 0xf0f07: 0xf0f07, + 0xf0f08: 0xf0f08, + 0xf0f09: 0xf0f09, + 0xf0f0a: 0xf0f0a, + 0xf0f0b: 0xf0f0b, + 0xf0f0c: 0xf0f0c, + 0xf0f0d: 0xf0f0d, + 0xf0f0e: 0xf0f0e, + 0xf0f0f: 0xf0f0f, + 0xf0f10: 0xf0f10, + 0xf0f11: 0xf0f11, + 0xf0f12: 0xf0f12, + 0xf0f13: 0xf0f13, + 0xf0f14: 0xf0f14, + 0xf0f15: 0xf0f15, + 0xf0f16: 0xf0f16, + 0xf0f17: 0xf0f17, + 0xf0f18: 0xf0f18, + 0xf0f19: 0xf0f19, + 0xf0f1a: 0xf0f1a, + 0xf0f1b: 0xf0f1b, + 0xf0f1c: 0xf0f1c, + 0xf0f1d: 0xf0f1d, + 0xf0f1e: 0xf0f1e, + 0xf0f1f: 0xf0f1f, + 0xf0f20: 0xf0f20, + 0xf0f21: 0xf0f21, + 0xf0f22: 0xf0f22, + 0xf0f23: 0xf0f23, + 0xf0f24: 0xf0f24, + 0xf0f25: 0xf0f25, + 0xf0f26: 0xf0f26, + 0xf0f27: 0xf0f27, + 0xf0f28: 0xf0f28, + 0xf0f29: 0xf0f29, + 0xf0f2a: 0xf0f2a, + 0xf0f2b: 0xf0f2b, + 0xf0f2c: 0xf0f2c, + 0xf0f2d: 0xf0f2d, + 0xf0f2e: 0xf0f2e, + 0xf0f2f: 0xf0f2f, + 0xf0f30: 0xf0f30, + 0xf0f31: 0xf0f31, + 0xf0f32: 0xf0f32, + 0xf0f33: 0xf0f33, + 0xf0f34: 0xf0f34, + 0xf0f35: 0xf0f35, + 0xf0f36: 0xf0f36, + 0xf0f37: 0xf0f37, + 0xf0f38: 0xf0f38, + 0xf0f39: 0xf0f39, + 0xf0f3a: 0xf0f3a, + 0xf0f3b: 0xf0f3b, + 0xf0f3c: 0xf0f3c, + 0xf0f3d: 0xf0f3d, + 0xf0f3e: 0xf0f3e, + 0xf0f3f: 0xf0f3f, + 0xf0f40: 0xf0f40, + 0xf0f41: 0xf0f41, + 0xf0f42: 0xf0f42, + 0xf0f43: 0xf0f43, + 0xf0f44: 0xf0f44, + 0xf0f45: 0xf0f45, + 0xf0f46: 0xf0f46, + 0xf0f47: 0xf0f47, + 0xf0f48: 0xf0f48, + 0xf0f49: 0xf0f49, + 0xf0f4a: 0xf0f4a, + 0xf0f4b: 0xf0f4b, + 0xf0f4c: 0xf0f4c, + 0xf0f4d: 0xf0f4d, + 0xf0f4e: 0xf0f4e, + 0xf0f4f: 0xf0f4f, + 0xf0f50: 0xf0f50, + 0xf0f51: 0xf0f51, + 0xf0f52: 0xf0f52, + 0xf0f53: 0xf0f53, + 0xf0f54: 0xf0f54, + 0xf0f55: 0xf0f55, + 0xf0f56: 0xf0f56, + 0xf0f57: 0xf0f57, + 0xf0f58: 0xf0f58, + 0xf0f59: 0xf0f59, + 0xf0f5a: 0xf0f5a, + 0xf0f5b: 0xf0f5b, + 0xf0f5c: 0xf0f5c, + 0xf0f5d: 0xf0f5d, + 0xf0f5e: 0xf0f5e, + 0xf0f5f: 0xf0f5f, + 0xf0f60: 0xf0f60, + 0xf0f61: 0xf0f61, + 0xf0f62: 0xf0f62, + 0xf0f63: 0xf0f63, + 0xf0f64: 0xf0f64, + 0xf0f65: 0xf0f65, + 0xf0f66: 0xf0f66, + 0xf0f67: 0xf0f67, + 0xf0f68: 0xf0f68, + 0xf0f69: 0xf0f69, + 0xf0f6a: 0xf0f6a, + 0xf0f6b: 0xf0f6b, + 0xf0f6c: 0xf0f6c, + 0xf0f6d: 0xf0f6d, + 0xf0f6e: 0xf0f6e, + 0xf0f6f: 0xf0f6f, + 0xf0f70: 0xf0f70, + 0xf0f71: 0xf0f71, + 0xf0f72: 0xf0f72, + 0xf0f73: 0xf0f73, + 0xf0f74: 0xf0f74, + 0xf0f75: 0xf0f75, + 0xf0f76: 0xf0f76, + 0xf0f77: 0xf0f77, + 0xf0f78: 0xf0f78, + 0xf0f79: 0xf0f79, + 0xf0f7a: 0xf0f7a, + 0xf0f7b: 0xf0f7b, + 0xf0f7c: 0xf0f7c, + 0xf0f7d: 0xf0f7d, + 0xf0f7e: 0xf0f7e, + 0xf0f7f: 0xf0f7f, + 0xf0f80: 0xf0f80, + 0xf0f81: 0xf0f81, + 0xf0f82: 0xf0f82, + 0xf0f83: 0xf0f83, + 0xf0f84: 0xf0f84, + 0xf0f85: 0xf0f85, + 0xf0f86: 0xf0f86, + 0xf0f87: 0xf0f87, + 0xf0f88: 0xf0f88, + 0xf0f89: 0xf0f89, + 0xf0f8a: 0xf0f8a, + 0xf0f8b: 0xf0f8b, + 0xf0f8c: 0xf0f8c, + 0xf0f8d: 0xf0f8d, + 0xf0f8e: 0xf0f8e, + 0xf0f8f: 0xf0f8f, + 0xf0f90: 0xf0f90, + 0xf0f91: 0xf0f91, + 0xf0f92: 0xf0f92, + 0xf0f93: 0xf0f93, + 0xf0f94: 0xf0f94, + 0xf0f95: 0xf0f95, + 0xf0f96: 0xf0f96, + 0xf0f97: 0xf0f97, + 0xf0f98: 0xf0f98, + 0xf0f99: 0xf0f99, + 0xf0f9a: 0xf0f9a, + 0xf0f9b: 0xf0f9b, + 0xf0f9c: 0xf0f9c, + 0xf0f9d: 0xf0f9d, + 0xf0f9e: 0xf0f9e, + 0xf0f9f: 0xf0f9f, + 0xf0fa0: 0xf0fa0, + 0xf0fa1: 0xf0fa1, + 0xf0fa2: 0xf0fa2, + 0xf0fa3: 0xf0fa3, + 0xf0fa4: 0xf0fa4, + 0xf0fa5: 0xf0fa5, + 0xf0fa6: 0xf0fa6, + 0xf0fa7: 0xf0fa7, + 0xf0fa8: 0xf0fa8, + 0xf0fa9: 0xf0fa9, + 0xf0faa: 0xf0faa, + 0xf0fab: 0xf0fab, + 0xf0fac: 0xf0fac, + 0xf0fad: 0xf0fad, + 0xf0fae: 0xf0fae, + 0xf0faf: 0xf0faf, + 0xf0fb0: 0xf0fb0, + 0xf0fb1: 0xf0fb1, + 0xf0fb2: 0xf0fb2, + 0xf0fb3: 0xf0fb3, + 0xf0fb4: 0xf0fb4, + 0xf0fb5: 0xf0fb5, + 0xf0fb6: 0xf0fb6, + 0xf0fb7: 0xf0fb7, + 0xf0fb8: 0xf0fb8, + 0xf0fb9: 0xf0fb9, + 0xf0fba: 0xf0fba, + 0xf0fbb: 0xf0fbb, + 0xf0fbc: 0xf0fbc, + 0xf0fbd: 0xf0fbd, + 0xf0fbe: 0xf0fbe, + 0xf0fbf: 0xf0fbf, + 0xf0fc0: 0xf0fc0, + 0xf0fc1: 0xf0fc1, + 0xf0fc2: 0xf0fc2, + 0xf0fc3: 0xf0fc3, + 0xf0fc4: 0xf0fc4, + 0xf0fc5: 0xf0fc5, + 0xf0fc6: 0xf0fc6, + 0xf0fc7: 0xf0fc7, + 0xf0fc8: 0xf0fc8, + 0xf0fc9: 0xf0fc9, + 0xf0fca: 0xf0fca, + 0xf0fcb: 0xf0fcb, + 0xf0fcc: 0xf0fcc, + 0xf0fcd: 0xf0fcd, + 0xf0fce: 0xf0fce, + 0xf0fcf: 0xf0fcf, + 0xf0fd0: 0xf0fd0, + 0xf0fd1: 0xf0fd1, + 0xf0fd2: 0xf0fd2, + 0xf0fd3: 0xf0fd3, + 0xf0fd4: 0xf0fd4, + 0xf0fd5: 0xf0fd5, + 0xf0fd6: 0xf0fd6, + 0xf0fd7: 0xf0fd7, + 0xf0fd8: 0xf0fd8, + 0xf0fd9: 0xf0fd9, + 0xf0fda: 0xf0fda, + 0xf0fdb: 0xf0fdb, + 0xf0fdc: 0xf0fdc, + 0xf0fdd: 0xf0fdd, + 0xf0fde: 0xf0fde, + 0xf0fdf: 0xf0fdf, + 0xf0fe0: 0xf0fe0, + 0xf0fe1: 0xf0fe1, + 0xf0fe2: 0xf0fe2, + 0xf0fe3: 0xf0fe3, + 0xf0fe4: 0xf0fe4, + 0xf0fe5: 0xf0fe5, + 0xf0fe6: 0xf0fe6, + 0xf0fe7: 0xf0fe7, + 0xf0fe8: 0xf0fe8, + 0xf0fe9: 0xf0fe9, + 0xf0fea: 0xf0fea, + 0xf0feb: 0xf0feb, + 0xf0fec: 0xf0fec, + 0xf0fed: 0xf0fed, + 0xf0fee: 0xf0fee, + 0xf0fef: 0xf0fef, + 0xf0ff0: 0xf0ff0, + 0xf0ff1: 0xf0ff1, + 0xf0ff2: 0xf0ff2, + 0xf0ff3: 0xf0ff3, + 0xf0ff4: 0xf0ff4, + 0xf0ff5: 0xf0ff5, + 0xf0ff6: 0xf0ff6, + 0xf0ff7: 0xf0ff7, + 0xf0ff8: 0xf0ff8, + 0xf0ff9: 0xf0ff9, + 0xf0ffa: 0xf0ffa, + 0xf0ffb: 0xf0ffb, + 0xf0ffc: 0xf0ffc, + 0xf0ffd: 0xf0ffd, + 0xf0ffe: 0xf0ffe, + 0xf0fff: 0xf0fff, + 0xf1000: 0xf1000, + 0xf1001: 0xf1001, + 0xf1002: 0xf1002, + 0xf1003: 0xf1003, + 0xf1004: 0xf1004, + 0xf1005: 0xf1005, + 0xf1006: 0xf1006, + 0xf1007: 0xf1007, + 0xf1008: 0xf1008, + 0xf1009: 0xf1009, + 0xf100a: 0xf100a, + 0xf100b: 0xf100b, + 0xf100c: 0xf100c, + 0xf100d: 0xf100d, + 0xf100e: 0xf100e, + 0xf100f: 0xf100f, + 0xf1010: 0xf1010, + 0xf1011: 0xf1011, + 0xf1012: 0xf1012, + 0xf1013: 0xf1013, + 0xf1014: 0xf1014, + 0xf1015: 0xf1015, + 0xf1016: 0xf1016, + 0xf1017: 0xf1017, + 0xf1018: 0xf1018, + 0xf1019: 0xf1019, + 0xf101a: 0xf101a, + 0xf101b: 0xf101b, + 0xf101c: 0xf101c, + 0xf101d: 0xf101d, + 0xf101e: 0xf101e, + 0xf101f: 0xf101f, + 0xf1020: 0xf1020, + 0xf1021: 0xf1021, + 0xf1022: 0xf1022, + 0xf1023: 0xf1023, + 0xf1024: 0xf1024, + 0xf1025: 0xf1025, + 0xf1026: 0xf1026, + 0xf1027: 0xf1027, + 0xf1028: 0xf1028, + 0xf1029: 0xf1029, + 0xf102a: 0xf102a, + 0xf102b: 0xf102b, + 0xf102c: 0xf102c, + 0xf102d: 0xf102d, + 0xf102e: 0xf102e, + 0xf102f: 0xf102f, + 0xf1030: 0xf1030, + 0xf1031: 0xf1031, + 0xf1032: 0xf1032, + 0xf1033: 0xf1033, + 0xf1034: 0xf1034, + 0xf1035: 0xf1035, + 0xf1036: 0xf1036, + 0xf1037: 0xf1037, + 0xf1038: 0xf1038, + 0xf1039: 0xf1039, + 0xf103a: 0xf103a, + 0xf103b: 0xf103b, + 0xf103c: 0xf103c, + 0xf103d: 0xf103d, + 0xf103e: 0xf103e, + 0xf103f: 0xf103f, + 0xf1040: 0xf1040, + 0xf1041: 0xf1041, + 0xf1042: 0xf1042, + 0xf1043: 0xf1043, + 0xf1044: 0xf1044, + 0xf1045: 0xf1045, + 0xf1046: 0xf1046, + 0xf1047: 0xf1047, + 0xf1048: 0xf1048, + 0xf1049: 0xf1049, + 0xf104a: 0xf104a, + 0xf104b: 0xf104b, + 0xf104c: 0xf104c, + 0xf104d: 0xf104d, + 0xf104e: 0xf104e, + 0xf104f: 0xf104f, + 0xf1050: 0xf1050, + 0xf1051: 0xf1051, + 0xf1052: 0xf1052, + 0xf1053: 0xf1053, + 0xf1054: 0xf1054, + 0xf1055: 0xf1055, + 0xf1056: 0xf1056, + 0xf1057: 0xf1057, + 0xf1058: 0xf1058, + 0xf1059: 0xf1059, + 0xf105a: 0xf105a, + 0xf105b: 0xf105b, + 0xf105c: 0xf105c, + 0xf105d: 0xf105d, + 0xf105e: 0xf105e, + 0xf105f: 0xf105f, + 0xf1060: 0xf1060, + 0xf1061: 0xf1061, + 0xf1062: 0xf1062, + 0xf1063: 0xf1063, + 0xf1064: 0xf1064, + 0xf1065: 0xf1065, + 0xf1066: 0xf1066, + 0xf1067: 0xf1067, + 0xf1068: 0xf1068, + 0xf1069: 0xf1069, + 0xf106a: 0xf106a, + 0xf106b: 0xf106b, + 0xf106c: 0xf106c, + 0xf106d: 0xf106d, + 0xf106e: 0xf106e, + 0xf106f: 0xf106f, + 0xf1070: 0xf1070, + 0xf1071: 0xf1071, + 0xf1072: 0xf1072, + 0xf1073: 0xf1073, + 0xf1074: 0xf1074, + 0xf1075: 0xf1075, + 0xf1076: 0xf1076, + 0xf1077: 0xf1077, + 0xf1078: 0xf1078, + 0xf1079: 0xf1079, + 0xf107a: 0xf107a, + 0xf107b: 0xf107b, + 0xf107c: 0xf107c, + 0xf107d: 0xf107d, + 0xf107e: 0xf107e, + 0xf107f: 0xf107f, + 0xf1080: 0xf1080, + 0xf1081: 0xf1081, + 0xf1082: 0xf1082, + 0xf1083: 0xf1083, + 0xf1084: 0xf1084, + 0xf1085: 0xf1085, + 0xf1086: 0xf1086, + 0xf1087: 0xf1087, + 0xf1088: 0xf1088, + 0xf1089: 0xf1089, + 0xf108a: 0xf108a, + 0xf108b: 0xf108b, + 0xf108c: 0xf108c, + 0xf108d: 0xf108d, + 0xf108e: 0xf108e, + 0xf108f: 0xf108f, + 0xf1090: 0xf1090, + 0xf1091: 0xf1091, + 0xf1092: 0xf1092, + 0xf1093: 0xf1093, + 0xf1094: 0xf1094, + 0xf1095: 0xf1095, + 0xf1096: 0xf1096, + 0xf1097: 0xf1097, + 0xf1098: 0xf1098, + 0xf1099: 0xf1099, + 0xf109a: 0xf109a, + 0xf109b: 0xf109b, + 0xf109c: 0xf109c, + 0xf109d: 0xf109d, + 0xf109e: 0xf109e, + 0xf109f: 0xf109f, + 0xf10a0: 0xf10a0, + 0xf10a1: 0xf10a1, + 0xf10a2: 0xf10a2, + 0xf10a3: 0xf10a3, + 0xf10a4: 0xf10a4, + 0xf10a5: 0xf10a5, + 0xf10a6: 0xf10a6, + 0xf10a7: 0xf10a7, + 0xf10a8: 0xf10a8, + 0xf10a9: 0xf10a9, + 0xf10aa: 0xf10aa, + 0xf10ab: 0xf10ab, + 0xf10ac: 0xf10ac, + 0xf10ad: 0xf10ad, + 0xf10ae: 0xf10ae, + 0xf10af: 0xf10af, + 0xf10b0: 0xf10b0, + 0xf10b1: 0xf10b1, + 0xf10b2: 0xf10b2, + 0xf10b3: 0xf10b3, + 0xf10b4: 0xf10b4, + 0xf10b5: 0xf10b5, + 0xf10b6: 0xf10b6, + 0xf10b7: 0xf10b7, + 0xf10b8: 0xf10b8, + 0xf10b9: 0xf10b9, + 0xf10ba: 0xf10ba, + 0xf10bb: 0xf10bb, + 0xf10bc: 0xf10bc, + 0xf10bd: 0xf10bd, + 0xf10be: 0xf10be, + 0xf10bf: 0xf10bf, + 0xf10c0: 0xf10c0, + 0xf10c1: 0xf10c1, + 0xf10c2: 0xf10c2, + 0xf10c3: 0xf10c3, + 0xf10c4: 0xf10c4, + 0xf10c5: 0xf10c5, + 0xf10c6: 0xf10c6, + 0xf10c7: 0xf10c7, + 0xf10c8: 0xf10c8, + 0xf10c9: 0xf10c9, + 0xf10ca: 0xf10ca, + 0xf10cb: 0xf10cb, + 0xf10cc: 0xf10cc, + 0xf10cd: 0xf10cd, + 0xf10ce: 0xf10ce, + 0xf10cf: 0xf10cf, + 0xf10d0: 0xf10d0, + 0xf10d1: 0xf10d1, + 0xf10d2: 0xf10d2, + 0xf10d3: 0xf10d3, + 0xf10d4: 0xf10d4, + 0xf10d5: 0xf10d5, + 0xf10d6: 0xf10d6, + 0xf10d7: 0xf10d7, + 0xf10d8: 0xf10d8, + 0xf10d9: 0xf10d9, + 0xf10da: 0xf10da, + 0xf10db: 0xf10db, + 0xf10dc: 0xf10dc, + 0xf10dd: 0xf10dd, + 0xf10de: 0xf10de, + 0xf10df: 0xf10df, + 0xf10e0: 0xf10e0, + 0xf10e1: 0xf10e1, + 0xf10e2: 0xf10e2, + 0xf10e3: 0xf10e3, + 0xf10e4: 0xf10e4, + 0xf10e5: 0xf10e5, + 0xf10e6: 0xf10e6, + 0xf10e7: 0xf10e7, + 0xf10e8: 0xf10e8, + 0xf10e9: 0xf10e9, + 0xf10ea: 0xf10ea, + 0xf10eb: 0xf10eb, + 0xf10ec: 0xf10ec, + 0xf10ed: 0xf10ed, + 0xf10ee: 0xf10ee, + 0xf10ef: 0xf10ef, + 0xf10f0: 0xf10f0, + 0xf10f1: 0xf10f1, + 0xf10f2: 0xf10f2, + 0xf10f3: 0xf10f3, + 0xf10f4: 0xf10f4, + 0xf10f5: 0xf10f5, + 0xf10f6: 0xf10f6, + 0xf10f7: 0xf10f7, + 0xf10f8: 0xf10f8, + 0xf10f9: 0xf10f9, + 0xf10fa: 0xf10fa, + 0xf10fb: 0xf10fb, + 0xf10fc: 0xf10fc, + 0xf10fd: 0xf10fd, + 0xf10fe: 0xf10fe, + 0xf10ff: 0xf10ff, + 0xf1100: 0xf1100, + 0xf1101: 0xf1101, + 0xf1102: 0xf1102, + 0xf1103: 0xf1103, + 0xf1104: 0xf1104, + 0xf1105: 0xf1105, + 0xf1106: 0xf1106, + 0xf1107: 0xf1107, + 0xf1108: 0xf1108, + 0xf1109: 0xf1109, + 0xf110a: 0xf110a, + 0xf110b: 0xf110b, + 0xf110c: 0xf110c, + 0xf110d: 0xf110d, + 0xf110e: 0xf110e, + 0xf110f: 0xf110f, + 0xf1110: 0xf1110, + 0xf1111: 0xf1111, + 0xf1112: 0xf1112, + 0xf1113: 0xf1113, + 0xf1114: 0xf1114, + 0xf1115: 0xf1115, + 0xf1116: 0xf1116, + 0xf1117: 0xf1117, + 0xf1118: 0xf1118, + 0xf1119: 0xf1119, + 0xf111a: 0xf111a, + 0xf111b: 0xf111b, + 0xf111c: 0xf111c, + 0xf111d: 0xf111d, + 0xf111e: 0xf111e, + 0xf111f: 0xf111f, + 0xf1120: 0xf1120, + 0xf1121: 0xf1121, + 0xf1122: 0xf1122, + 0xf1123: 0xf1123, + 0xf1124: 0xf1124, + 0xf1125: 0xf1125, + 0xf1126: 0xf1126, + 0xf1127: 0xf1127, + 0xf1128: 0xf1128, + 0xf1129: 0xf1129, + 0xf112a: 0xf112a, + 0xf112b: 0xf112b, + 0xf112c: 0xf112c, + 0xf112d: 0xf112d, + 0xf112e: 0xf112e, + 0xf112f: 0xf112f, + 0xf1130: 0xf1130, + 0xf1131: 0xf1131, + 0xf1132: 0xf1132, + 0xf1133: 0xf1133, + 0xf1134: 0xf1134, + 0xf1135: 0xf1135, + 0xf1136: 0xf1136, + 0xf1137: 0xf1137, + 0xf1138: 0xf1138, + 0xf1139: 0xf1139, + 0xf113a: 0xf113a, + 0xf113b: 0xf113b, + 0xf113c: 0xf113c, + 0xf113d: 0xf113d, + 0xf113e: 0xf113e, + 0xf113f: 0xf113f, + 0xf1140: 0xf1140, + 0xf1141: 0xf1141, + 0xf1142: 0xf1142, + 0xf1143: 0xf1143, + 0xf1144: 0xf1144, + 0xf1145: 0xf1145, + 0xf1146: 0xf1146, + 0xf1147: 0xf1147, + 0xf1148: 0xf1148, + 0xf1149: 0xf1149, + 0xf114a: 0xf114a, + 0xf114b: 0xf114b, + 0xf114c: 0xf114c, + 0xf114d: 0xf114d, + 0xf114e: 0xf114e, + 0xf114f: 0xf114f, + 0xf1150: 0xf1150, + 0xf1151: 0xf1151, + 0xf1152: 0xf1152, + 0xf1153: 0xf1153, + 0xf1154: 0xf1154, + 0xf1155: 0xf1155, + 0xf1156: 0xf1156, + 0xf1157: 0xf1157, + 0xf1158: 0xf1158, + 0xf1159: 0xf1159, + 0xf115a: 0xf115a, + 0xf115b: 0xf115b, + 0xf115c: 0xf115c, + 0xf115d: 0xf115d, + 0xf115e: 0xf115e, + 0xf115f: 0xf115f, + 0xf1160: 0xf1160, + 0xf1161: 0xf1161, + 0xf1162: 0xf1162, + 0xf1163: 0xf1163, + 0xf1164: 0xf1164, + 0xf1165: 0xf1165, + 0xf1166: 0xf1166, + 0xf1167: 0xf1167, + 0xf1168: 0xf1168, + 0xf1169: 0xf1169, + 0xf116a: 0xf116a, + 0xf116b: 0xf116b, + 0xf116c: 0xf116c, + 0xf116d: 0xf116d, + 0xf116e: 0xf116e, + 0xf116f: 0xf116f, + 0xf1170: 0xf1170, + 0xf1171: 0xf1171, + 0xf1172: 0xf1172, + 0xf1173: 0xf1173, + 0xf1174: 0xf1174, + 0xf1175: 0xf1175, + 0xf1176: 0xf1176, + 0xf1177: 0xf1177, + 0xf1178: 0xf1178, + 0xf1179: 0xf1179, + 0xf117a: 0xf117a, + 0xf117b: 0xf117b, + 0xf117c: 0xf117c, + 0xf117d: 0xf117d, + 0xf117e: 0xf117e, + 0xf117f: 0xf117f, + 0xf1180: 0xf1180, + 0xf1181: 0xf1181, + 0xf1182: 0xf1182, + 0xf1183: 0xf1183, + 0xf1184: 0xf1184, + 0xf1185: 0xf1185, + 0xf1186: 0xf1186, + 0xf1187: 0xf1187, + 0xf1188: 0xf1188, + 0xf1189: 0xf1189, + 0xf118a: 0xf118a, + 0xf118b: 0xf118b, + 0xf118c: 0xf118c, + 0xf118d: 0xf118d, + 0xf118e: 0xf118e, + 0xf118f: 0xf118f, + 0xf1190: 0xf1190, + 0xf1191: 0xf1191, + 0xf1192: 0xf1192, + 0xf1193: 0xf1193, + 0xf1194: 0xf1194, + 0xf1195: 0xf1195, + 0xf1196: 0xf1196, + 0xf1197: 0xf1197, + 0xf1198: 0xf1198, + 0xf1199: 0xf1199, + 0xf119a: 0xf119a, + 0xf119b: 0xf119b, + 0xf119c: 0xf119c, + 0xf119d: 0xf119d, + 0xf119e: 0xf119e, + 0xf119f: 0xf119f, + 0xf11a0: 0xf11a0, + 0xf11a1: 0xf11a1, + 0xf11a2: 0xf11a2, + 0xf11a3: 0xf11a3, + 0xf11a4: 0xf11a4, + 0xf11a5: 0xf11a5, + 0xf11a6: 0xf11a6, + 0xf11a7: 0xf11a7, + 0xf11a8: 0xf11a8, + 0xf11a9: 0xf11a9, + 0xf11aa: 0xf11aa, + 0xf11ab: 0xf11ab, + 0xf11ac: 0xf11ac, + 0xf11ad: 0xf11ad, + 0xf11ae: 0xf11ae, + 0xf11af: 0xf11af, + 0xf11b0: 0xf11b0, + 0xf11b1: 0xf11b1, + 0xf11b2: 0xf11b2, + 0xf11b3: 0xf11b3, + 0xf11b4: 0xf11b4, + 0xf11b5: 0xf11b5, + 0xf11b6: 0xf11b6, + 0xf11b7: 0xf11b7, + 0xf11b8: 0xf11b8, + 0xf11b9: 0xf11b9, + 0xf11ba: 0xf11ba, + 0xf11bb: 0xf11bb, + 0xf11bc: 0xf11bc, + 0xf11bd: 0xf11bd, + 0xf11be: 0xf11be, + 0xf11bf: 0xf11bf, + 0xf11c0: 0xf11c0, + 0xf11c1: 0xf11c1, + 0xf11c2: 0xf11c2, + 0xf11c3: 0xf11c3, + 0xf11c4: 0xf11c4, + 0xf11c5: 0xf11c5, + 0xf11c6: 0xf11c6, + 0xf11c7: 0xf11c7, + 0xf11c8: 0xf11c8, + 0xf11c9: 0xf11c9, + 0xf11ca: 0xf11ca, + 0xf11cb: 0xf11cb, + 0xf11cc: 0xf11cc, + 0xf11cd: 0xf11cd, + 0xf11ce: 0xf11ce, + 0xf11cf: 0xf11cf, + 0xf11d0: 0xf11d0, + 0xf11d1: 0xf11d1, + 0xf11d2: 0xf11d2, + 0xf11d3: 0xf11d3, + 0xf11d4: 0xf11d4, + 0xf11d5: 0xf11d5, + 0xf11d6: 0xf11d6, + 0xf11d7: 0xf11d7, + 0xf11d8: 0xf11d8, + 0xf11d9: 0xf11d9, + 0xf11da: 0xf11da, + 0xf11db: 0xf11db, + 0xf11dc: 0xf11dc, + 0xf11dd: 0xf11dd, + 0xf11de: 0xf11de, + 0xf11df: 0xf11df, + 0xf11e0: 0xf11e0, + 0xf11e1: 0xf11e1, + 0xf11e2: 0xf11e2, + 0xf11e3: 0xf11e3, + 0xf11e4: 0xf11e4, + 0xf11e5: 0xf11e5, + 0xf11e6: 0xf11e6, + 0xf11e7: 0xf11e7, + 0xf11e8: 0xf11e8, + 0xf11e9: 0xf11e9, + 0xf11ea: 0xf11ea, + 0xf11eb: 0xf11eb, + 0xf11ec: 0xf11ec, + 0xf11ed: 0xf11ed, + 0xf11ee: 0xf11ee, + 0xf11ef: 0xf11ef, + 0xf11f0: 0xf11f0, + 0xf11f1: 0xf11f1, + 0xf11f2: 0xf11f2, + 0xf11f3: 0xf11f3, + 0xf11f4: 0xf11f4, + 0xf11f5: 0xf11f5, + 0xf11f6: 0xf11f6, + 0xf11f7: 0xf11f7, + 0xf11f8: 0xf11f8, + 0xf11f9: 0xf11f9, + 0xf11fa: 0xf11fa, + 0xf11fb: 0xf11fb, + 0xf11fc: 0xf11fc, + 0xf11fd: 0xf11fd, + 0xf11fe: 0xf11fe, + 0xf11ff: 0xf11ff, + 0xf1200: 0xf1200, + 0xf1201: 0xf1201, + 0xf1202: 0xf1202, + 0xf1203: 0xf1203, + 0xf1204: 0xf1204, + 0xf1205: 0xf1205, + 0xf1206: 0xf1206, + 0xf1207: 0xf1207, + 0xf1208: 0xf1208, + 0xf1209: 0xf1209, + 0xf120a: 0xf120a, + 0xf120b: 0xf120b, + 0xf120c: 0xf120c, + 0xf120d: 0xf120d, + 0xf120e: 0xf120e, + 0xf120f: 0xf120f, + 0xf1210: 0xf1210, + 0xf1211: 0xf1211, + 0xf1212: 0xf1212, + 0xf1213: 0xf1213, + 0xf1214: 0xf1214, + 0xf1215: 0xf1215, + 0xf1216: 0xf1216, + 0xf1217: 0xf1217, + 0xf1218: 0xf1218, + 0xf1219: 0xf1219, + 0xf121a: 0xf121a, + 0xf121b: 0xf121b, + 0xf121c: 0xf121c, + 0xf121d: 0xf121d, + 0xf121e: 0xf121e, + 0xf121f: 0xf121f, + 0xf1220: 0xf1220, + 0xf1221: 0xf1221, + 0xf1222: 0xf1222, + 0xf1223: 0xf1223, + 0xf1224: 0xf1224, + 0xf1225: 0xf1225, + 0xf1226: 0xf1226, + 0xf1227: 0xf1227, + 0xf1228: 0xf1228, + 0xf1229: 0xf1229, + 0xf122a: 0xf122a, + 0xf122b: 0xf122b, + 0xf122c: 0xf122c, + 0xf122d: 0xf122d, + 0xf122e: 0xf122e, + 0xf122f: 0xf122f, + 0xf1230: 0xf1230, + 0xf1231: 0xf1231, + 0xf1232: 0xf1232, + 0xf1233: 0xf1233, + 0xf1234: 0xf1234, + 0xf1235: 0xf1235, + 0xf1236: 0xf1236, + 0xf1237: 0xf1237, + 0xf1238: 0xf1238, + 0xf1239: 0xf1239, + 0xf123a: 0xf123a, + 0xf123b: 0xf123b, + 0xf123c: 0xf123c, + 0xf123d: 0xf123d, + 0xf123e: 0xf123e, + 0xf123f: 0xf123f, + 0xf1240: 0xf1240, + 0xf1241: 0xf1241, + 0xf1242: 0xf1242, + 0xf1243: 0xf1243, + 0xf1244: 0xf1244, + 0xf1245: 0xf1245, + 0xf1246: 0xf1246, + 0xf1247: 0xf1247, + 0xf1248: 0xf1248, + 0xf1249: 0xf1249, + 0xf124a: 0xf124a, + 0xf124b: 0xf124b, + 0xf124c: 0xf124c, + 0xf124d: 0xf124d, + 0xf124e: 0xf124e, + 0xf124f: 0xf124f, + 0xf1250: 0xf1250, + 0xf1251: 0xf1251, + 0xf1252: 0xf1252, + 0xf1253: 0xf1253, + 0xf1254: 0xf1254, + 0xf1255: 0xf1255, + 0xf1256: 0xf1256, + 0xf1257: 0xf1257, + 0xf1258: 0xf1258, + 0xf1259: 0xf1259, + 0xf125a: 0xf125a, + 0xf125b: 0xf125b, + 0xf125c: 0xf125c, + 0xf125d: 0xf125d, + 0xf125e: 0xf125e, + 0xf125f: 0xf125f, + 0xf1260: 0xf1260, + 0xf1261: 0xf1261, + 0xf1262: 0xf1262, + 0xf1263: 0xf1263, + 0xf1264: 0xf1264, + 0xf1265: 0xf1265, + 0xf1266: 0xf1266, + 0xf1267: 0xf1267, + 0xf1268: 0xf1268, + 0xf1269: 0xf1269, + 0xf126a: 0xf126a, + 0xf126b: 0xf126b, + 0xf126c: 0xf126c, + 0xf126d: 0xf126d, + 0xf126e: 0xf126e, + 0xf126f: 0xf126f, + 0xf1270: 0xf1270, + 0xf1271: 0xf1271, + 0xf1272: 0xf1272, + 0xf1273: 0xf1273, + 0xf1274: 0xf1274, + 0xf1275: 0xf1275, + 0xf1276: 0xf1276, + 0xf1277: 0xf1277, + 0xf1278: 0xf1278, + 0xf1279: 0xf1279, + 0xf127a: 0xf127a, + 0xf127b: 0xf127b, + 0xf127c: 0xf127c, + 0xf127d: 0xf127d, + 0xf127e: 0xf127e, + 0xf127f: 0xf127f, + 0xf1280: 0xf1280, + 0xf1281: 0xf1281, + 0xf1282: 0xf1282, + 0xf1283: 0xf1283, + 0xf1284: 0xf1284, + 0xf1285: 0xf1285, + 0xf1286: 0xf1286, + 0xf1287: 0xf1287, + 0xf1288: 0xf1288, + 0xf1289: 0xf1289, + 0xf128a: 0xf128a, + 0xf128b: 0xf128b, + 0xf128c: 0xf128c, + 0xf128d: 0xf128d, + 0xf128e: 0xf128e, + 0xf128f: 0xf128f, + 0xf1290: 0xf1290, + 0xf1291: 0xf1291, + 0xf1292: 0xf1292, + 0xf1293: 0xf1293, + 0xf1294: 0xf1294, + 0xf1295: 0xf1295, + 0xf1296: 0xf1296, + 0xf1297: 0xf1297, + 0xf1298: 0xf1298, + 0xf1299: 0xf1299, + 0xf129a: 0xf129a, + 0xf129b: 0xf129b, + 0xf129c: 0xf129c, + 0xf129d: 0xf129d, + 0xf129e: 0xf129e, + 0xf129f: 0xf129f, + 0xf12a0: 0xf12a0, + 0xf12a1: 0xf12a1, + 0xf12a2: 0xf12a2, + 0xf12a3: 0xf12a3, + 0xf12a4: 0xf12a4, + 0xf12a5: 0xf12a5, + 0xf12a6: 0xf12a6, + 0xf12a7: 0xf12a7, + 0xf12a8: 0xf12a8, + 0xf12a9: 0xf12a9, + 0xf12aa: 0xf12aa, + 0xf12ab: 0xf12ab, + 0xf12ac: 0xf12ac, + 0xf12ad: 0xf12ad, + 0xf12ae: 0xf12ae, + 0xf12af: 0xf12af, + 0xf12b0: 0xf12b0, + 0xf12b1: 0xf12b1, + 0xf12b2: 0xf12b2, + 0xf12b3: 0xf12b3, + 0xf12b4: 0xf12b4, + 0xf12b5: 0xf12b5, + 0xf12b6: 0xf12b6, + 0xf12b7: 0xf12b7, + 0xf12b8: 0xf12b8, + 0xf12b9: 0xf12b9, + 0xf12ba: 0xf12ba, + 0xf12bb: 0xf12bb, + 0xf12bc: 0xf12bc, + 0xf12bd: 0xf12bd, + 0xf12be: 0xf12be, + 0xf12bf: 0xf12bf, + 0xf12c0: 0xf12c0, + 0xf12c1: 0xf12c1, + 0xf12c2: 0xf12c2, + 0xf12c3: 0xf12c3, + 0xf12c4: 0xf12c4, + 0xf12c5: 0xf12c5, + 0xf12c6: 0xf12c6, + 0xf12c7: 0xf12c7, + 0xf12c8: 0xf12c8, + 0xf12c9: 0xf12c9, + 0xf12ca: 0xf12ca, + 0xf12cb: 0xf12cb, + 0xf12cc: 0xf12cc, + 0xf12cd: 0xf12cd, + 0xf12ce: 0xf12ce, + 0xf12cf: 0xf12cf, + 0xf12d0: 0xf12d0, + 0xf12d1: 0xf12d1, + 0xf12d2: 0xf12d2, + 0xf12d3: 0xf12d3, + 0xf12d4: 0xf12d4, + 0xf12d5: 0xf12d5, + 0xf12d6: 0xf12d6, + 0xf12d7: 0xf12d7, + 0xf12d8: 0xf12d8, + 0xf12d9: 0xf12d9, + 0xf12da: 0xf12da, + 0xf12db: 0xf12db, + 0xf12dc: 0xf12dc, + 0xf12dd: 0xf12dd, + 0xf12de: 0xf12de, + 0xf12df: 0xf12df, + 0xf12e0: 0xf12e0, + 0xf12e1: 0xf12e1, + 0xf12e2: 0xf12e2, + 0xf12e3: 0xf12e3, + 0xf12e4: 0xf12e4, + 0xf12e5: 0xf12e5, + 0xf12e6: 0xf12e6, + 0xf12e7: 0xf12e7, + 0xf12e8: 0xf12e8, + 0xf12e9: 0xf12e9, + 0xf12ea: 0xf12ea, + 0xf12eb: 0xf12eb, + 0xf12ec: 0xf12ec, + 0xf12ed: 0xf12ed, + 0xf12ee: 0xf12ee, + 0xf12ef: 0xf12ef, + 0xf12f0: 0xf12f0, + 0xf12f1: 0xf12f1, + 0xf12f2: 0xf12f2, + 0xf12f3: 0xf12f3, + 0xf12f4: 0xf12f4, + 0xf12f5: 0xf12f5, + 0xf12f6: 0xf12f6, + 0xf12f7: 0xf12f7, + 0xf12f8: 0xf12f8, + 0xf12f9: 0xf12f9, + 0xf12fa: 0xf12fa, + 0xf12fb: 0xf12fb, + 0xf12fc: 0xf12fc, + 0xf12fd: 0xf12fd, + 0xf12fe: 0xf12fe, + 0xf12ff: 0xf12ff, + 0xf1300: 0xf1300, + 0xf1301: 0xf1301, + 0xf1302: 0xf1302, + 0xf1303: 0xf1303, + 0xf1304: 0xf1304, + 0xf1305: 0xf1305, + 0xf1306: 0xf1306, + 0xf1307: 0xf1307, + 0xf1308: 0xf1308, + 0xf1309: 0xf1309, + 0xf130a: 0xf130a, + 0xf130b: 0xf130b, + 0xf130c: 0xf130c, + 0xf130d: 0xf130d, + 0xf130e: 0xf130e, + 0xf130f: 0xf130f, + 0xf1310: 0xf1310, + 0xf1311: 0xf1311, + 0xf1312: 0xf1312, + 0xf1313: 0xf1313, + 0xf1314: 0xf1314, + 0xf1315: 0xf1315, + 0xf1316: 0xf1316, + 0xf1317: 0xf1317, + 0xf1318: 0xf1318, + 0xf1319: 0xf1319, + 0xf131a: 0xf131a, + 0xf131b: 0xf131b, + 0xf131c: 0xf131c, + 0xf131d: 0xf131d, + 0xf131e: 0xf131e, + 0xf131f: 0xf131f, + 0xf1320: 0xf1320, + 0xf1321: 0xf1321, + 0xf1322: 0xf1322, + 0xf1323: 0xf1323, + 0xf1324: 0xf1324, + 0xf1325: 0xf1325, + 0xf1326: 0xf1326, + 0xf1327: 0xf1327, + 0xf1328: 0xf1328, + 0xf1329: 0xf1329, + 0xf132a: 0xf132a, + 0xf132b: 0xf132b, + 0xf132c: 0xf132c, + 0xf132d: 0xf132d, + 0xf132e: 0xf132e, + 0xf132f: 0xf132f, + 0xf1330: 0xf1330, + 0xf1331: 0xf1331, + 0xf1332: 0xf1332, + 0xf1333: 0xf1333, + 0xf1334: 0xf1334, + 0xf1335: 0xf1335, + 0xf1336: 0xf1336, + 0xf1337: 0xf1337, + 0xf1338: 0xf1338, + 0xf1339: 0xf1339, + 0xf133a: 0xf133a, + 0xf133b: 0xf133b, + 0xf133c: 0xf133c, + 0xf133d: 0xf133d, + 0xf133e: 0xf133e, + 0xf133f: 0xf133f, + 0xf1340: 0xf1340, + 0xf1341: 0xf1341, + 0xf1342: 0xf1342, + 0xf1343: 0xf1343, + 0xf1344: 0xf1344, + 0xf1345: 0xf1345, + 0xf1346: 0xf1346, + 0xf1347: 0xf1347, + 0xf1348: 0xf1348, + 0xf1349: 0xf1349, + 0xf134a: 0xf134a, + 0xf134b: 0xf134b, + 0xf134c: 0xf134c, + 0xf134d: 0xf134d, + 0xf134e: 0xf134e, + 0xf134f: 0xf134f, + 0xf1350: 0xf1350, + 0xf1351: 0xf1351, + 0xf1352: 0xf1352, + 0xf1353: 0xf1353, + 0xf1354: 0xf1354, + 0xf1355: 0xf1355, + 0xf1356: 0xf1356, + 0xf1357: 0xf1357, + 0xf1358: 0xf1358, + 0xf1359: 0xf1359, + 0xf135a: 0xf135a, + 0xf135b: 0xf135b, + 0xf135c: 0xf135c, + 0xf135d: 0xf135d, + 0xf135e: 0xf135e, + 0xf135f: 0xf135f, + 0xf1360: 0xf1360, + 0xf1361: 0xf1361, + 0xf1362: 0xf1362, + 0xf1363: 0xf1363, + 0xf1364: 0xf1364, + 0xf1365: 0xf1365, + 0xf1366: 0xf1366, + 0xf1367: 0xf1367, + 0xf1368: 0xf1368, + 0xf1369: 0xf1369, + 0xf136a: 0xf136a, + 0xf136b: 0xf136b, + 0xf136c: 0xf136c, + 0xf136d: 0xf136d, + 0xf136e: 0xf136e, + 0xf136f: 0xf136f, + 0xf1370: 0xf1370, + 0xf1371: 0xf1371, + 0xf1372: 0xf1372, + 0xf1373: 0xf1373, + 0xf1374: 0xf1374, + 0xf1375: 0xf1375, + 0xf1376: 0xf1376, + 0xf1377: 0xf1377, + 0xf1378: 0xf1378, + 0xf1379: 0xf1379, + 0xf137a: 0xf137a, + 0xf137b: 0xf137b, + 0xf137c: 0xf137c, + 0xf137d: 0xf137d, + 0xf137e: 0xf137e, + 0xf137f: 0xf137f, + 0xf1380: 0xf1380, + 0xf1381: 0xf1381, + 0xf1382: 0xf1382, + 0xf1383: 0xf1383, + 0xf1384: 0xf1384, + 0xf1385: 0xf1385, + 0xf1386: 0xf1386, + 0xf1387: 0xf1387, + 0xf1388: 0xf1388, + 0xf1389: 0xf1389, + 0xf138a: 0xf138a, + 0xf138b: 0xf138b, + 0xf138c: 0xf138c, + 0xf138d: 0xf138d, + 0xf138e: 0xf138e, + 0xf138f: 0xf138f, + 0xf1390: 0xf1390, + 0xf1391: 0xf1391, + 0xf1392: 0xf1392, + 0xf1393: 0xf1393, + 0xf1394: 0xf1394, + 0xf1395: 0xf1395, + 0xf1396: 0xf1396, + 0xf1397: 0xf1397, + 0xf1398: 0xf1398, + 0xf1399: 0xf1399, + 0xf139a: 0xf139a, + 0xf139b: 0xf139b, + 0xf139c: 0xf139c, + 0xf139d: 0xf139d, + 0xf139e: 0xf139e, + 0xf139f: 0xf139f, + 0xf13a0: 0xf13a0, + 0xf13a1: 0xf13a1, + 0xf13a2: 0xf13a2, + 0xf13a3: 0xf13a3, + 0xf13a4: 0xf13a4, + 0xf13a5: 0xf13a5, + 0xf13a6: 0xf13a6, + 0xf13a7: 0xf13a7, + 0xf13a8: 0xf13a8, + 0xf13a9: 0xf13a9, + 0xf13aa: 0xf13aa, + 0xf13ab: 0xf13ab, + 0xf13ac: 0xf13ac, + 0xf13ad: 0xf13ad, + 0xf13ae: 0xf13ae, + 0xf13af: 0xf13af, + 0xf13b0: 0xf13b0, + 0xf13b1: 0xf13b1, + 0xf13b2: 0xf13b2, + 0xf13b3: 0xf13b3, + 0xf13b4: 0xf13b4, + 0xf13b5: 0xf13b5, + 0xf13b6: 0xf13b6, + 0xf13b7: 0xf13b7, + 0xf13b8: 0xf13b8, + 0xf13b9: 0xf13b9, + 0xf13ba: 0xf13ba, + 0xf13bb: 0xf13bb, + 0xf13bc: 0xf13bc, + 0xf13bd: 0xf13bd, + 0xf13be: 0xf13be, + 0xf13bf: 0xf13bf, + 0xf13c0: 0xf13c0, + 0xf13c1: 0xf13c1, + 0xf13c2: 0xf13c2, + 0xf13c3: 0xf13c3, + 0xf13c4: 0xf13c4, + 0xf13c5: 0xf13c5, + 0xf13c6: 0xf13c6, + 0xf13c7: 0xf13c7, + 0xf13c8: 0xf13c8, + 0xf13c9: 0xf13c9, + 0xf13ca: 0xf13ca, + 0xf13cb: 0xf13cb, + 0xf13cc: 0xf13cc, + 0xf13cd: 0xf13cd, + 0xf13ce: 0xf13ce, + 0xf13cf: 0xf13cf, + 0xf13d0: 0xf13d0, + 0xf13d1: 0xf13d1, + 0xf13d2: 0xf13d2, + 0xf13d3: 0xf13d3, + 0xf13d4: 0xf13d4, + 0xf13d5: 0xf13d5, + 0xf13d6: 0xf13d6, + 0xf13d7: 0xf13d7, + 0xf13d8: 0xf13d8, + 0xf13d9: 0xf13d9, + 0xf13da: 0xf13da, + 0xf13db: 0xf13db, + 0xf13dc: 0xf13dc, + 0xf13dd: 0xf13dd, + 0xf13de: 0xf13de, + 0xf13df: 0xf13df, + 0xf13e0: 0xf13e0, + 0xf13e1: 0xf13e1, + 0xf13e2: 0xf13e2, + 0xf13e3: 0xf13e3, + 0xf13e4: 0xf13e4, + 0xf13e5: 0xf13e5, + 0xf13e6: 0xf13e6, + 0xf13e7: 0xf13e7, + 0xf13e8: 0xf13e8, + 0xf13e9: 0xf13e9, + 0xf13ea: 0xf13ea, + 0xf13eb: 0xf13eb, + 0xf13ec: 0xf13ec, + 0xf13ed: 0xf13ed, + 0xf13ee: 0xf13ee, + 0xf13ef: 0xf13ef, + 0xf13f0: 0xf13f0, + 0xf13f1: 0xf13f1, + 0xf13f2: 0xf13f2, + 0xf13f3: 0xf13f3, + 0xf13f4: 0xf13f4, + 0xf13f5: 0xf13f5, + 0xf13f6: 0xf13f6, + 0xf13f7: 0xf13f7, + 0xf13f8: 0xf13f8, + 0xf13f9: 0xf13f9, + 0xf13fa: 0xf13fa, + 0xf13fb: 0xf13fb, + 0xf13fc: 0xf13fc, + 0xf13fd: 0xf13fd, + 0xf13fe: 0xf13fe, + 0xf13ff: 0xf13ff, + 0xf1400: 0xf1400, + 0xf1401: 0xf1401, + 0xf1402: 0xf1402, + 0xf1403: 0xf1403, + 0xf1404: 0xf1404, + 0xf1405: 0xf1405, + 0xf1406: 0xf1406, + 0xf1407: 0xf1407, + 0xf1408: 0xf1408, + 0xf1409: 0xf1409, + 0xf140a: 0xf140a, + 0xf140b: 0xf140b, + 0xf140c: 0xf140c, + 0xf140d: 0xf140d, + 0xf140e: 0xf140e, + 0xf140f: 0xf140f, + 0xf1410: 0xf1410, + 0xf1411: 0xf1411, + 0xf1412: 0xf1412, + 0xf1413: 0xf1413, + 0xf1414: 0xf1414, + 0xf1415: 0xf1415, + 0xf1416: 0xf1416, + 0xf1417: 0xf1417, + 0xf1418: 0xf1418, + 0xf1419: 0xf1419, + 0xf141a: 0xf141a, + 0xf141b: 0xf141b, + 0xf141c: 0xf141c, + 0xf141d: 0xf141d, + 0xf141e: 0xf141e, + 0xf141f: 0xf141f, + 0xf1420: 0xf1420, + 0xf1421: 0xf1421, + 0xf1422: 0xf1422, + 0xf1423: 0xf1423, + 0xf1424: 0xf1424, + 0xf1425: 0xf1425, + 0xf1426: 0xf1426, + 0xf1427: 0xf1427, + 0xf1428: 0xf1428, + 0xf1429: 0xf1429, + 0xf142a: 0xf142a, + 0xf142b: 0xf142b, + 0xf142c: 0xf142c, + 0xf142d: 0xf142d, + 0xf142e: 0xf142e, + 0xf142f: 0xf142f, + 0xf1430: 0xf1430, + 0xf1431: 0xf1431, + 0xf1432: 0xf1432, + 0xf1433: 0xf1433, + 0xf1434: 0xf1434, + 0xf1435: 0xf1435, + 0xf1436: 0xf1436, + 0xf1437: 0xf1437, + 0xf1438: 0xf1438, + 0xf1439: 0xf1439, + 0xf143a: 0xf143a, + 0xf143b: 0xf143b, + 0xf143c: 0xf143c, + 0xf143d: 0xf143d, + 0xf143e: 0xf143e, + 0xf143f: 0xf143f, + 0xf1440: 0xf1440, + 0xf1441: 0xf1441, + 0xf1442: 0xf1442, + 0xf1443: 0xf1443, + 0xf1444: 0xf1444, + 0xf1445: 0xf1445, + 0xf1446: 0xf1446, + 0xf1447: 0xf1447, + 0xf1448: 0xf1448, + 0xf1449: 0xf1449, + 0xf144a: 0xf144a, + 0xf144b: 0xf144b, + 0xf144c: 0xf144c, + 0xf144d: 0xf144d, + 0xf144e: 0xf144e, + 0xf144f: 0xf144f, + 0xf1450: 0xf1450, + 0xf1451: 0xf1451, + 0xf1452: 0xf1452, + 0xf1453: 0xf1453, + 0xf1454: 0xf1454, + 0xf1455: 0xf1455, + 0xf1456: 0xf1456, + 0xf1457: 0xf1457, + 0xf1458: 0xf1458, + 0xf1459: 0xf1459, + 0xf145a: 0xf145a, + 0xf145b: 0xf145b, + 0xf145c: 0xf145c, + 0xf145d: 0xf145d, + 0xf145e: 0xf145e, + 0xf145f: 0xf145f, + 0xf1460: 0xf1460, + 0xf1461: 0xf1461, + 0xf1462: 0xf1462, + 0xf1463: 0xf1463, + 0xf1464: 0xf1464, + 0xf1465: 0xf1465, + 0xf1466: 0xf1466, + 0xf1467: 0xf1467, + 0xf1468: 0xf1468, + 0xf1469: 0xf1469, + 0xf146a: 0xf146a, + 0xf146b: 0xf146b, + 0xf146c: 0xf146c, + 0xf146d: 0xf146d, + 0xf146e: 0xf146e, + 0xf146f: 0xf146f, + 0xf1470: 0xf1470, + 0xf1471: 0xf1471, + 0xf1472: 0xf1472, + 0xf1473: 0xf1473, + 0xf1474: 0xf1474, + 0xf1475: 0xf1475, + 0xf1476: 0xf1476, + 0xf1477: 0xf1477, + 0xf1478: 0xf1478, + 0xf1479: 0xf1479, + 0xf147a: 0xf147a, + 0xf147b: 0xf147b, + 0xf147c: 0xf147c, + 0xf147d: 0xf147d, + 0xf147e: 0xf147e, + 0xf147f: 0xf147f, + 0xf1480: 0xf1480, + 0xf1481: 0xf1481, + 0xf1482: 0xf1482, + 0xf1483: 0xf1483, + 0xf1484: 0xf1484, + 0xf1485: 0xf1485, + 0xf1486: 0xf1486, + 0xf1487: 0xf1487, + 0xf1488: 0xf1488, + 0xf1489: 0xf1489, + 0xf148a: 0xf148a, + 0xf148b: 0xf148b, + 0xf148c: 0xf148c, + 0xf148d: 0xf148d, + 0xf148e: 0xf148e, + 0xf148f: 0xf148f, + 0xf1490: 0xf1490, + 0xf1491: 0xf1491, + 0xf1492: 0xf1492, + 0xf1493: 0xf1493, + 0xf1494: 0xf1494, + 0xf1495: 0xf1495, + 0xf1496: 0xf1496, + 0xf1497: 0xf1497, + 0xf1498: 0xf1498, + 0xf1499: 0xf1499, + 0xf149a: 0xf149a, + 0xf149b: 0xf149b, + 0xf149c: 0xf149c, + 0xf149d: 0xf149d, + 0xf149e: 0xf149e, + 0xf149f: 0xf149f, + 0xf14a0: 0xf14a0, + 0xf14a1: 0xf14a1, + 0xf14a2: 0xf14a2, + 0xf14a3: 0xf14a3, + 0xf14a4: 0xf14a4, + 0xf14a5: 0xf14a5, + 0xf14a6: 0xf14a6, + 0xf14a7: 0xf14a7, + 0xf14a8: 0xf14a8, + 0xf14a9: 0xf14a9, + 0xf14aa: 0xf14aa, + 0xf14ab: 0xf14ab, + 0xf14ac: 0xf14ac, + 0xf14ad: 0xf14ad, + 0xf14ae: 0xf14ae, + 0xf14af: 0xf14af, + 0xf14b0: 0xf14b0, + 0xf14b1: 0xf14b1, + 0xf14b2: 0xf14b2, + 0xf14b3: 0xf14b3, + 0xf14b4: 0xf14b4, + 0xf14b5: 0xf14b5, + 0xf14b6: 0xf14b6, + 0xf14b7: 0xf14b7, + 0xf14b8: 0xf14b8, + 0xf14b9: 0xf14b9, + 0xf14ba: 0xf14ba, + 0xf14bb: 0xf14bb, + 0xf14bc: 0xf14bc, + 0xf14bd: 0xf14bd, + 0xf14be: 0xf14be, + 0xf14bf: 0xf14bf, + 0xf14c0: 0xf14c0, + 0xf14c1: 0xf14c1, + 0xf14c2: 0xf14c2, + 0xf14c3: 0xf14c3, + 0xf14c4: 0xf14c4, + 0xf14c5: 0xf14c5, + 0xf14c6: 0xf14c6, + 0xf14c7: 0xf14c7, + 0xf14c8: 0xf14c8, + 0xf14c9: 0xf14c9, + 0xf14ca: 0xf14ca, + 0xf14cb: 0xf14cb, + 0xf14cc: 0xf14cc, + 0xf14cd: 0xf14cd, + 0xf14ce: 0xf14ce, + 0xf14cf: 0xf14cf, + 0xf14d0: 0xf14d0, + 0xf14d1: 0xf14d1, + 0xf14d2: 0xf14d2, + 0xf14d3: 0xf14d3, + 0xf14d4: 0xf14d4, + 0xf14d5: 0xf14d5, + 0xf14d6: 0xf14d6, + 0xf14d7: 0xf14d7, + 0xf14d8: 0xf14d8, + 0xf14d9: 0xf14d9, + 0xf14da: 0xf14da, + 0xf14db: 0xf14db, + 0xf14dc: 0xf14dc, + 0xf14dd: 0xf14dd, + 0xf14de: 0xf14de, + 0xf14df: 0xf14df, + 0xf14e0: 0xf14e0, + 0xf14e1: 0xf14e1, + 0xf14e2: 0xf14e2, + 0xf14e3: 0xf14e3, + 0xf14e4: 0xf14e4, + 0xf14e5: 0xf14e5, + 0xf14e6: 0xf14e6, + 0xf14e7: 0xf14e7, + 0xf14e8: 0xf14e8, + 0xf14e9: 0xf14e9, + 0xf14ea: 0xf14ea, + 0xf14eb: 0xf14eb, + 0xf14ec: 0xf14ec, + 0xf14ed: 0xf14ed, + 0xf14ee: 0xf14ee, + 0xf14ef: 0xf14ef, + 0xf14f0: 0xf14f0, + 0xf14f1: 0xf14f1, + 0xf14f2: 0xf14f2, + 0xf14f3: 0xf14f3, + 0xf14f4: 0xf14f4, + 0xf14f5: 0xf14f5, + 0xf14f6: 0xf14f6, + 0xf14f7: 0xf14f7, + 0xf14f8: 0xf14f8, + 0xf14f9: 0xf14f9, + 0xf14fa: 0xf14fa, + 0xf14fb: 0xf14fb, + 0xf14fc: 0xf14fc, + 0xf14fd: 0xf14fd, + 0xf14fe: 0xf14fe, + 0xf14ff: 0xf14ff, + 0xf1500: 0xf1500, + 0xf1501: 0xf1501, + 0xf1502: 0xf1502, + 0xf1503: 0xf1503, + 0xf1504: 0xf1504, + 0xf1505: 0xf1505, + 0xf1506: 0xf1506, + 0xf1507: 0xf1507, + 0xf1508: 0xf1508, + 0xf1509: 0xf1509, + 0xf150a: 0xf150a, + 0xf150b: 0xf150b, + 0xf150c: 0xf150c, + 0xf150d: 0xf150d, + 0xf150e: 0xf150e, + 0xf150f: 0xf150f, + 0xf1510: 0xf1510, + 0xf1511: 0xf1511, + 0xf1512: 0xf1512, + 0xf1513: 0xf1513, + 0xf1514: 0xf1514, + 0xf1515: 0xf1515, + 0xf1516: 0xf1516, + 0xf1517: 0xf1517, + 0xf1518: 0xf1518, + 0xf1519: 0xf1519, + 0xf151a: 0xf151a, + 0xf151b: 0xf151b, + 0xf151c: 0xf151c, + 0xf151d: 0xf151d, + 0xf151e: 0xf151e, + 0xf151f: 0xf151f, + 0xf1520: 0xf1520, + 0xf1521: 0xf1521, + 0xf1522: 0xf1522, + 0xf1523: 0xf1523, + 0xf1524: 0xf1524, + 0xf1525: 0xf1525, + 0xf1526: 0xf1526, + 0xf1527: 0xf1527, + 0xf1528: 0xf1528, + 0xf1529: 0xf1529, + 0xf152a: 0xf152a, + 0xf152b: 0xf152b, + 0xf152c: 0xf152c, + 0xf152d: 0xf152d, + 0xf152e: 0xf152e, + 0xf152f: 0xf152f, + 0xf1530: 0xf1530, + 0xf1531: 0xf1531, + 0xf1532: 0xf1532, + 0xf1533: 0xf1533, + 0xf1534: 0xf1534, + 0xf1535: 0xf1535, + 0xf1536: 0xf1536, + 0xf1537: 0xf1537, + 0xf1538: 0xf1538, + 0xf1539: 0xf1539, + 0xf153a: 0xf153a, + 0xf153b: 0xf153b, + 0xf153c: 0xf153c, + 0xf153d: 0xf153d, + 0xf153e: 0xf153e, + 0xf153f: 0xf153f, + 0xf1540: 0xf1540, + 0xf1541: 0xf1541, + 0xf1542: 0xf1542, + 0xf1543: 0xf1543, + 0xf1544: 0xf1544, + 0xf1545: 0xf1545, + 0xf1546: 0xf1546, + 0xf1547: 0xf1547, + 0xf1548: 0xf1548, + 0xf1549: 0xf1549, + 0xf154a: 0xf154a, + 0xf154b: 0xf154b, + 0xf154c: 0xf154c, + 0xf154d: 0xf154d, + 0xf154e: 0xf154e, + 0xf154f: 0xf154f, + 0xf1550: 0xf1550, + 0xf1551: 0xf1551, + 0xf1552: 0xf1552, + 0xf1553: 0xf1553, + 0xf1554: 0xf1554, + 0xf1555: 0xf1555, + 0xf1556: 0xf1556, + 0xf1557: 0xf1557, + 0xf1558: 0xf1558, + 0xf1559: 0xf1559, + 0xf155a: 0xf155a, + 0xf155b: 0xf155b, + 0xf155c: 0xf155c, + 0xf155d: 0xf155d, + 0xf155e: 0xf155e, + 0xf155f: 0xf155f, + 0xf1560: 0xf1560, + 0xf1561: 0xf1561, + 0xf1562: 0xf1562, + 0xf1563: 0xf1563, + 0xf1564: 0xf1564, + 0xf1565: 0xf1565, + 0xf1566: 0xf1566, + 0xf1567: 0xf1567, + 0xf1568: 0xf1568, + 0xf1569: 0xf1569, + 0xf156a: 0xf156a, + 0xf156b: 0xf156b, + 0xf156c: 0xf156c, + 0xf156d: 0xf156d, + 0xf156e: 0xf156e, + 0xf156f: 0xf156f, + 0xf1570: 0xf1570, + 0xf1571: 0xf1571, + 0xf1572: 0xf1572, + 0xf1573: 0xf1573, + 0xf1574: 0xf1574, + 0xf1575: 0xf1575, + 0xf1576: 0xf1576, + 0xf1577: 0xf1577, + 0xf1578: 0xf1578, + 0xf1579: 0xf1579, + 0xf157a: 0xf157a, + 0xf157b: 0xf157b, + 0xf157c: 0xf157c, + 0xf157d: 0xf157d, + 0xf157e: 0xf157e, + 0xf157f: 0xf157f, + 0xf1580: 0xf1580, + 0xf1581: 0xf1581, + 0xf1582: 0xf1582, + 0xf1583: 0xf1583, + 0xf1584: 0xf1584, + 0xf1585: 0xf1585, + 0xf1586: 0xf1586, + 0xf1587: 0xf1587, + 0xf1588: 0xf1588, + 0xf1589: 0xf1589, + 0xf158a: 0xf158a, + 0xf158b: 0xf158b, + 0xf158c: 0xf158c, + 0xf158d: 0xf158d, + 0xf158e: 0xf158e, + 0xf158f: 0xf158f, + 0xf1590: 0xf1590, + 0xf1591: 0xf1591, + 0xf1592: 0xf1592, + 0xf1593: 0xf1593, + 0xf1594: 0xf1594, + 0xf1595: 0xf1595, + 0xf1596: 0xf1596, + 0xf1597: 0xf1597, + 0xf1598: 0xf1598, + 0xf1599: 0xf1599, + 0xf159a: 0xf159a, + 0xf159b: 0xf159b, + 0xf159c: 0xf159c, + 0xf159d: 0xf159d, + 0xf159e: 0xf159e, + 0xf159f: 0xf159f, + 0xf15a0: 0xf15a0, + 0xf15a1: 0xf15a1, + 0xf15a2: 0xf15a2, + 0xf15a3: 0xf15a3, + 0xf15a4: 0xf15a4, + 0xf15a5: 0xf15a5, + 0xf15a6: 0xf15a6, + 0xf15a7: 0xf15a7, + 0xf15a8: 0xf15a8, + 0xf15a9: 0xf15a9, + 0xf15aa: 0xf15aa, + 0xf15ab: 0xf15ab, + 0xf15ac: 0xf15ac, + 0xf15ad: 0xf15ad, + 0xf15ae: 0xf15ae, + 0xf15af: 0xf15af, + 0xf15b0: 0xf15b0, + 0xf15b1: 0xf15b1, + 0xf15b2: 0xf15b2, + 0xf15b3: 0xf15b3, + 0xf15b4: 0xf15b4, + 0xf15b5: 0xf15b5, + 0xf15b6: 0xf15b6, + 0xf15b7: 0xf15b7, + 0xf15b8: 0xf15b8, + 0xf15b9: 0xf15b9, + 0xf15ba: 0xf15ba, + 0xf15bb: 0xf15bb, + 0xf15bc: 0xf15bc, + 0xf15bd: 0xf15bd, + 0xf15be: 0xf15be, + 0xf15bf: 0xf15bf, + 0xf15c0: 0xf15c0, + 0xf15c1: 0xf15c1, + 0xf15c2: 0xf15c2, + 0xf15c3: 0xf15c3, + 0xf15c4: 0xf15c4, + 0xf15c5: 0xf15c5, + 0xf15c6: 0xf15c6, + 0xf15c7: 0xf15c7, + 0xf15c8: 0xf15c8, + 0xf15c9: 0xf15c9, + 0xf15ca: 0xf15ca, + 0xf15cb: 0xf15cb, + 0xf15cc: 0xf15cc, + 0xf15cd: 0xf15cd, + 0xf15ce: 0xf15ce, + 0xf15cf: 0xf15cf, + 0xf15d0: 0xf15d0, + 0xf15d1: 0xf15d1, + 0xf15d2: 0xf15d2, + 0xf15d3: 0xf15d3, + 0xf15d4: 0xf15d4, + 0xf15d5: 0xf15d5, + 0xf15d6: 0xf15d6, + 0xf15d7: 0xf15d7, + 0xf15d8: 0xf15d8, + 0xf15d9: 0xf15d9, + 0xf15da: 0xf15da, + 0xf15db: 0xf15db, + 0xf15dc: 0xf15dc, + 0xf15dd: 0xf15dd, + 0xf15de: 0xf15de, + 0xf15df: 0xf15df, + 0xf15e0: 0xf15e0, + 0xf15e1: 0xf15e1, + 0xf15e2: 0xf15e2, + 0xf15e3: 0xf15e3, + 0xf15e4: 0xf15e4, + 0xf15e5: 0xf15e5, + 0xf15e6: 0xf15e6, + 0xf15e7: 0xf15e7, + 0xf15e8: 0xf15e8, + 0xf15e9: 0xf15e9, + 0xf15ea: 0xf15ea, + 0xf15eb: 0xf15eb, + 0xf15ec: 0xf15ec, + 0xf15ed: 0xf15ed, + 0xf15ee: 0xf15ee, + 0xf15ef: 0xf15ef, + 0xf15f0: 0xf15f0, + 0xf15f1: 0xf15f1, + 0xf15f2: 0xf15f2, + 0xf15f3: 0xf15f3, + 0xf15f4: 0xf15f4, + 0xf15f5: 0xf15f5, + 0xf15f6: 0xf15f6, + 0xf15f7: 0xf15f7, + 0xf15f8: 0xf15f8, + 0xf15f9: 0xf15f9, + 0xf15fa: 0xf15fa, + 0xf15fb: 0xf15fb, + 0xf15fc: 0xf15fc, + 0xf15fd: 0xf15fd, + 0xf15fe: 0xf15fe, + 0xf15ff: 0xf15ff, + 0xf1600: 0xf1600, + 0xf1601: 0xf1601, + 0xf1602: 0xf1602, + 0xf1603: 0xf1603, + 0xf1604: 0xf1604, + 0xf1605: 0xf1605, + 0xf1606: 0xf1606, + 0xf1607: 0xf1607, + 0xf1608: 0xf1608, + 0xf1609: 0xf1609, + 0xf160a: 0xf160a, + 0xf160b: 0xf160b, + 0xf160c: 0xf160c, + 0xf160d: 0xf160d, + 0xf160e: 0xf160e, + 0xf160f: 0xf160f, + 0xf1610: 0xf1610, + 0xf1611: 0xf1611, + 0xf1612: 0xf1612, + 0xf1613: 0xf1613, + 0xf1614: 0xf1614, + 0xf1615: 0xf1615, + 0xf1616: 0xf1616, + 0xf1617: 0xf1617, + 0xf1618: 0xf1618, + 0xf1619: 0xf1619, + 0xf161a: 0xf161a, + 0xf161b: 0xf161b, + 0xf161c: 0xf161c, + 0xf161d: 0xf161d, + 0xf161e: 0xf161e, + 0xf161f: 0xf161f, + 0xf1620: 0xf1620, + 0xf1621: 0xf1621, + 0xf1622: 0xf1622, + 0xf1623: 0xf1623, + 0xf1624: 0xf1624, + 0xf1625: 0xf1625, + 0xf1626: 0xf1626, + 0xf1627: 0xf1627, + 0xf1628: 0xf1628, + 0xf1629: 0xf1629, + 0xf162a: 0xf162a, + 0xf162b: 0xf162b, + 0xf162c: 0xf162c, + 0xf162d: 0xf162d, + 0xf162e: 0xf162e, + 0xf162f: 0xf162f, + 0xf1630: 0xf1630, + 0xf1631: 0xf1631, + 0xf1632: 0xf1632, + 0xf1633: 0xf1633, + 0xf1634: 0xf1634, + 0xf1635: 0xf1635, + 0xf1636: 0xf1636, + 0xf1637: 0xf1637, + 0xf1638: 0xf1638, + 0xf1639: 0xf1639, + 0xf163a: 0xf163a, + 0xf163b: 0xf163b, + 0xf163c: 0xf163c, + 0xf163d: 0xf163d, + 0xf163e: 0xf163e, + 0xf163f: 0xf163f, + 0xf1640: 0xf1640, + 0xf1641: 0xf1641, + 0xf1642: 0xf1642, + 0xf1643: 0xf1643, + 0xf1644: 0xf1644, + 0xf1645: 0xf1645, + 0xf1646: 0xf1646, + 0xf1647: 0xf1647, + 0xf1648: 0xf1648, + 0xf1649: 0xf1649, + 0xf164a: 0xf164a, + 0xf164b: 0xf164b, + 0xf164c: 0xf164c, + 0xf164d: 0xf164d, + 0xf164e: 0xf164e, + 0xf164f: 0xf164f, + 0xf1650: 0xf1650, + 0xf1651: 0xf1651, + 0xf1652: 0xf1652, + 0xf1653: 0xf1653, + 0xf1654: 0xf1654, + 0xf1655: 0xf1655, + 0xf1656: 0xf1656, + 0xf1657: 0xf1657, + 0xf1658: 0xf1658, + 0xf1659: 0xf1659, + 0xf165a: 0xf165a, + 0xf165b: 0xf165b, + 0xf165c: 0xf165c, + 0xf165d: 0xf165d, + 0xf165e: 0xf165e, + 0xf165f: 0xf165f, + 0xf1660: 0xf1660, + 0xf1661: 0xf1661, + 0xf1662: 0xf1662, + 0xf1663: 0xf1663, + 0xf1664: 0xf1664, + 0xf1665: 0xf1665, + 0xf1666: 0xf1666, + 0xf1667: 0xf1667, + 0xf1668: 0xf1668, + 0xf1669: 0xf1669, + 0xf166a: 0xf166a, + 0xf166b: 0xf166b, + 0xf166c: 0xf166c, + 0xf166d: 0xf166d, + 0xf166e: 0xf166e, + 0xf166f: 0xf166f, + 0xf1670: 0xf1670, + 0xf1671: 0xf1671, + 0xf1672: 0xf1672, + 0xf1673: 0xf1673, + 0xf1674: 0xf1674, + 0xf1675: 0xf1675, + 0xf1676: 0xf1676, + 0xf1677: 0xf1677, + 0xf1678: 0xf1678, + 0xf1679: 0xf1679, + 0xf167a: 0xf167a, + 0xf167b: 0xf167b, + 0xf167c: 0xf167c, + 0xf167d: 0xf167d, + 0xf167e: 0xf167e, + 0xf167f: 0xf167f, + 0xf1680: 0xf1680, + 0xf1681: 0xf1681, + 0xf1682: 0xf1682, + 0xf1683: 0xf1683, + 0xf1684: 0xf1684, + 0xf1685: 0xf1685, + 0xf1686: 0xf1686, + 0xf1687: 0xf1687, + 0xf1688: 0xf1688, + 0xf1689: 0xf1689, + 0xf168a: 0xf168a, + 0xf168b: 0xf168b, + 0xf168c: 0xf168c, + 0xf168d: 0xf168d, + 0xf168e: 0xf168e, + 0xf168f: 0xf168f, + 0xf1690: 0xf1690, + 0xf1691: 0xf1691, + 0xf1692: 0xf1692, + 0xf1693: 0xf1693, + 0xf1694: 0xf1694, + 0xf1695: 0xf1695, + 0xf1696: 0xf1696, + 0xf1697: 0xf1697, + 0xf1698: 0xf1698, + 0xf1699: 0xf1699, + 0xf169a: 0xf169a, + 0xf169b: 0xf169b, + 0xf169c: 0xf169c, + 0xf169d: 0xf169d, + 0xf169e: 0xf169e, + 0xf169f: 0xf169f, + 0xf16a0: 0xf16a0, + 0xf16a1: 0xf16a1, + 0xf16a2: 0xf16a2, + 0xf16a3: 0xf16a3, + 0xf16a4: 0xf16a4, + 0xf16a5: 0xf16a5, + 0xf16a6: 0xf16a6, + 0xf16a7: 0xf16a7, + 0xf16a8: 0xf16a8, + 0xf16a9: 0xf16a9, + 0xf16aa: 0xf16aa, + 0xf16ab: 0xf16ab, + 0xf16ac: 0xf16ac, + 0xf16ad: 0xf16ad, + 0xf16ae: 0xf16ae, + 0xf16af: 0xf16af, + 0xf16b0: 0xf16b0, + 0xf16b1: 0xf16b1, + 0xf16b2: 0xf16b2, + 0xf16b3: 0xf16b3, + 0xf16b4: 0xf16b4, + 0xf16b5: 0xf16b5, + 0xf16b6: 0xf16b6, + 0xf16b7: 0xf16b7, + 0xf16b8: 0xf16b8, + 0xf16b9: 0xf16b9, + 0xf16ba: 0xf16ba, + 0xf16bb: 0xf16bb, + 0xf16bc: 0xf16bc, + 0xf16bd: 0xf16bd, + 0xf16be: 0xf16be, + 0xf16bf: 0xf16bf, + 0xf16c0: 0xf16c0, + 0xf16c1: 0xf16c1, + 0xf16c2: 0xf16c2, + 0xf16c3: 0xf16c3, + 0xf16c4: 0xf16c4, + 0xf16c5: 0xf16c5, + 0xf16c6: 0xf16c6, + 0xf16c7: 0xf16c7, + 0xf16c8: 0xf16c8, + 0xf16c9: 0xf16c9, + 0xf16ca: 0xf16ca, + 0xf16cb: 0xf16cb, + 0xf16cc: 0xf16cc, + 0xf16cd: 0xf16cd, + 0xf16ce: 0xf16ce, + 0xf16cf: 0xf16cf, + 0xf16d0: 0xf16d0, + 0xf16d1: 0xf16d1, + 0xf16d2: 0xf16d2, + 0xf16d3: 0xf16d3, + 0xf16d4: 0xf16d4, + 0xf16d5: 0xf16d5, + 0xf16d6: 0xf16d6, + 0xf16d7: 0xf16d7, + 0xf16d8: 0xf16d8, + 0xf16d9: 0xf16d9, + 0xf16da: 0xf16da, + 0xf16db: 0xf16db, + 0xf16dc: 0xf16dc, + 0xf16dd: 0xf16dd, + 0xf16de: 0xf16de, + 0xf16df: 0xf16df, + 0xf16e0: 0xf16e0, + 0xf16e1: 0xf16e1, + 0xf16e2: 0xf16e2, + 0xf16e3: 0xf16e3, + 0xf16e4: 0xf16e4, + 0xf16e5: 0xf16e5, + 0xf16e6: 0xf16e6, + 0xf16e7: 0xf16e7, + 0xf16e8: 0xf16e8, + 0xf16e9: 0xf16e9, + 0xf16ea: 0xf16ea, + 0xf16eb: 0xf16eb, + 0xf16ec: 0xf16ec, + 0xf16ed: 0xf16ed, + 0xf16ee: 0xf16ee, + 0xf16ef: 0xf16ef, + 0xf16f0: 0xf16f0, + 0xf16f1: 0xf16f1, + 0xf16f2: 0xf16f2, + 0xf16f3: 0xf16f3, + 0xf16f4: 0xf16f4, + 0xf16f5: 0xf16f5, + 0xf16f6: 0xf16f6, + 0xf16f7: 0xf16f7, + 0xf16f8: 0xf16f8, + 0xf16f9: 0xf16f9, + 0xf16fa: 0xf16fa, + 0xf16fb: 0xf16fb, + 0xf16fc: 0xf16fc, + 0xf16fd: 0xf16fd, + 0xf16fe: 0xf16fe, + 0xf16ff: 0xf16ff, + 0xf1700: 0xf1700, + 0xf1701: 0xf1701, + 0xf1702: 0xf1702, + 0xf1703: 0xf1703, + 0xf1704: 0xf1704, + 0xf1705: 0xf1705, + 0xf1706: 0xf1706, + 0xf1707: 0xf1707, + 0xf1708: 0xf1708, + 0xf1709: 0xf1709, + 0xf170a: 0xf170a, + 0xf170b: 0xf170b, + 0xf170c: 0xf170c, + 0xf170d: 0xf170d, + 0xf170e: 0xf170e, + 0xf170f: 0xf170f, + 0xf1710: 0xf1710, + 0xf1711: 0xf1711, + 0xf1712: 0xf1712, + 0xf1713: 0xf1713, + 0xf1714: 0xf1714, + 0xf1715: 0xf1715, + 0xf1716: 0xf1716, + 0xf1717: 0xf1717, + 0xf1718: 0xf1718, + 0xf1719: 0xf1719, + 0xf171a: 0xf171a, + 0xf171b: 0xf171b, + 0xf171c: 0xf171c, + 0xf171d: 0xf171d, + 0xf171e: 0xf171e, + 0xf171f: 0xf171f, + 0xf1720: 0xf1720, + 0xf1721: 0xf1721, + 0xf1722: 0xf1722, + 0xf1723: 0xf1723, + 0xf1724: 0xf1724, + 0xf1725: 0xf1725, + 0xf1726: 0xf1726, + 0xf1727: 0xf1727, + 0xf1728: 0xf1728, + 0xf1729: 0xf1729, + 0xf172a: 0xf172a, + 0xf172b: 0xf172b, + 0xf172c: 0xf172c, + 0xf172d: 0xf172d, + 0xf172e: 0xf172e, + 0xf172f: 0xf172f, + 0xf1730: 0xf1730, + 0xf1731: 0xf1731, + 0xf1732: 0xf1732, + 0xf1733: 0xf1733, + 0xf1734: 0xf1734, + 0xf1735: 0xf1735, + 0xf1736: 0xf1736, + 0xf1737: 0xf1737, + 0xf1738: 0xf1738, + 0xf1739: 0xf1739, + 0xf173a: 0xf173a, + 0xf173b: 0xf173b, + 0xf173c: 0xf173c, + 0xf173d: 0xf173d, + 0xf173e: 0xf173e, + 0xf173f: 0xf173f, + 0xf1740: 0xf1740, + 0xf1741: 0xf1741, + 0xf1742: 0xf1742, + 0xf1743: 0xf1743, + 0xf1744: 0xf1744, + 0xf1745: 0xf1745, + 0xf1746: 0xf1746, + 0xf1747: 0xf1747, + 0xf1748: 0xf1748, + 0xf1749: 0xf1749, + 0xf174a: 0xf174a, + 0xf174b: 0xf174b, + 0xf174c: 0xf174c, + 0xf174d: 0xf174d, + 0xf174e: 0xf174e, + 0xf174f: 0xf174f, + 0xf1750: 0xf1750, + 0xf1751: 0xf1751, + 0xf1752: 0xf1752, + 0xf1753: 0xf1753, + 0xf1754: 0xf1754, + 0xf1755: 0xf1755, + 0xf1756: 0xf1756, + 0xf1757: 0xf1757, + 0xf1758: 0xf1758, + 0xf1759: 0xf1759, + 0xf175a: 0xf175a, + 0xf175b: 0xf175b, + 0xf175c: 0xf175c, + 0xf175d: 0xf175d, + 0xf175e: 0xf175e, + 0xf175f: 0xf175f, + 0xf1760: 0xf1760, + 0xf1761: 0xf1761, + 0xf1762: 0xf1762, + 0xf1763: 0xf1763, + 0xf1764: 0xf1764, + 0xf1765: 0xf1765, + 0xf1766: 0xf1766, + 0xf1767: 0xf1767, + 0xf1768: 0xf1768, + 0xf1769: 0xf1769, + 0xf176a: 0xf176a, + 0xf176b: 0xf176b, + 0xf176c: 0xf176c, + 0xf176d: 0xf176d, + 0xf176e: 0xf176e, + 0xf176f: 0xf176f, + 0xf1770: 0xf1770, + 0xf1771: 0xf1771, + 0xf1772: 0xf1772, + 0xf1773: 0xf1773, + 0xf1774: 0xf1774, + 0xf1775: 0xf1775, + 0xf1776: 0xf1776, + 0xf1777: 0xf1777, + 0xf1778: 0xf1778, + 0xf1779: 0xf1779, + 0xf177a: 0xf177a, + 0xf177b: 0xf177b, + 0xf177c: 0xf177c, + 0xf177d: 0xf177d, + 0xf177e: 0xf177e, + 0xf177f: 0xf177f, + 0xf1780: 0xf1780, + 0xf1781: 0xf1781, + 0xf1782: 0xf1782, + 0xf1783: 0xf1783, + 0xf1784: 0xf1784, + 0xf1785: 0xf1785, + 0xf1786: 0xf1786, + 0xf1787: 0xf1787, + 0xf1788: 0xf1788, + 0xf1789: 0xf1789, + 0xf178a: 0xf178a, + 0xf178b: 0xf178b, + 0xf178c: 0xf178c, + 0xf178d: 0xf178d, + 0xf178e: 0xf178e, + 0xf178f: 0xf178f, + 0xf1790: 0xf1790, + 0xf1791: 0xf1791, + 0xf1792: 0xf1792, + 0xf1793: 0xf1793, + 0xf1794: 0xf1794, + 0xf1795: 0xf1795, + 0xf1796: 0xf1796, + 0xf1797: 0xf1797, + 0xf1798: 0xf1798, + 0xf1799: 0xf1799, + 0xf179a: 0xf179a, + 0xf179b: 0xf179b, + 0xf179c: 0xf179c, + 0xf179d: 0xf179d, + 0xf179e: 0xf179e, + 0xf179f: 0xf179f, + 0xf17a0: 0xf17a0, + 0xf17a1: 0xf17a1, + 0xf17a2: 0xf17a2, + 0xf17a3: 0xf17a3, + 0xf17a4: 0xf17a4, + 0xf17a5: 0xf17a5, + 0xf17a6: 0xf17a6, + 0xf17a7: 0xf17a7, + 0xf17a8: 0xf17a8, + 0xf17a9: 0xf17a9, + 0xf17aa: 0xf17aa, + 0xf17ab: 0xf17ab, + 0xf17ac: 0xf17ac, + 0xf17ad: 0xf17ad, + 0xf17ae: 0xf17ae, + 0xf17af: 0xf17af, + 0xf17b0: 0xf17b0, + 0xf17b1: 0xf17b1, + 0xf17b2: 0xf17b2, + 0xf17b3: 0xf17b3, + 0xf17b4: 0xf17b4, + 0xf17b5: 0xf17b5, + 0xf17b6: 0xf17b6, + 0xf17b7: 0xf17b7, + 0xf17b8: 0xf17b8, + 0xf17b9: 0xf17b9, + 0xf17ba: 0xf17ba, + 0xf17bb: 0xf17bb, + 0xf17bc: 0xf17bc, + 0xf17bd: 0xf17bd, + 0xf17be: 0xf17be, + 0xf17bf: 0xf17bf, + 0xf17c0: 0xf17c0, + 0xf17c1: 0xf17c1, + 0xf17c2: 0xf17c2, + 0xf17c3: 0xf17c3, + 0xf17c4: 0xf17c4, + 0xf17c5: 0xf17c5, + 0xf17c6: 0xf17c6, + 0xf17c7: 0xf17c7, + 0xf17c8: 0xf17c8, + 0xf17c9: 0xf17c9, + 0xf17ca: 0xf17ca, + 0xf17cb: 0xf17cb, + 0xf17cc: 0xf17cc, + 0xf17cd: 0xf17cd, + 0xf17ce: 0xf17ce, + 0xf17cf: 0xf17cf, + 0xf17d0: 0xf17d0, + 0xf17d1: 0xf17d1, + 0xf17d2: 0xf17d2, + 0xf17d3: 0xf17d3, + 0xf17d4: 0xf17d4, + 0xf17d5: 0xf17d5, + 0xf17d6: 0xf17d6, + 0xf17d7: 0xf17d7, + 0xf17d8: 0xf17d8, + 0xf17d9: 0xf17d9, + 0xf17da: 0xf17da, + 0xf17db: 0xf17db, + 0xf17dc: 0xf17dc, + 0xf17dd: 0xf17dd, + 0xf17de: 0xf17de, + 0xf17df: 0xf17df, + 0xf17e0: 0xf17e0, + 0xf17e1: 0xf17e1, + 0xf17e2: 0xf17e2, + 0xf17e3: 0xf17e3, + 0xf17e4: 0xf17e4, + 0xf17e5: 0xf17e5, + 0xf17e6: 0xf17e6, + 0xf17e7: 0xf17e7, + 0xf17e8: 0xf17e8, + 0xf17e9: 0xf17e9, + 0xf17ea: 0xf17ea, + 0xf17eb: 0xf17eb, + 0xf17ec: 0xf17ec, + 0xf17ed: 0xf17ed, + 0xf17ee: 0xf17ee, + 0xf17ef: 0xf17ef, + 0xf17f0: 0xf17f0, + 0xf17f1: 0xf17f1, + 0xf17f2: 0xf17f2, + 0xf17f3: 0xf17f3, + 0xf17f4: 0xf17f4, + 0xf17f5: 0xf17f5, + 0xf17f6: 0xf17f6, + 0xf17f7: 0xf17f7, + 0xf17f8: 0xf17f8, + 0xf17f9: 0xf17f9, + 0xf17fa: 0xf17fa, + 0xf17fb: 0xf17fb, + 0xf17fc: 0xf17fc, + 0xf17fd: 0xf17fd, + 0xf17fe: 0xf17fe, + 0xf17ff: 0xf17ff, + 0xf1800: 0xf1800, + 0xf1801: 0xf1801, + 0xf1802: 0xf1802, + 0xf1803: 0xf1803, + 0xf1804: 0xf1804, + 0xf1805: 0xf1805, + 0xf1806: 0xf1806, + 0xf1807: 0xf1807, + 0xf1808: 0xf1808, + 0xf1809: 0xf1809, + 0xf180a: 0xf180a, + 0xf180b: 0xf180b, + 0xf180c: 0xf180c, + 0xf180d: 0xf180d, + 0xf180e: 0xf180e, + 0xf180f: 0xf180f, + 0xf1810: 0xf1810, + 0xf1811: 0xf1811, + 0xf1812: 0xf1812, + 0xf1813: 0xf1813, + 0xf1814: 0xf1814, + 0xf1815: 0xf1815, + 0xf1816: 0xf1816, + 0xf1817: 0xf1817, + 0xf1818: 0xf1818, + 0xf1819: 0xf1819, + 0xf181a: 0xf181a, + 0xf181b: 0xf181b, + 0xf181c: 0xf181c, + 0xf181d: 0xf181d, + 0xf181e: 0xf181e, + 0xf181f: 0xf181f, + 0xf1820: 0xf1820, + 0xf1821: 0xf1821, + 0xf1822: 0xf1822, + 0xf1823: 0xf1823, + 0xf1824: 0xf1824, + 0xf1825: 0xf1825, + 0xf1826: 0xf1826, + 0xf1827: 0xf1827, + 0xf1828: 0xf1828, + 0xf1829: 0xf1829, + 0xf182a: 0xf182a, + 0xf182b: 0xf182b, + 0xf182c: 0xf182c, + 0xf182d: 0xf182d, + 0xf182e: 0xf182e, + 0xf182f: 0xf182f, + 0xf1830: 0xf1830, + 0xf1831: 0xf1831, + 0xf1832: 0xf1832, + 0xf1833: 0xf1833, + 0xf1834: 0xf1834, + 0xf1835: 0xf1835, + 0xf1836: 0xf1836, + 0xf1837: 0xf1837, + 0xf1838: 0xf1838, + 0xf1839: 0xf1839, + 0xf183a: 0xf183a, + 0xf183b: 0xf183b, + 0xf183c: 0xf183c, + 0xf183d: 0xf183d, + 0xf183e: 0xf183e, + 0xf183f: 0xf183f, + 0xf1840: 0xf1840, + 0xf1841: 0xf1841, + 0xf1842: 0xf1842, + 0xf1843: 0xf1843, + 0xf1844: 0xf1844, + 0xf1845: 0xf1845, + 0xf1846: 0xf1846, + 0xf1847: 0xf1847, + 0xf1848: 0xf1848, + 0xf1849: 0xf1849, + 0xf184a: 0xf184a, + 0xf184b: 0xf184b, + 0xf184c: 0xf184c, + 0xf184d: 0xf184d, + 0xf184e: 0xf184e, + 0xf184f: 0xf184f, + 0xf1850: 0xf1850, + 0xf1851: 0xf1851, + 0xf1852: 0xf1852, + 0xf1853: 0xf1853, + 0xf1854: 0xf1854, + 0xf1855: 0xf1855, + 0xf1856: 0xf1856, + 0xf1857: 0xf1857, + 0xf1858: 0xf1858, + 0xf1859: 0xf1859, + 0xf185a: 0xf185a, + 0xf185b: 0xf185b, + 0xf185c: 0xf185c, + 0xf185d: 0xf185d, + 0xf185e: 0xf185e, + 0xf185f: 0xf185f, + 0xf1860: 0xf1860, + 0xf1861: 0xf1861, + 0xf1862: 0xf1862, + 0xf1863: 0xf1863, + 0xf1864: 0xf1864, + 0xf1865: 0xf1865, + 0xf1866: 0xf1866, + 0xf1867: 0xf1867, + 0xf1868: 0xf1868, + 0xf1869: 0xf1869, + 0xf186a: 0xf186a, + 0xf186b: 0xf186b, + 0xf186c: 0xf186c, + 0xf186d: 0xf186d, + 0xf186e: 0xf186e, + 0xf186f: 0xf186f, + 0xf1870: 0xf1870, + 0xf1871: 0xf1871, + 0xf1872: 0xf1872, + 0xf1873: 0xf1873, + 0xf1874: 0xf1874, + 0xf1875: 0xf1875, + 0xf1876: 0xf1876, + 0xf1877: 0xf1877, + 0xf1878: 0xf1878, + 0xf1879: 0xf1879, + 0xf187a: 0xf187a, + 0xf187b: 0xf187b, + 0xf187c: 0xf187c, + 0xf187d: 0xf187d, + 0xf187e: 0xf187e, + 0xf187f: 0xf187f, + 0xf1880: 0xf1880, + 0xf1881: 0xf1881, + 0xf1882: 0xf1882, + 0xf1883: 0xf1883, + 0xf1884: 0xf1884, + 0xf1885: 0xf1885, + 0xf1886: 0xf1886, + 0xf1887: 0xf1887, + 0xf1888: 0xf1888, + 0xf1889: 0xf1889, + 0xf188a: 0xf188a, + 0xf188b: 0xf188b, + 0xf188c: 0xf188c, + 0xf188d: 0xf188d, + 0xf188e: 0xf188e, + 0xf188f: 0xf188f, + 0xf1890: 0xf1890, + 0xf1891: 0xf1891, + 0xf1892: 0xf1892, + 0xf1893: 0xf1893, + 0xf1894: 0xf1894, + 0xf1895: 0xf1895, + 0xf1896: 0xf1896, + 0xf1897: 0xf1897, + 0xf1898: 0xf1898, + 0xf1899: 0xf1899, + 0xf189a: 0xf189a, + 0xf189b: 0xf189b, + 0xf189c: 0xf189c, + 0xf189d: 0xf189d, + 0xf189e: 0xf189e, + 0xf189f: 0xf189f, + 0xf18a0: 0xf18a0, + 0xf18a1: 0xf18a1, + 0xf18a2: 0xf18a2, + 0xf18a3: 0xf18a3, + 0xf18a4: 0xf18a4, + 0xf18a5: 0xf18a5, + 0xf18a6: 0xf18a6, + 0xf18a7: 0xf18a7, + 0xf18a8: 0xf18a8, + 0xf18a9: 0xf18a9, + 0xf18aa: 0xf18aa, + 0xf18ab: 0xf18ab, + 0xf18ac: 0xf18ac, + 0xf18ad: 0xf18ad, + 0xf18ae: 0xf18ae, + 0xf18af: 0xf18af, + 0xf18b0: 0xf18b0, + 0xf18b1: 0xf18b1, + 0xf18b2: 0xf18b2, + 0xf18b3: 0xf18b3, + 0xf18b4: 0xf18b4, + 0xf18b5: 0xf18b5, + 0xf18b6: 0xf18b6, + 0xf18b7: 0xf18b7, + 0xf18b8: 0xf18b8, + 0xf18b9: 0xf18b9, + 0xf18ba: 0xf18ba, + 0xf18bb: 0xf18bb, + 0xf18bc: 0xf18bc, + 0xf18bd: 0xf18bd, + 0xf18be: 0xf18be, + 0xf18bf: 0xf18bf, + 0xf18c0: 0xf18c0, + 0xf18c1: 0xf18c1, + 0xf18c2: 0xf18c2, + 0xf18c3: 0xf18c3, + 0xf18c4: 0xf18c4, + 0xf18c5: 0xf18c5, + 0xf18c6: 0xf18c6, + 0xf18c7: 0xf18c7, + 0xf18c8: 0xf18c8, + 0xf18c9: 0xf18c9, + 0xf18ca: 0xf18ca, + 0xf18cb: 0xf18cb, + 0xf18cc: 0xf18cc, + 0xf18cd: 0xf18cd, + 0xf18ce: 0xf18ce, + 0xf18cf: 0xf18cf, + 0xf18d0: 0xf18d0, + 0xf18d1: 0xf18d1, + 0xf18d2: 0xf18d2, + 0xf18d3: 0xf18d3, + 0xf18d4: 0xf18d4, + 0xf18d5: 0xf18d5, + 0xf18d6: 0xf18d6, + 0xf18d7: 0xf18d7, + 0xf18d8: 0xf18d8, + 0xf18d9: 0xf18d9, + 0xf18da: 0xf18da, + 0xf18db: 0xf18db, + 0xf18dc: 0xf18dc, + 0xf18dd: 0xf18dd, + 0xf18de: 0xf18de, + 0xf18df: 0xf18df, + 0xf18e0: 0xf18e0, + 0xf18e1: 0xf18e1, + 0xf18e2: 0xf18e2, + 0xf18e3: 0xf18e3, + 0xf18e4: 0xf18e4, + 0xf18e5: 0xf18e5, + 0xf18e6: 0xf18e6, + 0xf18e7: 0xf18e7, + 0xf18e8: 0xf18e8, + 0xf18e9: 0xf18e9, + 0xf18ea: 0xf18ea, + 0xf18eb: 0xf18eb, + 0xf18ec: 0xf18ec, + 0xf18ed: 0xf18ed, + 0xf18ee: 0xf18ee, + 0xf18ef: 0xf18ef, + 0xf18f0: 0xf18f0, + 0xf18f1: 0xf18f1, + 0xf18f2: 0xf18f2, + 0xf18f3: 0xf18f3, + 0xf18f4: 0xf18f4, + 0xf18f5: 0xf18f5, + 0xf18f6: 0xf18f6, + 0xf18f7: 0xf18f7, + 0xf18f8: 0xf18f8, + 0xf18f9: 0xf18f9, + 0xf18fa: 0xf18fa, + 0xf18fb: 0xf18fb, + 0xf18fc: 0xf18fc, + 0xf18fd: 0xf18fd, + 0xf18fe: 0xf18fe, + 0xf18ff: 0xf18ff, + 0xf1900: 0xf1900, + 0xf1901: 0xf1901, + 0xf1902: 0xf1902, + 0xf1903: 0xf1903, + 0xf1904: 0xf1904, + 0xf1905: 0xf1905, + 0xf1906: 0xf1906, + 0xf1907: 0xf1907, + 0xf1908: 0xf1908, + 0xf1909: 0xf1909, + 0xf190a: 0xf190a, + 0xf190b: 0xf190b, + 0xf190c: 0xf190c, + 0xf190d: 0xf190d, + 0xf190e: 0xf190e, + 0xf190f: 0xf190f, + 0xf1910: 0xf1910, + 0xf1911: 0xf1911, + 0xf1912: 0xf1912, + 0xf1913: 0xf1913, + 0xf1914: 0xf1914, + 0xf1915: 0xf1915, + 0xf1916: 0xf1916, + 0xf1917: 0xf1917, + 0xf1918: 0xf1918, + 0xf1919: 0xf1919, + 0xf191a: 0xf191a, + 0xf191b: 0xf191b, + 0xf191c: 0xf191c, + 0xf191d: 0xf191d, + 0xf191e: 0xf191e, + 0xf191f: 0xf191f, + 0xf1920: 0xf1920, + 0xf1921: 0xf1921, + 0xf1922: 0xf1922, + 0xf1923: 0xf1923, + 0xf1924: 0xf1924, + 0xf1925: 0xf1925, + 0xf1926: 0xf1926, + 0xf1927: 0xf1927, + 0xf1928: 0xf1928, + 0xf1929: 0xf1929, + 0xf192a: 0xf192a, + 0xf192b: 0xf192b, + 0xf192c: 0xf192c, + 0xf192d: 0xf192d, + 0xf192e: 0xf192e, + 0xf192f: 0xf192f, + 0xf1930: 0xf1930, + 0xf1931: 0xf1931, + 0xf1932: 0xf1932, + 0xf1933: 0xf1933, + 0xf1934: 0xf1934, + 0xf1935: 0xf1935, + 0xf1936: 0xf1936, + 0xf1937: 0xf1937, + 0xf1938: 0xf1938, + 0xf1939: 0xf1939, + 0xf193a: 0xf193a, + 0xf193b: 0xf193b, + 0xf193c: 0xf193c, + 0xf193d: 0xf193d, + 0xf193e: 0xf193e, + 0xf193f: 0xf193f, + 0xf1940: 0xf1940, + 0xf1941: 0xf1941, + 0xf1942: 0xf1942, + 0xf1943: 0xf1943, + 0xf1944: 0xf1944, + 0xf1945: 0xf1945, + 0xf1946: 0xf1946, + 0xf1947: 0xf1947, + 0xf1948: 0xf1948, + 0xf1949: 0xf1949, + 0xf194a: 0xf194a, + 0xf194b: 0xf194b, + 0xf194c: 0xf194c, + 0xf194d: 0xf194d, + 0xf194e: 0xf194e, + 0xf194f: 0xf194f, + 0xf1950: 0xf1950, + 0xf1951: 0xf1951, + 0xf1952: 0xf1952, + 0xf1953: 0xf1953, + 0xf1954: 0xf1954, + 0xf1955: 0xf1955, + 0xf1956: 0xf1956, + 0xf1957: 0xf1957, + 0xf1958: 0xf1958, + 0xf1959: 0xf1959, + 0xf195a: 0xf195a, + 0xf195b: 0xf195b, + 0xf195c: 0xf195c, + 0xf195d: 0xf195d, + 0xf195e: 0xf195e, + 0xf195f: 0xf195f, + 0xf1960: 0xf1960, + 0xf1961: 0xf1961, + 0xf1962: 0xf1962, + 0xf1963: 0xf1963, + 0xf1964: 0xf1964, + 0xf1965: 0xf1965, + 0xf1966: 0xf1966, + 0xf1967: 0xf1967, + 0xf1968: 0xf1968, + 0xf1969: 0xf1969, + 0xf196a: 0xf196a, + 0xf196b: 0xf196b, + 0xf196c: 0xf196c, + 0xf196d: 0xf196d, + 0xf196e: 0xf196e, + 0xf196f: 0xf196f, + 0xf1970: 0xf1970, + 0xf1971: 0xf1971, + 0xf1972: 0xf1972, + 0xf1973: 0xf1973, + 0xf1974: 0xf1974, + 0xf1975: 0xf1975, + 0xf1976: 0xf1976, + 0xf1977: 0xf1977, + 0xf1978: 0xf1978, + 0xf1979: 0xf1979, + 0xf197a: 0xf197a, + 0xf197b: 0xf197b, + 0xf197c: 0xf197c, + 0xf197d: 0xf197d, + 0xf197e: 0xf197e, + 0xf197f: 0xf197f, + 0xf1980: 0xf1980, + 0xf1981: 0xf1981, + 0xf1982: 0xf1982, + 0xf1983: 0xf1983, + 0xf1984: 0xf1984, + 0xf1985: 0xf1985, + 0xf1986: 0xf1986, + 0xf1987: 0xf1987, + 0xf1988: 0xf1988, + 0xf1989: 0xf1989, + 0xf198a: 0xf198a, + 0xf198b: 0xf198b, + 0xf198c: 0xf198c, + 0xf198d: 0xf198d, + 0xf198e: 0xf198e, + 0xf198f: 0xf198f, + 0xf1990: 0xf1990, + 0xf1991: 0xf1991, + 0xf1992: 0xf1992, + 0xf1993: 0xf1993, + 0xf1994: 0xf1994, + 0xf1995: 0xf1995, + 0xf1996: 0xf1996, + 0xf1997: 0xf1997, + 0xf1998: 0xf1998, + 0xf1999: 0xf1999, + 0xf199a: 0xf199a, + 0xf199b: 0xf199b, + 0xf199c: 0xf199c, + 0xf199d: 0xf199d, + 0xf199e: 0xf199e, + 0xf199f: 0xf199f, + 0xf19a0: 0xf19a0, + 0xf19a1: 0xf19a1, + 0xf19a2: 0xf19a2, + 0xf19a3: 0xf19a3, + 0xf19a4: 0xf19a4, + 0xf19a5: 0xf19a5, + 0xf19a6: 0xf19a6, + 0xf19a7: 0xf19a7, + 0xf19a8: 0xf19a8, + 0xf19a9: 0xf19a9, + 0xf19aa: 0xf19aa, + 0xf19ab: 0xf19ab, + 0xf19ac: 0xf19ac, + 0xf19ad: 0xf19ad, + 0xf19ae: 0xf19ae, + 0xf19af: 0xf19af, + 0xf19b0: 0xf19b0, + 0xf19b1: 0xf19b1, + 0xf19b2: 0xf19b2, + 0xf19b3: 0xf19b3, + 0xf19b4: 0xf19b4, + 0xf19b5: 0xf19b5, + 0xf19b6: 0xf19b6, + 0xf19b7: 0xf19b7, + 0xf19b8: 0xf19b8, + 0xf19b9: 0xf19b9, + 0xf19ba: 0xf19ba, + 0xf19bb: 0xf19bb, + 0xf19bc: 0xf19bc, + 0xf19bd: 0xf19bd, + 0xf19be: 0xf19be, + 0xf19bf: 0xf19bf, + 0xf19c0: 0xf19c0, + 0xf19c1: 0xf19c1, + 0xf19c2: 0xf19c2, + 0xf19c3: 0xf19c3, + 0xf19c4: 0xf19c4, + 0xf19c5: 0xf19c5, + 0xf19c6: 0xf19c6, + 0xf19c7: 0xf19c7, + 0xf19c8: 0xf19c8, + 0xf19c9: 0xf19c9, + 0xf19ca: 0xf19ca, + 0xf19cb: 0xf19cb, + 0xf19cc: 0xf19cc, + 0xf19cd: 0xf19cd, + 0xf19ce: 0xf19ce, + 0xf19cf: 0xf19cf, + 0xf19d0: 0xf19d0, + 0xf19d1: 0xf19d1, + 0xf19d2: 0xf19d2, + 0xf19d3: 0xf19d3, + 0xf19d4: 0xf19d4, + 0xf19d5: 0xf19d5, + 0xf19d6: 0xf19d6, + 0xf19d7: 0xf19d7, + 0xf19d8: 0xf19d8, + 0xf19d9: 0xf19d9, + 0xf19da: 0xf19da, + 0xf19db: 0xf19db, + 0xf19dc: 0xf19dc, + 0xf19dd: 0xf19dd, + 0xf19de: 0xf19de, + 0xf19df: 0xf19df, + 0xf19e0: 0xf19e0, + 0xf19e1: 0xf19e1, + 0xf19e2: 0xf19e2, + 0xf19e3: 0xf19e3, + 0xf19e4: 0xf19e4, + 0xf19e5: 0xf19e5, + 0xf19e6: 0xf19e6, + 0xf19e7: 0xf19e7, + 0xf19e8: 0xf19e8, + 0xf19e9: 0xf19e9, + 0xf19ea: 0xf19ea, + 0xf19eb: 0xf19eb, + 0xf19ec: 0xf19ec, + 0xf19ed: 0xf19ed, + 0xf19ee: 0xf19ee, + 0xf19ef: 0xf19ef, + 0xf19f0: 0xf19f0, + 0xf19f1: 0xf19f1, + 0xf19f2: 0xf19f2, + 0xf19f3: 0xf19f3, + 0xf19f4: 0xf19f4, + 0xf19f5: 0xf19f5, + 0xf19f6: 0xf19f6, + 0xf19f7: 0xf19f7, + 0xf19f8: 0xf19f8, + 0xf19f9: 0xf19f9, + 0xf19fa: 0xf19fa, + 0xf19fb: 0xf19fb, + 0xf19fc: 0xf19fc, + 0xf19fd: 0xf19fd, + 0xf19fe: 0xf19fe, + 0xf19ff: 0xf19ff, + 0xf1a00: 0xf1a00, + 0xf1a01: 0xf1a01, + 0xf1a02: 0xf1a02, + 0xf1a03: 0xf1a03, + 0xf1a04: 0xf1a04, + 0xf1a05: 0xf1a05, + 0xf1a06: 0xf1a06, + 0xf1a07: 0xf1a07, + 0xf1a08: 0xf1a08, + 0xf1a09: 0xf1a09, + 0xf1a0a: 0xf1a0a, + 0xf1a0b: 0xf1a0b, + 0xf1a0c: 0xf1a0c, + 0xf1a0d: 0xf1a0d, + 0xf1a0e: 0xf1a0e, + 0xf1a0f: 0xf1a0f, + 0xf1a10: 0xf1a10, + 0xf1a11: 0xf1a11, + 0xf1a12: 0xf1a12, + 0xf1a13: 0xf1a13, + 0xf1a14: 0xf1a14, + 0xf1a15: 0xf1a15, + 0xf1a16: 0xf1a16, + 0xf1a17: 0xf1a17, + 0xf1a18: 0xf1a18, + 0xf1a19: 0xf1a19, + 0xf1a1a: 0xf1a1a, + 0xf1a1b: 0xf1a1b, + 0xf1a1c: 0xf1a1c, + 0xf1a1d: 0xf1a1d, + 0xf1a1e: 0xf1a1e, + 0xf1a1f: 0xf1a1f, + 0xf1a20: 0xf1a20, + 0xf1a21: 0xf1a21, + 0xf1a22: 0xf1a22, + 0xf1a23: 0xf1a23, + 0xf1a24: 0xf1a24, + 0xf1a25: 0xf1a25, + 0xf1a26: 0xf1a26, + 0xf1a27: 0xf1a27, + 0xf1a28: 0xf1a28, + 0xf1a29: 0xf1a29, + 0xf1a2a: 0xf1a2a, + 0xf1a2b: 0xf1a2b, + 0xf1a2c: 0xf1a2c, + 0xf1a2d: 0xf1a2d, + 0xf1a2e: 0xf1a2e, + 0xf1a2f: 0xf1a2f, + 0xf1a30: 0xf1a30, + 0xf1a31: 0xf1a31, + 0xf1a32: 0xf1a32, + 0xf1a33: 0xf1a33, + 0xf1a34: 0xf1a34, + 0xf1a35: 0xf1a35, + 0xf1a36: 0xf1a36, + 0xf1a37: 0xf1a37, + 0xf1a38: 0xf1a38, + 0xf1a39: 0xf1a39, + 0xf1a3a: 0xf1a3a, + 0xf1a3b: 0xf1a3b, + 0xf1a3c: 0xf1a3c, + 0xf1a3d: 0xf1a3d, + 0xf1a3e: 0xf1a3e, + 0xf1a3f: 0xf1a3f, + 0xf1a40: 0xf1a40, + 0xf1a41: 0xf1a41, + 0xf1a42: 0xf1a42, + 0xf1a43: 0xf1a43, + 0xf1a44: 0xf1a44, + 0xf1a45: 0xf1a45, + 0xf1a46: 0xf1a46, + 0xf1a47: 0xf1a47, + 0xf1a48: 0xf1a48, + 0xf1a49: 0xf1a49, + 0xf1a4a: 0xf1a4a, + 0xf1a4b: 0xf1a4b, + 0xf1a4c: 0xf1a4c, + 0xf1a4d: 0xf1a4d, + 0xf1a4e: 0xf1a4e, + 0xf1a4f: 0xf1a4f, + 0xf1a50: 0xf1a50, + 0xf1a51: 0xf1a51, + 0xf1a52: 0xf1a52, + 0xf1a53: 0xf1a53, + 0xf1a54: 0xf1a54, + 0xf1a55: 0xf1a55, + 0xf1a56: 0xf1a56, + 0xf1a57: 0xf1a57, + 0xf1a58: 0xf1a58, + 0xf1a59: 0xf1a59, + 0xf1a5a: 0xf1a5a, + 0xf1a5b: 0xf1a5b, + 0xf1a5c: 0xf1a5c, + 0xf1a5d: 0xf1a5d, + 0xf1a5e: 0xf1a5e, + 0xf1a5f: 0xf1a5f, + 0xf1a60: 0xf1a60, + 0xf1a61: 0xf1a61, + 0xf1a62: 0xf1a62, + 0xf1a63: 0xf1a63, + 0xf1a64: 0xf1a64, + 0xf1a65: 0xf1a65, + 0xf1a66: 0xf1a66, + 0xf1a67: 0xf1a67, + 0xf1a68: 0xf1a68, + 0xf1a69: 0xf1a69, + 0xf1a6a: 0xf1a6a, + 0xf1a6b: 0xf1a6b, + 0xf1a6c: 0xf1a6c, + 0xf1a6d: 0xf1a6d, + 0xf1a6e: 0xf1a6e, + 0xf1a6f: 0xf1a6f, + 0xf1a70: 0xf1a70, + 0xf1a71: 0xf1a71, + 0xf1a72: 0xf1a72, + 0xf1a73: 0xf1a73, + 0xf1a74: 0xf1a74, + 0xf1a75: 0xf1a75, + 0xf1a76: 0xf1a76, + 0xf1a77: 0xf1a77, + 0xf1a78: 0xf1a78, + 0xf1a79: 0xf1a79, + 0xf1a7a: 0xf1a7a, + 0xf1a7b: 0xf1a7b, + 0xf1a7c: 0xf1a7c, + 0xf1a7d: 0xf1a7d, + 0xf1a7e: 0xf1a7e, + 0xf1a7f: 0xf1a7f, + 0xf1a80: 0xf1a80, + 0xf1a81: 0xf1a81, + 0xf1a82: 0xf1a82, + 0xf1a83: 0xf1a83, + 0xf1a84: 0xf1a84, + 0xf1a85: 0xf1a85, + 0xf1a86: 0xf1a86, + 0xf1a87: 0xf1a87, + 0xf1a88: 0xf1a88, + 0xf1a89: 0xf1a89, + 0xf1a8a: 0xf1a8a, + 0xf1a8b: 0xf1a8b, + 0xf1a8c: 0xf1a8c, + 0xf1a8d: 0xf1a8d, + 0xf1a8e: 0xf1a8e, + 0xf1a8f: 0xf1a8f, + 0xf1a90: 0xf1a90, + 0xf1a91: 0xf1a91, + 0xf1a92: 0xf1a92, + 0xf1a93: 0xf1a93, + 0xf1a94: 0xf1a94, + 0xf1a95: 0xf1a95, + 0xf1a96: 0xf1a96, + 0xf1a97: 0xf1a97, + 0xf1a98: 0xf1a98, + 0xf1a99: 0xf1a99, + 0xf1a9a: 0xf1a9a, + 0xf1a9b: 0xf1a9b, + 0xf1a9c: 0xf1a9c, + 0xf1a9d: 0xf1a9d, + 0xf1a9e: 0xf1a9e, + 0xf1a9f: 0xf1a9f, + 0xf1aa0: 0xf1aa0, + 0xf1aa1: 0xf1aa1, + 0xf1aa2: 0xf1aa2, + 0xf1aa3: 0xf1aa3, + 0xf1aa4: 0xf1aa4, + 0xf1aa5: 0xf1aa5, + 0xf1aa6: 0xf1aa6, + 0xf1aa7: 0xf1aa7, + 0xf1aa8: 0xf1aa8, + 0xf1aa9: 0xf1aa9, + 0xf1aaa: 0xf1aaa, + 0xf1aab: 0xf1aab, + 0xf1aac: 0xf1aac, + 0xf1aad: 0xf1aad, + 0xf1aae: 0xf1aae, + 0xf1aaf: 0xf1aaf, + 0xf1ab0: 0xf1ab0, + 0xf1ab1: 0xf1ab1, + 0xf1ab2: 0xf1ab2, + 0xf1ab3: 0xf1ab3, + 0xf1ab4: 0xf1ab4, + 0xf1ab5: 0xf1ab5, + 0xf1ab6: 0xf1ab6, + 0xf1ab7: 0xf1ab7, + 0xf1ab8: 0xf1ab8, + 0xf1ab9: 0xf1ab9, + 0xf1aba: 0xf1aba, + 0xf1abb: 0xf1abb, + 0xf1abc: 0xf1abc, + 0xf1abd: 0xf1abd, + 0xf1abe: 0xf1abe, + 0xf1abf: 0xf1abf, + 0xf1ac0: 0xf1ac0, + 0xf1ac1: 0xf1ac1, + 0xf1ac2: 0xf1ac2, + 0xf1ac3: 0xf1ac3, + 0xf1ac4: 0xf1ac4, + 0xf1ac5: 0xf1ac5, + 0xf1ac6: 0xf1ac6, + 0xf1ac7: 0xf1ac7, + 0xf1ac8: 0xf1ac8, + 0xf1ac9: 0xf1ac9, + 0xf1aca: 0xf1aca, + 0xf1acb: 0xf1acb, + 0xf1acc: 0xf1acc, + 0xf1acd: 0xf1acd, + 0xf1ace: 0xf1ace, + 0xf1acf: 0xf1acf, + 0xf1ad0: 0xf1ad0, + 0xf1ad1: 0xf1ad1, + 0xf1ad2: 0xf1ad2, + 0xf1ad3: 0xf1ad3, + 0xf1ad4: 0xf1ad4, + 0xf1ad5: 0xf1ad5, + 0xf1ad6: 0xf1ad6, + 0xf1ad7: 0xf1ad7, + 0xf1ad8: 0xf1ad8, + 0xf1ad9: 0xf1ad9, + 0xf1ada: 0xf1ada, + 0xf1adb: 0xf1adb, + 0xf1adc: 0xf1adc, + 0xf1add: 0xf1add, + 0xf1ade: 0xf1ade, + 0xf1adf: 0xf1adf, + 0xf1ae0: 0xf1ae0, + 0xf1ae1: 0xf1ae1, + 0xf1ae2: 0xf1ae2, + 0xf1ae3: 0xf1ae3, + 0xf1ae4: 0xf1ae4, + 0xf1ae5: 0xf1ae5, + 0xf1ae6: 0xf1ae6, + 0xf1ae7: 0xf1ae7, + 0xf1ae8: 0xf1ae8, + 0xf1ae9: 0xf1ae9, + 0xf1aea: 0xf1aea, + 0xf1aeb: 0xf1aeb, + 0xf1aec: 0xf1aec, + 0xf1aed: 0xf1aed, + 0xf1aee: 0xf1aee, + 0xf1aef: 0xf1aef, + 0xf1af0: 0xf1af0, + }, + "Weather Icons": { + 0xf000: 0xe300, + 0xf001: 0xe301, + 0xf002: 0xe302, + 0xf003: 0xe303, + 0xf004: 0xe304, + 0xf005: 0xe305, + 0xf006: 0xe306, + 0xf007: 0xe307, + 0xf008: 0xe308, + 0xf009: 0xe309, + 0xf00a: 0xe30a, + 0xf00b: 0xe30b, + 0xf00c: 0xe30c, + 0xf00d: 0xe30d, + 0xf00e: 0xe30e, + 0xf010: 0xe30f, + 0xf011: 0xe310, + 0xf012: 0xe311, + 0xf013: 0xe312, + 0xf014: 0xe313, + 0xf015: 0xe314, + 0xf016: 0xe315, + 0xf017: 0xe316, + 0xf018: 0xe317, + 0xf019: 0xe318, + 0xf01a: 0xe319, + 0xf01b: 0xe31a, + 0xf01c: 0xe31b, + 0xf01d: 0xe31c, + 0xf01e: 0xe31d, + 0xf021: 0xe31e, + 0xf022: 0xe31f, + 0xf023: 0xe320, + 0xf024: 0xe321, + 0xf025: 0xe322, + 0xf026: 0xe323, + 0xf027: 0xe324, + 0xf028: 0xe325, + 0xf029: 0xe326, + 0xf02a: 0xe327, + 0xf02b: 0xe328, + 0xf02c: 0xe329, + 0xf02d: 0xe32a, + 0xf02e: 0xe32b, + 0xf02f: 0xe32c, + 0xf030: 0xe32d, + 0xf031: 0xe32e, + 0xf032: 0xe32f, + 0xf033: 0xe330, + 0xf034: 0xe331, + 0xf035: 0xe332, + 0xf036: 0xe333, + 0xf037: 0xe334, + 0xf038: 0xe335, + 0xf039: 0xe336, + 0xf03a: 0xe337, + 0xf03b: 0xe338, + 0xf03c: 0xe339, + 0xf03d: 0xe33a, + 0xf03e: 0xe33b, + 0xf040: 0xe33c, + 0xf041: 0xe33d, + 0xf042: 0xe33e, + 0xf043: 0xe33f, + 0xf044: 0xe340, + 0xf045: 0xe341, + 0xf046: 0xe342, + 0xf047: 0xe343, + 0xf048: 0xe344, + 0xf049: 0xe345, + 0xf04a: 0xe346, + 0xf04b: 0xe347, + 0xf04c: 0xe348, + 0xf04d: 0xe349, + 0xf04e: 0xe34a, + 0xf050: 0xe34b, + 0xf051: 0xe34c, + 0xf052: 0xe34d, + 0xf053: 0xe34e, + 0xf054: 0xe34f, + 0xf055: 0xe350, + 0xf056: 0xe351, + 0xf057: 0xe352, + 0xf058: 0xe353, + 0xf059: 0xe354, + 0xf05a: 0xe355, + 0xf05b: 0xe356, + 0xf05c: 0xe357, + 0xf05d: 0xe358, + 0xf05e: 0xe359, + 0xf060: 0xe35a, + 0xf061: 0xe35b, + 0xf062: 0xe35c, + 0xf063: 0xe35d, + 0xf064: 0xe35e, + 0xf065: 0xe35f, + 0xf066: 0xe360, + 0xf067: 0xe361, + 0xf068: 0xe362, + 0xf069: 0xe363, + 0xf06a: 0xe364, + 0xf06b: 0xe365, + 0xf06c: 0xe366, + 0xf06d: 0xe367, + 0xf06e: 0xe368, + 0xf070: 0xe369, + 0xf071: 0xe36a, + 0xf072: 0xe36b, + 0xf073: 0xe36c, + 0xf074: 0xe36d, + 0xf075: 0xe36e, + 0xf076: 0xe36f, + 0xf077: 0xe370, + 0xf078: 0xe371, + 0xf079: 0xe372, + 0xf07a: 0xe373, + 0xf07b: 0xe374, + 0xf07c: 0xe375, + 0xf07d: 0xe376, + 0xf07e: 0xe377, + 0xf080: 0xe378, + 0xf081: 0xe379, + 0xf082: 0xe37a, + 0xf083: 0xe37b, + 0xf084: 0xe37c, + 0xf085: 0xe37d, + 0xf086: 0xe37e, + 0xf087: 0xe37f, + 0xf088: 0xe380, + 0xf089: 0xe381, + 0xf08a: 0xe382, + 0xf08b: 0xe383, + 0xf08c: 0xe384, + 0xf08d: 0xe385, + 0xf08e: 0xe386, + 0xf08f: 0xe387, + 0xf090: 0xe388, + 0xf091: 0xe389, + 0xf092: 0xe38a, + 0xf093: 0xe38b, + 0xf094: 0xe38c, + 0xf095: 0xe38d, + 0xf096: 0xe38e, + 0xf097: 0xe38f, + 0xf098: 0xe390, + 0xf099: 0xe391, + 0xf09a: 0xe392, + 0xf09b: 0xe393, + 0xf09c: 0xe394, + 0xf09d: 0xe395, + 0xf09e: 0xe396, + 0xf09f: 0xe397, + 0xf0a0: 0xe398, + 0xf0a1: 0xe399, + 0xf0a2: 0xe39a, + 0xf0a3: 0xe39b, + 0xf0a4: 0xe39c, + 0xf0a5: 0xe39d, + 0xf0a6: 0xe39e, + 0xf0a7: 0xe39f, + 0xf0a8: 0xe3a0, + 0xf0a9: 0xe3a1, + 0xf0aa: 0xe3a2, + 0xf0ab: 0xe3a3, + 0xf0ac: 0xe3a4, + 0xf0ad: 0xe3a5, + 0xf0ae: 0xe3a6, + 0xf0af: 0xe3a7, + 0xf0b0: 0xe3a8, + 0xf0b1: 0xe3a9, + 0xf0b2: 0xe3aa, + 0xf0b3: 0xe3ab, + 0xf0b4: 0xe3ac, + 0xf0b5: 0xe3ad, + 0xf0b6: 0xe3ae, + 0xf0b7: 0xe3af, + 0xf0b8: 0xe3b0, + 0xf0b9: 0xe3b1, + 0xf0ba: 0xe3b2, + 0xf0bb: 0xe3b3, + 0xf0bc: 0xe3b4, + 0xf0bd: 0xe3b5, + 0xf0be: 0xe3b6, + 0xf0bf: 0xe3b7, + 0xf0c0: 0xe3b8, + 0xf0c1: 0xe3b9, + 0xf0c2: 0xe3ba, + 0xf0c3: 0xe3bb, + 0xf0c4: 0xe3bc, + 0xf0c5: 0xe3bd, + 0xf0c6: 0xe3be, + 0xf0c7: 0xe3bf, + 0xf0c8: 0xe3c0, + 0xf0c9: 0xe3c1, + 0xf0ca: 0xe3c2, + 0xf0cb: 0xe3c3, + 0xf0cc: 0xe3c4, + 0xf0cd: 0xe3c5, + 0xf0ce: 0xe3c6, + 0xf0cf: 0xe3c7, + 0xf0d0: 0xe3c8, + 0xf0d1: 0xe3c9, + 0xf0d2: 0xe3ca, + 0xf0d3: 0xe3cb, + 0xf0d4: 0xe3cc, + 0xf0d5: 0xe3cd, + 0xf0d6: 0xe3ce, + 0xf0d7: 0xe3cf, + 0xf0d8: 0xe3d0, + 0xf0d9: 0xe3d1, + 0xf0da: 0xe3d2, + 0xf0db: 0xe3d3, + 0xf0dc: 0xe3d4, + 0xf0dd: 0xe3d5, + 0xf0de: 0xe3d6, + 0xf0df: 0xe3d7, + 0xf0e0: 0xe3d8, + 0xf0e1: 0xe3d9, + 0xf0e2: 0xe3da, + 0xf0e3: 0xe3db, + 0xf0e4: 0xe3dc, + 0xf0e5: 0xe3dd, + 0xf0e6: 0xe3de, + 0xf0e7: 0xe3df, + 0xf0e8: 0xe3e0, + 0xf0e9: 0xe3e1, + 0xf0ea: 0xe3e2, + 0xf0eb: 0xe3e3, + }, + "Font Logos": { + 0xf300: 0xf300, + 0xf301: 0xf301, + 0xf302: 0xf302, + 0xf303: 0xf303, + 0xf304: 0xf304, + 0xf305: 0xf305, + 0xf306: 0xf306, + 0xf307: 0xf307, + 0xf308: 0xf308, + 0xf309: 0xf309, + 0xf30a: 0xf30a, + 0xf30b: 0xf30b, + 0xf30c: 0xf30c, + 0xf30d: 0xf30d, + 0xf30e: 0xf30e, + 0xf30f: 0xf30f, + 0xf310: 0xf310, + 0xf311: 0xf311, + 0xf312: 0xf312, + 0xf313: 0xf313, + 0xf314: 0xf314, + 0xf315: 0xf315, + 0xf316: 0xf316, + 0xf317: 0xf317, + 0xf318: 0xf318, + 0xf319: 0xf319, + 0xf31a: 0xf31a, + 0xf31b: 0xf31b, + 0xf31c: 0xf31c, + 0xf31d: 0xf31d, + 0xf31e: 0xf31e, + 0xf31f: 0xf31f, + 0xf320: 0xf320, + 0xf321: 0xf321, + 0xf322: 0xf322, + 0xf323: 0xf323, + 0xf324: 0xf324, + 0xf325: 0xf325, + 0xf326: 0xf326, + 0xf327: 0xf327, + 0xf328: 0xf328, + 0xf329: 0xf329, + 0xf32a: 0xf32a, + 0xf32b: 0xf32b, + 0xf32c: 0xf32c, + 0xf32d: 0xf32d, + 0xf32e: 0xf32e, + 0xf32f: 0xf32f, + 0xf330: 0xf330, + 0xf331: 0xf331, + 0xf332: 0xf332, + 0xf333: 0xf333, + 0xf334: 0xf334, + 0xf335: 0xf335, + 0xf336: 0xf336, + 0xf337: 0xf337, + 0xf338: 0xf338, + 0xf339: 0xf339, + 0xf33a: 0xf33a, + 0xf33b: 0xf33b, + 0xf33c: 0xf33c, + 0xf33d: 0xf33d, + 0xf33e: 0xf33e, + 0xf33f: 0xf33f, + 0xf340: 0xf340, + 0xf341: 0xf341, + 0xf342: 0xf342, + 0xf343: 0xf343, + 0xf344: 0xf344, + 0xf345: 0xf345, + 0xf346: 0xf346, + 0xf347: 0xf347, + 0xf348: 0xf348, + 0xf349: 0xf349, + 0xf34a: 0xf34a, + 0xf34b: 0xf34b, + 0xf34c: 0xf34c, + 0xf34d: 0xf34d, + 0xf34e: 0xf34e, + 0xf34f: 0xf34f, + 0xf350: 0xf350, + 0xf351: 0xf351, + 0xf352: 0xf352, + 0xf353: 0xf353, + 0xf354: 0xf354, + 0xf355: 0xf355, + 0xf356: 0xf356, + 0xf357: 0xf357, + 0xf358: 0xf358, + 0xf359: 0xf359, + 0xf35a: 0xf35a, + 0xf35b: 0xf35b, + 0xf35c: 0xf35c, + 0xf35d: 0xf35d, + 0xf35e: 0xf35e, + 0xf35f: 0xf35f, + 0xf360: 0xf360, + 0xf361: 0xf361, + 0xf362: 0xf362, + 0xf363: 0xf363, + 0xf364: 0xf364, + 0xf365: 0xf365, + 0xf366: 0xf366, + 0xf367: 0xf367, + 0xf368: 0xf368, + 0xf369: 0xf369, + 0xf36a: 0xf36a, + 0xf36b: 0xf36b, + 0xf36c: 0xf36c, + 0xf36d: 0xf36d, + 0xf36e: 0xf36e, + 0xf36f: 0xf36f, + 0xf370: 0xf370, + 0xf371: 0xf371, + 0xf372: 0xf372, + 0xf373: 0xf373, + 0xf374: 0xf374, + 0xf375: 0xf375, + 0xf376: 0xf376, + 0xf377: 0xf377, + 0xf378: 0xf378, + 0xf379: 0xf379, + 0xf37a: 0xf37a, + 0xf37b: 0xf37b, + 0xf37c: 0xf37c, + 0xf37d: 0xf37d, + 0xf37e: 0xf37e, + 0xf37f: 0xf37f, + 0xf380: 0xf380, + 0xf381: 0xf381, + }, + "Octicons": { + 0xf000: 0xf400, + 0xf001: 0xf401, + 0xf002: 0xf402, + 0xf005: 0xf403, + 0xf006: 0xf404, + 0xf007: 0xf405, + 0xf008: 0xf406, + 0xf009: 0xf407, + 0xf00a: 0xf408, + 0xf00b: 0xf409, + 0xf00c: 0xf40a, + 0xf00d: 0xf40b, + 0xf00e: 0xf40c, + 0xf010: 0xf40d, + 0xf011: 0xf40e, + 0xf012: 0xf40f, + 0xf013: 0xf410, + 0xf014: 0xf411, + 0xf015: 0xf412, + 0xf016: 0xf413, + 0xf017: 0xf414, + 0xf018: 0xf415, + 0xf019: 0xf416, + 0xf01f: 0xf417, + 0xf020: 0xf418, + 0xf023: 0xf419, + 0xf024: 0xf41a, + 0xf026: 0xf41b, + 0xf027: 0xf41c, + 0xf028: 0xf41d, + 0xf02a: 0xf41e, + 0xf02b: 0xf41f, + 0xf02c: 0xf420, + 0xf02d: 0xf421, + 0xf02e: 0xf422, + 0xf02f: 0xf423, + 0xf030: 0xf424, + 0xf031: 0xf425, + 0xf032: 0xf426, + 0xf033: 0xf427, + 0xf034: 0xf428, + 0xf035: 0xf429, + 0xf036: 0xf42a, + 0xf037: 0xf42b, + 0xf038: 0xf42c, + 0xf039: 0xf42d, + 0xf03a: 0xf42e, + 0xf03b: 0xf42f, + 0xf03c: 0xf430, + 0xf03d: 0xf431, + 0xf03e: 0xf432, + 0xf03f: 0xf433, + 0xf040: 0xf434, + 0xf041: 0xf435, + 0xf042: 0xf436, + 0xf043: 0xf437, + 0xf044: 0xf438, + 0xf045: 0xf439, + 0xf046: 0xf43a, + 0xf047: 0xf43b, + 0xf048: 0xf43c, + 0xf049: 0xf43d, + 0xf04a: 0xf43e, + 0xf04c: 0xf43f, + 0xf04d: 0xf440, + 0xf04e: 0xf441, + 0xf04f: 0xf442, + 0xf051: 0xf443, + 0xf052: 0xf444, + 0xf053: 0xf445, + 0xf056: 0xf446, + 0xf057: 0xf447, + 0xf058: 0xf448, + 0xf059: 0xf449, + 0xf05a: 0xf44a, + 0xf05b: 0xf44b, + 0xf05c: 0xf44c, + 0xf05d: 0xf44d, + 0xf05e: 0xf44e, + 0xf05f: 0xf44f, + 0xf060: 0xf450, + 0xf061: 0xf451, + 0xf062: 0xf452, + 0xf063: 0xf453, + 0xf064: 0xf454, + 0xf068: 0xf455, + 0xf06a: 0xf456, + 0xf06b: 0xf457, + 0xf06c: 0xf458, + 0xf06d: 0xf459, + 0xf06e: 0xf45a, + 0xf070: 0xf45b, + 0xf071: 0xf45c, + 0xf075: 0xf45d, + 0xf076: 0xf45e, + 0xf077: 0xf45f, + 0xf078: 0xf460, + 0xf07b: 0xf461, + 0xf07c: 0xf462, + 0xf07d: 0xf463, + 0xf07e: 0xf464, + 0xf07f: 0xf465, + 0xf080: 0xf466, + 0xf081: 0xf467, + 0xf084: 0xf468, + 0xf085: 0xf469, + 0xf087: 0xf46a, + 0xf088: 0xf46b, + 0xf08c: 0xf46c, + 0xf08d: 0xf46d, + 0xf08f: 0xf46e, + 0xf091: 0xf46f, + 0xf092: 0xf470, + 0xf094: 0xf471, + 0xf096: 0xf472, + 0xf097: 0xf473, + 0xf099: 0xf474, + 0xf09a: 0xf475, + 0xf09c: 0xf476, + 0xf09d: 0xf477, + 0xf09f: 0xf478, + 0xf0a0: 0xf479, + 0xf0a1: 0xf47a, + 0xf0a2: 0xf47b, + 0xf0a3: 0xf47c, + 0xf0a4: 0xf47d, + 0xf0aa: 0xf47e, + 0xf0ac: 0xf47f, + 0xf0ad: 0xf480, + 0xf0b0: 0xf481, + 0xf0b1: 0xf482, + 0xf0b2: 0xf483, + 0xf0b6: 0xf484, + 0xf0ba: 0xf485, + 0xf0be: 0xf486, + 0xf0c4: 0xf487, + 0xf0c5: 0xf488, + 0xf0c8: 0xf489, + 0xf0c9: 0xf48a, + 0xf0ca: 0xf48b, + 0xf0cc: 0xf48c, + 0xf0cf: 0xf48d, + 0xf0d0: 0xf48e, + 0xf0d1: 0xf48f, + 0xf0d2: 0xf490, + 0xf0d3: 0xf491, + 0xf0d4: 0xf492, + 0xf0d6: 0xf493, + 0xf0d7: 0xf494, + 0xf0d8: 0xf495, + 0xf0da: 0xf496, + 0xf0db: 0xf497, + 0xf0dc: 0xf498, + 0xf0dd: 0xf499, + 0xf0de: 0xf49a, + 0xf0e0: 0xf49b, + 0xf0e1: 0xf49c, + 0xf0e2: 0xf49d, + 0xf0e3: 0xf49e, + 0xf0e4: 0xf49f, + 0xf0e5: 0xf4a0, + 0xf0e6: 0xf4a1, + 0xf0e7: 0xf4a2, + 0xf0e8: 0xf4a3, + 0xf101: 0xf4a4, + 0xf102: 0xf4a5, + 0xf103: 0xf4a6, + 0xf104: 0xf4a7, + 0xf105: 0xf4a8, + 0x2665: 0x2665, + 0x26a1: 0x26a1, + 0xf27c: 0xf4a9, + 0xf27d: 0xf4aa, + 0xf27e: 0xf4ab, + 0xf27f: 0xf4ac, + 0xf280: 0xf4ad, + 0xf281: 0xf4ae, + 0xf282: 0xf4af, + 0xf283: 0xf4b0, + 0xf284: 0xf4b1, + 0xf285: 0xf4b2, + 0xf286: 0xf4b3, + 0xf287: 0xf4b4, + 0xf288: 0xf4b5, + 0xf289: 0xf4b6, + 0xf28a: 0xf4b7, + 0xf28b: 0xf4b8, + 0xf28c: 0xf4b9, + 0xf28d: 0xf4ba, + 0xf28e: 0xf4bb, + 0xf28f: 0xf4bc, + 0xf290: 0xf4bd, + 0xf291: 0xf4be, + 0xf292: 0xf4bf, + 0xf293: 0xf4c0, + 0xf294: 0xf4c1, + 0xf295: 0xf4c2, + 0xf296: 0xf4c3, + 0xf297: 0xf4c4, + 0xf298: 0xf4c5, + 0xf299: 0xf4c6, + 0xf29a: 0xf4c7, + 0xf29b: 0xf4c8, + 0xf29c: 0xf4c9, + 0xf29d: 0xf4ca, + 0xf29e: 0xf4cb, + 0xf29f: 0xf4cc, + 0xf2a0: 0xf4cd, + 0xf2a1: 0xf4ce, + 0xf2a2: 0xf4cf, + 0xf2a3: 0xf4d0, + 0xf2a4: 0xf4d1, + 0xf2a5: 0xf4d2, + 0xf2a6: 0xf4d3, + 0xf2a7: 0xf4d4, + 0xf2a8: 0xf4d5, + 0xf2a9: 0xf4d6, + 0xf2aa: 0xf4d7, + 0xf2ab: 0xf4d8, + 0xf2ac: 0xf4d9, + 0xf2ad: 0xf4da, + 0xf2ae: 0xf4db, + 0xf2af: 0xf4dc, + 0xf2b0: 0xf4dd, + 0xf2b1: 0xf4de, + 0xf2b2: 0xf4df, + 0xf2b3: 0xf4e0, + 0xf2b4: 0xf4e1, + 0xf2b5: 0xf4e2, + 0xf2b6: 0xf4e3, + 0xf2b7: 0xf4e4, + 0xf2b8: 0xf4e5, + 0xf2b9: 0xf4e6, + 0xf2ba: 0xf4e7, + 0xf2bb: 0xf4e8, + 0xf2bc: 0xf4e9, + 0xf2bd: 0xf4ea, + 0xf2be: 0xf4eb, + 0xf2bf: 0xf4ec, + 0xf2c0: 0xf4ed, + 0xf2c1: 0xf4ee, + 0xf2c2: 0xf4ef, + 0xf2c3: 0xf4f0, + 0xf2c4: 0xf4f1, + 0xf2c5: 0xf4f2, + 0xf2c6: 0xf4f3, + 0xf2c7: 0xf4f4, + 0xf2c8: 0xf4f5, + 0xf2c9: 0xf4f6, + 0xf2ca: 0xf4f7, + 0xf2cb: 0xf4f8, + 0xf2cc: 0xf4f9, + 0xf2cd: 0xf4fa, + 0xf2ce: 0xf4fb, + 0xf2cf: 0xf4fc, + 0xf2d0: 0xf4fd, + 0xf2d1: 0xf4fe, + 0xf2d2: 0xf4ff, + 0xf2d3: 0xf500, + 0xf2d4: 0xf501, + 0xf2d5: 0xf502, + 0xf2d6: 0xf503, + 0xf2d7: 0xf504, + 0xf2d8: 0xf505, + 0xf2d9: 0xf506, + 0xf2da: 0xf507, + 0xf2db: 0xf508, + 0xf2dc: 0xf509, + 0xf2dd: 0xf50a, + 0xf2de: 0xf50b, + 0xf2df: 0xf50c, + 0xf2e0: 0xf50d, + 0xf2e1: 0xf50e, + 0xf2e2: 0xf50f, + 0xf2e3: 0xf510, + 0xf2e4: 0xf511, + 0xf2e5: 0xf512, + 0xf2e6: 0xf513, + 0xf2e7: 0xf514, + 0xf2e8: 0xf515, + 0xf2e9: 0xf516, + 0xf2ea: 0xf517, + 0xf2eb: 0xf518, + 0xf2ec: 0xf519, + 0xf2ed: 0xf51a, + 0xf2ee: 0xf51b, + 0xf2ef: 0xf51c, + 0xf2f0: 0xf51d, + 0xf2f1: 0xf51e, + 0xf2f2: 0xf51f, + 0xf2f3: 0xf520, + 0xf2f4: 0xf521, + 0xf2f5: 0xf522, + 0xf2f6: 0xf523, + 0xf2f7: 0xf524, + 0xf2f8: 0xf525, + 0xf2f9: 0xf526, + 0xf2fa: 0xf527, + 0xf2fb: 0xf528, + 0xf2fc: 0xf529, + 0xf2fd: 0xf52a, + 0xf2fe: 0xf52b, + 0xf2ff: 0xf52c, + 0xf300: 0xf52d, + 0xf301: 0xf52e, + 0xf302: 0xf52f, + 0xf303: 0xf530, + 0xf304: 0xf531, + 0xf305: 0xf532, + 0xf306: 0xf533, + }, + "Codicons": { + 0xea60: 0xea60, + 0xea61: 0xea61, + 0xea62: 0xea62, + 0xea63: 0xea63, + 0xea64: 0xea64, + 0xea65: 0xea65, + 0xea66: 0xea66, + 0xea67: 0xea67, + 0xea68: 0xea68, + 0xea69: 0xea69, + 0xea6a: 0xea6a, + 0xea6b: 0xea6b, + 0xea6c: 0xea6c, + 0xea6d: 0xea6d, + 0xea6e: 0xea6e, + 0xea6f: 0xea6f, + 0xea70: 0xea70, + 0xea71: 0xea71, + 0xea72: 0xea72, + 0xea73: 0xea73, + 0xea74: 0xea74, + 0xea75: 0xea75, + 0xea76: 0xea76, + 0xea77: 0xea77, + 0xea78: 0xea78, + 0xea79: 0xea79, + 0xea7a: 0xea7a, + 0xea7b: 0xea7b, + 0xea7c: 0xea7c, + 0xea7d: 0xea7d, + 0xea7e: 0xea7e, + 0xea7f: 0xea7f, + 0xea80: 0xea80, + 0xea81: 0xea81, + 0xea82: 0xea82, + 0xea83: 0xea83, + 0xea84: 0xea84, + 0xea85: 0xea85, + 0xea86: 0xea86, + 0xea87: 0xea87, + 0xea88: 0xea88, + 0xea8a: 0xea8a, + 0xea8b: 0xea8b, + 0xea8c: 0xea8c, + 0xea8f: 0xea8f, + 0xea90: 0xea90, + 0xea91: 0xea91, + 0xea92: 0xea92, + 0xea93: 0xea93, + 0xea94: 0xea94, + 0xea95: 0xea95, + 0xea96: 0xea96, + 0xea97: 0xea97, + 0xea98: 0xea98, + 0xea99: 0xea99, + 0xea9a: 0xea9a, + 0xea9b: 0xea9b, + 0xea9c: 0xea9c, + 0xea9d: 0xea9d, + 0xea9e: 0xea9e, + 0xea9f: 0xea9f, + 0xeaa0: 0xeaa0, + 0xeaa1: 0xeaa1, + 0xeaa2: 0xeaa2, + 0xeaa3: 0xeaa3, + 0xeaa4: 0xeaa4, + 0xeaa5: 0xeaa5, + 0xeaa6: 0xeaa6, + 0xeaa7: 0xeaa7, + 0xeaa8: 0xeaa8, + 0xeaa9: 0xeaa9, + 0xeaaa: 0xeaaa, + 0xeaab: 0xeaab, + 0xeaac: 0xeaac, + 0xeaad: 0xeaad, + 0xeaae: 0xeaae, + 0xeaaf: 0xeaaf, + 0xeab0: 0xeab0, + 0xeab1: 0xeab1, + 0xeab2: 0xeab2, + 0xeab3: 0xeab3, + 0xeab4: 0xeab4, + 0xeab5: 0xeab5, + 0xeab6: 0xeab6, + 0xeab7: 0xeab7, + 0xeab8: 0xeab8, + 0xeab9: 0xeab9, + 0xeaba: 0xeaba, + 0xeabb: 0xeabb, + 0xeabc: 0xeabc, + 0xeabd: 0xeabd, + 0xeabe: 0xeabe, + 0xeabf: 0xeabf, + 0xeac0: 0xeac0, + 0xeac1: 0xeac1, + 0xeac2: 0xeac2, + 0xeac3: 0xeac3, + 0xeac4: 0xeac4, + 0xeac5: 0xeac5, + 0xeac6: 0xeac6, + 0xeac7: 0xeac7, + 0xeac9: 0xeac9, + 0xeacc: 0xeacc, + 0xeacd: 0xeacd, + 0xeace: 0xeace, + 0xeacf: 0xeacf, + 0xead0: 0xead0, + 0xead1: 0xead1, + 0xead2: 0xead2, + 0xead3: 0xead3, + 0xead4: 0xead4, + 0xead5: 0xead5, + 0xead6: 0xead6, + 0xead7: 0xead7, + 0xead8: 0xead8, + 0xead9: 0xead9, + 0xeada: 0xeada, + 0xeadb: 0xeadb, + 0xeadc: 0xeadc, + 0xeadd: 0xeadd, + 0xeade: 0xeade, + 0xeadf: 0xeadf, + 0xeae0: 0xeae0, + 0xeae1: 0xeae1, + 0xeae2: 0xeae2, + 0xeae3: 0xeae3, + 0xeae4: 0xeae4, + 0xeae5: 0xeae5, + 0xeae6: 0xeae6, + 0xeae7: 0xeae7, + 0xeae8: 0xeae8, + 0xeae9: 0xeae9, + 0xeaea: 0xeaea, + 0xeaeb: 0xeaeb, + 0xeaec: 0xeaec, + 0xeaed: 0xeaed, + 0xeaee: 0xeaee, + 0xeaef: 0xeaef, + 0xeaf0: 0xeaf0, + 0xeaf1: 0xeaf1, + 0xeaf2: 0xeaf2, + 0xeaf3: 0xeaf3, + 0xeaf4: 0xeaf4, + 0xeaf5: 0xeaf5, + 0xeaf6: 0xeaf6, + 0xeaf7: 0xeaf7, + 0xeaf8: 0xeaf8, + 0xeaf9: 0xeaf9, + 0xeafa: 0xeafa, + 0xeafb: 0xeafb, + 0xeafc: 0xeafc, + 0xeafd: 0xeafd, + 0xeafe: 0xeafe, + 0xeaff: 0xeaff, + 0xeb00: 0xeb00, + 0xeb01: 0xeb01, + 0xeb02: 0xeb02, + 0xeb03: 0xeb03, + 0xeb04: 0xeb04, + 0xeb05: 0xeb05, + 0xeb06: 0xeb06, + 0xeb07: 0xeb07, + 0xeb08: 0xeb08, + 0xeb09: 0xeb09, + 0xeb0b: 0xeb0b, + 0xeb0c: 0xeb0c, + 0xeb0d: 0xeb0d, + 0xeb0e: 0xeb0e, + 0xeb0f: 0xeb0f, + 0xeb10: 0xeb10, + 0xeb11: 0xeb11, + 0xeb12: 0xeb12, + 0xeb13: 0xeb13, + 0xeb14: 0xeb14, + 0xeb15: 0xeb15, + 0xeb16: 0xeb16, + 0xeb17: 0xeb17, + 0xeb18: 0xeb18, + 0xeb19: 0xeb19, + 0xeb1a: 0xeb1a, + 0xeb1b: 0xeb1b, + 0xeb1c: 0xeb1c, + 0xeb1d: 0xeb1d, + 0xeb1e: 0xeb1e, + 0xeb1f: 0xeb1f, + 0xeb20: 0xeb20, + 0xeb21: 0xeb21, + 0xeb22: 0xeb22, + 0xeb23: 0xeb23, + 0xeb24: 0xeb24, + 0xeb25: 0xeb25, + 0xeb26: 0xeb26, + 0xeb27: 0xeb27, + 0xeb28: 0xeb28, + 0xeb29: 0xeb29, + 0xeb2a: 0xeb2a, + 0xeb2b: 0xeb2b, + 0xeb2c: 0xeb2c, + 0xeb2d: 0xeb2d, + 0xeb2e: 0xeb2e, + 0xeb2f: 0xeb2f, + 0xeb30: 0xeb30, + 0xeb31: 0xeb31, + 0xeb32: 0xeb32, + 0xeb33: 0xeb33, + 0xeb34: 0xeb34, + 0xeb35: 0xeb35, + 0xeb36: 0xeb36, + 0xeb37: 0xeb37, + 0xeb38: 0xeb38, + 0xeb39: 0xeb39, + 0xeb3a: 0xeb3a, + 0xeb3b: 0xeb3b, + 0xeb3c: 0xeb3c, + 0xeb3d: 0xeb3d, + 0xeb3e: 0xeb3e, + 0xeb3f: 0xeb3f, + 0xeb40: 0xeb40, + 0xeb41: 0xeb41, + 0xeb42: 0xeb42, + 0xeb43: 0xeb43, + 0xeb44: 0xeb44, + 0xeb45: 0xeb45, + 0xeb46: 0xeb46, + 0xeb47: 0xeb47, + 0xeb48: 0xeb48, + 0xeb49: 0xeb49, + 0xeb4a: 0xeb4a, + 0xeb4b: 0xeb4b, + 0xeb4c: 0xeb4c, + 0xeb4d: 0xeb4d, + 0xeb4e: 0xeb4e, + 0xeb50: 0xeb50, + 0xeb51: 0xeb51, + 0xeb52: 0xeb52, + 0xeb53: 0xeb53, + 0xeb54: 0xeb54, + 0xeb55: 0xeb55, + 0xeb56: 0xeb56, + 0xeb57: 0xeb57, + 0xeb58: 0xeb58, + 0xeb59: 0xeb59, + 0xeb5a: 0xeb5a, + 0xeb5b: 0xeb5b, + 0xeb5c: 0xeb5c, + 0xeb5d: 0xeb5d, + 0xeb5e: 0xeb5e, + 0xeb5f: 0xeb5f, + 0xeb60: 0xeb60, + 0xeb61: 0xeb61, + 0xeb62: 0xeb62, + 0xeb63: 0xeb63, + 0xeb64: 0xeb64, + 0xeb65: 0xeb65, + 0xeb66: 0xeb66, + 0xeb67: 0xeb67, + 0xeb68: 0xeb68, + 0xeb69: 0xeb69, + 0xeb6a: 0xeb6a, + 0xeb6b: 0xeb6b, + 0xeb6c: 0xeb6c, + 0xeb6d: 0xeb6d, + 0xeb6e: 0xeb6e, + 0xeb6f: 0xeb6f, + 0xeb70: 0xeb70, + 0xeb71: 0xeb71, + 0xeb72: 0xeb72, + 0xeb73: 0xeb73, + 0xeb74: 0xeb74, + 0xeb75: 0xeb75, + 0xeb76: 0xeb76, + 0xeb77: 0xeb77, + 0xeb78: 0xeb78, + 0xeb79: 0xeb79, + 0xeb7a: 0xeb7a, + 0xeb7b: 0xeb7b, + 0xeb7c: 0xeb7c, + 0xeb7d: 0xeb7d, + 0xeb7e: 0xeb7e, + 0xeb7f: 0xeb7f, + 0xeb80: 0xeb80, + 0xeb81: 0xeb81, + 0xeb82: 0xeb82, + 0xeb83: 0xeb83, + 0xeb84: 0xeb84, + 0xeb85: 0xeb85, + 0xeb86: 0xeb86, + 0xeb87: 0xeb87, + 0xeb88: 0xeb88, + 0xeb89: 0xeb89, + 0xeb8a: 0xeb8a, + 0xeb8b: 0xeb8b, + 0xeb8c: 0xeb8c, + 0xeb8d: 0xeb8d, + 0xeb8e: 0xeb8e, + 0xeb8f: 0xeb8f, + 0xeb90: 0xeb90, + 0xeb91: 0xeb91, + 0xeb92: 0xeb92, + 0xeb93: 0xeb93, + 0xeb94: 0xeb94, + 0xeb95: 0xeb95, + 0xeb96: 0xeb96, + 0xeb97: 0xeb97, + 0xeb98: 0xeb98, + 0xeb99: 0xeb99, + 0xeb9a: 0xeb9a, + 0xeb9b: 0xeb9b, + 0xeb9c: 0xeb9c, + 0xeb9d: 0xeb9d, + 0xeb9e: 0xeb9e, + 0xeb9f: 0xeb9f, + 0xeba0: 0xeba0, + 0xeba1: 0xeba1, + 0xeba2: 0xeba2, + 0xeba3: 0xeba3, + 0xeba4: 0xeba4, + 0xeba5: 0xeba5, + 0xeba6: 0xeba6, + 0xeba7: 0xeba7, + 0xeba8: 0xeba8, + 0xeba9: 0xeba9, + 0xebaa: 0xebaa, + 0xebab: 0xebab, + 0xebac: 0xebac, + 0xebad: 0xebad, + 0xebae: 0xebae, + 0xebaf: 0xebaf, + 0xebb0: 0xebb0, + 0xebb1: 0xebb1, + 0xebb2: 0xebb2, + 0xebb3: 0xebb3, + 0xebb4: 0xebb4, + 0xebb5: 0xebb5, + 0xebb6: 0xebb6, + 0xebb7: 0xebb7, + 0xebb8: 0xebb8, + 0xebb9: 0xebb9, + 0xebba: 0xebba, + 0xebbb: 0xebbb, + 0xebbc: 0xebbc, + 0xebbd: 0xebbd, + 0xebbe: 0xebbe, + 0xebbf: 0xebbf, + 0xebc0: 0xebc0, + 0xebc1: 0xebc1, + 0xebc2: 0xebc2, + 0xebc3: 0xebc3, + 0xebc4: 0xebc4, + 0xebc5: 0xebc5, + 0xebc6: 0xebc6, + 0xebc7: 0xebc7, + 0xebc8: 0xebc8, + 0xebc9: 0xebc9, + 0xebca: 0xebca, + 0xebcb: 0xebcb, + 0xebcc: 0xebcc, + 0xebcd: 0xebcd, + 0xebce: 0xebce, + 0xebcf: 0xebcf, + 0xebd0: 0xebd0, + 0xebd1: 0xebd1, + 0xebd2: 0xebd2, + 0xebd3: 0xebd3, + 0xebd4: 0xebd4, + 0xebd5: 0xebd5, + 0xebd6: 0xebd6, + 0xebd7: 0xebd7, + 0xebd8: 0xebd8, + 0xebd9: 0xebd9, + 0xebda: 0xebda, + 0xebdb: 0xebdb, + 0xebdc: 0xebdc, + 0xebdd: 0xebdd, + 0xebde: 0xebde, + 0xebdf: 0xebdf, + 0xebe0: 0xebe0, + 0xebe1: 0xebe1, + 0xebe2: 0xebe2, + 0xebe3: 0xebe3, + 0xebe4: 0xebe4, + 0xebe5: 0xebe5, + 0xebe6: 0xebe6, + 0xebe7: 0xebe7, + 0xebe8: 0xebe8, + 0xebe9: 0xebe9, + 0xebea: 0xebea, + 0xebeb: 0xebeb, + 0xebec: 0xebec, + 0xebed: 0xebed, + 0xebee: 0xebee, + 0xebef: 0xebef, + 0xebf0: 0xebf0, + 0xebf1: 0xebf1, + 0xebf2: 0xebf2, + 0xebf3: 0xebf3, + 0xebf4: 0xebf4, + 0xebf5: 0xebf5, + 0xebf6: 0xebf6, + 0xebf7: 0xebf7, + 0xebf8: 0xebf8, + 0xebf9: 0xebf9, + 0xebfa: 0xebfa, + 0xebfb: 0xebfb, + 0xebfc: 0xebfc, + 0xebfd: 0xebfd, + 0xebfe: 0xebfe, + 0xebff: 0xebff, + 0xec00: 0xec00, + 0xec01: 0xec01, + 0xec02: 0xec02, + 0xec03: 0xec03, + 0xec04: 0xec04, + 0xec05: 0xec05, + 0xec06: 0xec06, + 0xec07: 0xec07, + 0xec08: 0xec08, + 0xec09: 0xec09, + 0xec0a: 0xec0a, + 0xec0b: 0xec0b, + 0xec0c: 0xec0c, + 0xec0d: 0xec0d, + 0xec0e: 0xec0e, + 0xec0f: 0xec0f, + 0xec10: 0xec10, + 0xec11: 0xec11, + 0xec12: 0xec12, + 0xec13: 0xec13, + 0xec14: 0xec14, + 0xec15: 0xec15, + 0xec16: 0xec16, + 0xec17: 0xec17, + 0xec18: 0xec18, + 0xec19: 0xec19, + 0xec1a: 0xec1a, + 0xec1b: 0xec1b, + 0xec1c: 0xec1c, + 0xec1d: 0xec1d, + 0xec1e: 0xec1e, + }, +} 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 672845bfd..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; @@ -41,7 +40,7 @@ const CellCacheTable = CacheTable( // I'd expect then an average of 256 frequently cached runs is a // safe guess most terminal screens. 256, - // 8 items per bucket to give decent resilliency to important runs. + // 8 items per bucket to give decent resiliency to important runs. 8, ); diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index f1368679d..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, 5, 3, 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, 10, 3, 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, 5, 3, 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, 5, 3, 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, 5, 3, 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, 10, 3, 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, 10, 3, 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, 5, 3, 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, 5, 3, 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, 5, 3, 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, 5, 3, 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, 5, 3, 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, 10, 3, 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, 5, 3, 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, 30, 3, 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, 10, 3, 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, 10, 3, 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, 30, 3, 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, 30, 3, 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, 10, 3, 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, 10, 3, 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, 10, 3, 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, 3, 10, 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, 10, 3, 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, 3, 10, 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, 3, 10, 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, 3, 10, 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, 3, 10, 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, 10, 3, 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 b5c96797f..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, 5, 3, 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, 10, 3, 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, 5, 3, 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, 5, 3, 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, 10, 3, 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, 5, 3, 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, 5, 3, 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, 5, 3, 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, 120, 30, 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, 5, 3, 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,10 +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(alloc, 30, 3, 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.* = .{ @@ -531,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| { @@ -563,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, 10, 3, 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| { @@ -598,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, 10, 3, 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| { @@ -628,18 +705,27 @@ test "shape with empty cells in between" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 30, 3, 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"); + 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| { @@ -666,16 +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(alloc, 30, 3, 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| { @@ -704,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, 10, 3, 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| { @@ -737,9 +838,16 @@ test "shape selection boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 10, 3, 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 { @@ -747,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| { @@ -769,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| { @@ -791,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| { @@ -813,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| { @@ -835,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| { @@ -860,9 +943,16 @@ test "shape cursor boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 10, 3, 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 { @@ -870,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| { @@ -888,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; @@ -905,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| { @@ -924,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; @@ -941,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| { @@ -959,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; @@ -976,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| { @@ -997,9 +1080,19 @@ test "shape cursor boundary and colored emoji" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 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 { @@ -1007,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| { @@ -1024,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; @@ -1040,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| { @@ -1055,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; @@ -1071,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| { @@ -1092,15 +1180,21 @@ test "shape cell attribute change" { // Plain >= should shape into 1 run { - var screen = try terminal.Screen.init(alloc, 10, 3, 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| { @@ -1112,17 +1206,23 @@ test "shape cell attribute change" { // Bold vs regular should split { - var screen = try terminal.Screen.init(alloc, 3, 10, 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"); + 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| { @@ -1134,18 +1234,26 @@ test "shape cell attribute change" { // Changing fg color should split { - var screen = try terminal.Screen.init(alloc, 3, 10, 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| { @@ -1157,18 +1265,26 @@ test "shape cell attribute change" { // Changing bg color should not split { - var screen = try terminal.Screen.init(alloc, 3, 10, 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| { @@ -1180,17 +1296,24 @@ test "shape cell attribute change" { // Same bg color should not split { - var screen = try terminal.Screen.init(alloc, 3, 10, 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| { 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/global.zig b/src/global.zig index 8034fabe0..29eaf5f36 100644 --- a/src/global.zig +++ b/src/global.zig @@ -39,9 +39,13 @@ pub const GlobalState = struct { resources_dir: internal_os.ResourcesDir, /// Where logging should go - pub const Logging = union(enum) { - disabled: void, - stderr: void, + pub const Logging = packed struct { + /// Whether to log to stderr. For lib mode we always disable stderr + /// logging by default. Otherwise it's enabled by default. + stderr: bool = build_config.app_runtime != .none, + /// Whether to log to macOS's unified logging. Enabled by default + /// on macOS. + macos: bool = builtin.os.tag.isDarwin(), }; /// Initialize the global state. @@ -61,7 +65,7 @@ pub const GlobalState = struct { .gpa = null, .alloc = undefined, .action = null, - .logging = .{ .stderr = {} }, + .logging = .{}, .rlimits = .{}, .resources_dir = .{}, }; @@ -100,12 +104,7 @@ pub const GlobalState = struct { // If we have an action executing, we disable logging by default // since we write to stderr we don't want logs messing up our // output. - if (self.action != null) self.logging = .{ .disabled = {} }; - - // For lib mode we always disable stderr logging by default. - if (comptime build_config.app_runtime == .none) { - self.logging = .{ .disabled = {} }; - } + if (self.action != null) self.logging.stderr = false; // I don't love the env var name but I don't have it in my heart // to parse CLI args 3 times (once for actions, once for config, @@ -114,9 +113,7 @@ pub const GlobalState = struct { // easy to set. if ((try internal_os.getenv(self.alloc, "GHOSTTY_LOG"))) |v| { defer v.deinit(self.alloc); - if (v.value.len > 0) { - self.logging = .{ .stderr = {} }; - } + self.logging = cli.args.parsePackedStruct(Logging, v.value) catch .{}; } // Setup our signal handlers before logging diff --git a/src/input.zig b/src/input.zig index caaf80509..be84a60d6 100644 --- a/src/input.zig +++ b/src/input.zig @@ -1,6 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); +const config = @import("input/config.zig"); const mouse = @import("input/mouse.zig"); const key = @import("input/key.zig"); const keyboard = @import("input/keyboard.zig"); @@ -8,7 +9,9 @@ const keyboard = @import("input/keyboard.zig"); pub const command = @import("input/command.zig"); pub const function_keys = @import("input/function_keys.zig"); pub const keycodes = @import("input/keycodes.zig"); +pub const key_encode = @import("input/key_encode.zig"); pub const kitty = @import("input/kitty.zig"); +pub const paste = @import("input/paste.zig"); pub const ctrlOrSuper = key.ctrlOrSuper; pub const Action = key.Action; @@ -17,13 +20,13 @@ pub const Command = command.Command; pub const Link = @import("input/Link.zig"); pub const Key = key.Key; pub const KeyboardLayout = keyboard.Layout; -pub const KeyEncoder = @import("input/KeyEncoder.zig"); pub const KeyEvent = key.KeyEvent; pub const InspectorMode = Binding.Action.InspectorMode; pub const Mods = key.Mods; pub const MouseButton = mouse.Button; pub const MouseButtonState = mouse.ButtonState; pub const MousePressureStage = mouse.PressureStage; +pub const OptionAsAlt = config.OptionAsAlt; pub const ScrollMods = mouse.ScrollMods; pub const SplitFocusDirection = Binding.Action.SplitFocusDirection; pub const SplitResizeDirection = Binding.Action.SplitResizeDirection; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index c44fb0b09..31672bc1a 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; @@ -296,7 +296,7 @@ pub const Action = union(enum) { reset, /// Copy the selected text to the clipboard. - copy_to_clipboard, + copy_to_clipboard: CopyToClipboard, /// Paste the contents of the default clipboard. paste_from_clipboard, @@ -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, @@ -347,6 +366,10 @@ pub const Action = union(enum) { /// Scroll to the selected text. scroll_to_selection, + /// Scroll to the given absolute row in the screen with 0 being + /// the first row. + scroll_to_row: usize, + /// Scroll the screen up by one page. scroll_page_up, @@ -430,13 +453,13 @@ pub const Action = union(enum) { /// The default OS editor is determined by using `open` on macOS /// and `xdg-open` on Linux. /// - write_scrollback_file: WriteScreenAction, + write_scrollback_file: WriteScreen, /// Write the contents of the screen into a temporary file with the /// specified action. /// /// See `write_scrollback_file` for possible actions. - write_screen_file: WriteScreenAction, + write_screen_file: WriteScreen, /// Write the currently selected text into a temporary file with the /// specified action. @@ -444,7 +467,7 @@ pub const Action = union(enum) { /// See `write_scrollback_file` for possible actions. /// /// Does nothing when no text is selected. - write_selection_file: WriteScreenAction, + write_selection_file: WriteScreen, /// Open a new window. /// @@ -496,6 +519,11 @@ pub const Action = union(enum) { /// version can be found by running `ghostty +version`. prompt_surface_title, + /// Change the title of the current tab/window via a pop-up prompt. The + /// title set via this prompt overrides any title set by the terminal + /// and persists across focus changes within the tab. + prompt_tab_title, + /// Create a new split in the specified direction. /// /// Valid arguments: @@ -517,6 +545,9 @@ pub const Action = union(enum) { /// (`previous` and `next`). goto_split: SplitFocusDirection, + /// Focus on either the previous window or the next one ('previous', 'next') + goto_window: GotoWindow, + /// Zoom in or out of the current split. /// /// When a split is zoomed into, it will take up the entire space in @@ -524,6 +555,16 @@ pub const Action = union(enum) { /// reflect this by displaying an icon indicating the zoomed state. toggle_split_zoom, + /// Toggle read-only mode for the current surface. + /// + /// When a surface is in read-only mode: + /// - No input is sent to the PTY (mouse events, key encoding) + /// - Input can still be used at the terminal level to make selections, + /// copy/paste (keybinds), scroll, etc. + /// - Warn before quit is always enabled in this state even if an active + /// process is not running + toggle_readonly, + /// Resize the current split in the specified direction and amount in /// pixels. The two arguments should be joined with a comma (`,`), /// like in `resize_split:up,10`. @@ -577,9 +618,8 @@ pub const Action = union(enum) { /// of the `confirm-close-surface` configuration setting. close_surface, - /// Close the current tab and all splits therein _or_ close all tabs and - /// splits thein of tabs _other_ than the current tab, depending on the - /// mode. + /// Close the current tab and all splits therein, close all other tabs, or + /// close every tab to the right of the current one depending on the mode. /// /// If the mode is not specified, defaults to closing the current tab. /// @@ -633,6 +673,17 @@ pub const Action = union(enum) { /// Only implemented on macOS, as this uses a built-in system API. toggle_secure_input, + /// Toggle mouse reporting on or off. + /// + /// When mouse reporting is disabled, mouse events will not be reported to + /// terminal applications even if they request it. This allows you to always + /// use the mouse for selection and other terminal UI interactions without + /// applications capturing mouse input. + /// + /// This can also be controlled via the `mouse-reporting` configuration + /// option. + toggle_mouse_reporting, + /// Toggle the command palette. /// /// The command palette is a popup that lets you see what actions @@ -796,6 +847,20 @@ pub const Action = union(enum) { .application = try alloc.dupe(u8, self.application), }; } + + pub fn format( + self: CursorKey, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + _ = self; + _ = writer; + @panic("formatting not supported"); + } + }; + + pub const NavigateSearch = enum { + previous, + next, }; pub const AdjustSelection = enum { @@ -869,15 +934,87 @@ pub const Action = union(enum) { right, }; + pub const GotoWindow = enum { + previous, + next, + }; + pub const SplitResizeParameter = struct { SplitResizeDirection, u16, }; - pub const WriteScreenAction = enum { - copy, - paste, - open, + pub const CopyToClipboard = enum { + plain, + vt, + html, + + /// This type will mix multiple distinct types with a set content-type + /// such as text/html for html, so that the OS/application can choose + /// what is best when pasting. + mixed, + + pub const default: CopyToClipboard = .mixed; + }; + + pub const WriteScreen = struct { + action: WriteScreen.Action, + emit: WriteScreen.Format, + + pub const copy: WriteScreen = .{ .action = .copy, .emit = .plain }; + pub const paste: WriteScreen = .{ .action = .paste, .emit = .plain }; + pub const open: WriteScreen = .{ .action = .open, .emit = .plain }; + + pub const Action = enum { + copy, + paste, + open, + }; + + pub const Format = enum { + plain, + vt, + html, + }; + + pub fn parse(param: []const u8) !WriteScreen { + // If we don't have a `,`, default to the plain format. This is + // also very important for backwards compatibility before Ghostty + // 1.3 which didn't support output formats. + const idx = std.mem.indexOfScalar(u8, param, ',') orelse return .{ + .action = try Binding.Action.parseEnum( + WriteScreen.Action, + param, + ), + .emit = .plain, + }; + + return .{ + .action = try Binding.Action.parseEnum( + WriteScreen.Action, + param[0..idx], + ), + .emit = try Binding.Action.parseEnum( + WriteScreen.Format, + param[idx + 1 ..], + ), + }; + } + + pub fn clone( + self: WriteScreen, + alloc: Allocator, + ) Allocator.Error!WriteScreen { + _ = alloc; + return self; + } + + pub fn format(self: WriteScreen, writer: *std.Io.Writer) std.Io.Writer.Error!void { + try writer.print("{t},{t}", .{ + self.action, + self.emit, + }); + } }; // Extern because it is used in the embedded runtime ABI. @@ -890,6 +1027,7 @@ pub const Action = union(enum) { pub const CloseTabMode = enum { this, other, + right, pub const default: CloseTabMode = .this; }; @@ -920,7 +1058,7 @@ pub const Action = union(enum) { if (@hasDecl(field.type, "parse") and @typeInfo(@TypeOf(field.type.parse)) == .@"fn") { - return field.type.parse(param); + return try field.type.parse(param); } } @@ -1061,6 +1199,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, @@ -1072,11 +1214,13 @@ pub const Action = union(enum) { .reset_font_size, .set_font_size, .prompt_surface_title, + .prompt_tab_title, .clear_screen, .select_all, .scroll_to_top, .scroll_to_bottom, .scroll_to_selection, + .scroll_to_row, .scroll_page_up, .scroll_page_down, .scroll_page_fractional, @@ -1094,6 +1238,7 @@ pub const Action = union(enum) { .toggle_window_decorations, .toggle_window_float_on_top, .toggle_secure_input, + .toggle_mouse_reporting, .toggle_command_palette, .show_on_screen_keyboard, .reset_window_size, @@ -1113,7 +1258,9 @@ pub const Action = union(enum) { .toggle_tab_overview, .new_split, .goto_split, + .goto_window, .toggle_split_zoom, + .toggle_readonly, .resize_split, .equalize_splits, .inspector, @@ -1214,12 +1361,19 @@ pub const Action = union(enum) { .@"enum" => try writer.print("{t}", .{value}), .float => try writer.print("{d}", .{value}), .int => try writer.print("{d}", .{value}), - .@"struct" => |info| if (!info.is_tuple) { - try writer.print("{} (not configurable)", .{value}); - } else { - inline for (info.fields, 0..) |field, i| { - try formatValue(writer, @field(value, field.name)); - if (i + 1 < info.fields.len) try writer.writeAll(","); + .@"struct" => |info| format: { + if (@hasDecl(Value, "format")) { + try value.format(writer); + break :format; + } + + if (!info.is_tuple) { + @compileError("unhandled struct type: " ++ @typeName(Value)); + } else { + inline for (info.fields, 0..) |field, i| { + try formatValue(writer, @field(value, field.name)); + if (i + 1 < info.fields.len) try writer.writeAll(","); + } } }, else => @compileError("unhandled type: " ++ @typeName(Value)), @@ -3222,6 +3376,84 @@ test "parse: set_font_size" { } } +test "parse: copy to clipboard default" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=copy_to_clipboard"); + try testing.expect(binding.action == .copy_to_clipboard); + try testing.expectEqual(Action.CopyToClipboard.mixed, binding.action.copy_to_clipboard); + } +} + +test "parse: copy to clipboard explicit" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=copy_to_clipboard:html"); + try testing.expect(binding.action == .copy_to_clipboard); + try testing.expectEqual(Action.CopyToClipboard.html, binding.action.copy_to_clipboard); + } +} + +test "parse: write screen file no format" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=write_screen_file:copy"); + try testing.expect(binding.action == .write_screen_file); + try testing.expectEqual(Action.WriteScreen.copy, binding.action.write_screen_file); + } +} + +test "parse: write screen file format" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=write_screen_file:copy,html"); + try testing.expect(binding.action == .write_screen_file); + try testing.expectEqual(Action.WriteScreen{ + .action = .copy, + .emit = .html, + }, binding.action.write_screen_file); + } +} + +test "parse: write screen file format as string" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + const binding = try parseSingle("a=write_screen_file:copy,html"); + try binding.action.format(&buf.writer); + try testing.expectEqualStrings("write_screen_file:copy,html", buf.written()); + } +} + +test "parse: write screen file invalid" { + const testing = std.testing; + + // paramet r + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:", + )); + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:,", + )); + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:copy,", + )); + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:copy,html,extra", + )); +} + test "action: format" { const testing = std.testing; const alloc = testing.allocator; 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 ba55820fc..a377effa2 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; @@ -50,7 +49,7 @@ pub const Command = struct { return .{ .action_key = @tagName(self.action), - .action = std.fmt.comptimePrint("{t}", .{self.action}), + .action = std.fmt.comptimePrint("{f}", .{self.action}), .title = self.title, .description = self.description, }; @@ -121,11 +120,23 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Reset the terminal to a clean state.", }}, - .copy_to_clipboard => comptime &.{.{ - .action = .copy_to_clipboard, + .copy_to_clipboard => comptime &.{ .{ + .action = .{ .copy_to_clipboard = .mixed }, .title = "Copy to Clipboard", - .description = "Copy the selected text to the clipboard.", - }}, + .description = "Copy the selected text to the clipboard in both plain and styled formats.", + }, .{ + .action = .{ .copy_to_clipboard = .plain }, + .title = "Copy Selection as Plain Text to Clipboard", + .description = "Copy the selected text as plain text to the clipboard.", + }, .{ + .action = .{ .copy_to_clipboard = .vt }, + .title = "Copy Selection as ANSI Sequences to Clipboard", + .description = "Copy the selected text as ANSI escape sequences to the clipboard.", + }, .{ + .action = .{ .copy_to_clipboard = .html }, + .title = "Copy Selection as HTML to Clipboard", + .description = "Copy the selected text as HTML to the clipboard.", + } }, .copy_url_to_clipboard => comptime &.{.{ .action = .copy_url_to_clipboard, @@ -151,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", @@ -227,6 +260,56 @@ fn actionCommands(action: Action.Key) []const Command { .title = "Copy Screen to Temporary File and Open", .description = "Copy the screen contents to a temporary file and open it.", }, + + .{ + .action = .{ .write_screen_file = .{ + .action = .copy, + .emit = .html, + } }, + .title = "Copy Screen as HTML to Temporary File and Copy Path", + .description = "Copy the screen contents as HTML to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .paste, + .emit = .html, + } }, + .title = "Copy Screen as HTML to Temporary File and Paste Path", + .description = "Copy the screen contents as HTML to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .open, + .emit = .html, + } }, + .title = "Copy Screen as HTML to Temporary File and Open", + .description = "Copy the screen contents as HTML to a temporary file and open it.", + }, + + .{ + .action = .{ .write_screen_file = .{ + .action = .copy, + .emit = .vt, + } }, + .title = "Copy Screen as ANSI Sequences to Temporary File and Copy Path", + .description = "Copy the screen contents as ANSI escape sequences to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .paste, + .emit = .vt, + } }, + .title = "Copy Screen as ANSI Sequences to Temporary File and Paste Path", + .description = "Copy the screen contents as ANSI escape sequences to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .open, + .emit = .vt, + } }, + .title = "Copy Screen as ANSI Sequences to Temporary File and Open", + .description = "Copy the screen contents as ANSI escape sequences to a temporary file and open it.", + }, }, .write_selection_file => comptime &.{ @@ -245,6 +328,56 @@ fn actionCommands(action: Action.Key) []const Command { .title = "Copy Selection to Temporary File and Open", .description = "Copy the selection contents to a temporary file and open it.", }, + + .{ + .action = .{ .write_selection_file = .{ + .action = .copy, + .emit = .html, + } }, + .title = "Copy Selection as HTML to Temporary File and Copy Path", + .description = "Copy the selection contents as HTML to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .paste, + .emit = .html, + } }, + .title = "Copy Selection as HTML to Temporary File and Paste Path", + .description = "Copy the selection contents as HTML to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .open, + .emit = .html, + } }, + .title = "Copy Selection as HTML to Temporary File and Open", + .description = "Copy the selection contents as HTML to a temporary file and open it.", + }, + + .{ + .action = .{ .write_selection_file = .{ + .action = .copy, + .emit = .vt, + } }, + .title = "Copy Selection as ANSI Sequences to Temporary File and Copy Path", + .description = "Copy the selection contents as ANSI escape sequences to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .paste, + .emit = .vt, + } }, + .title = "Copy Selection as ANSI Sequences to Temporary File and Paste Path", + .description = "Copy the selection contents as ANSI escape sequences to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .open, + .emit = .vt, + } }, + .title = "Copy Selection as ANSI Sequences to Temporary File and Open", + .description = "Copy the selection contents as ANSI escape sequences to a temporary file and open it.", + }, }, .new_window => comptime &.{.{ @@ -280,10 +413,16 @@ fn actionCommands(action: Action.Key) []const Command { .prompt_surface_title => comptime &.{.{ .action = .prompt_surface_title, - .title = "Change Title...", + .title = "Change Terminal Title...", .description = "Prompt for a new title for the current terminal.", }}, + .prompt_tab_title => comptime &.{.{ + .action = .prompt_tab_title, + .title = "Change Tab Title...", + .description = "Prompt for a new title for the current tab.", + }}, + .new_split => comptime &.{ .{ .action = .{ .new_split = .left }, @@ -340,12 +479,31 @@ fn actionCommands(action: Action.Key) []const Command { }, }, + .goto_window => comptime &.{ + .{ + .action = .{ .goto_window = .previous }, + .title = "Focus Window: Previous", + .description = "Focus the previous window, if any.", + }, + .{ + .action = .{ .goto_window = .next }, + .title = "Focus Window: Next", + .description = "Focus the next window, if any.", + }, + }, + .toggle_split_zoom => comptime &.{.{ .action = .toggle_split_zoom, .title = "Toggle Split Zoom", .description = "Toggle the zoom state of the current split.", }}, + .toggle_readonly => comptime &.{.{ + .action = .toggle_readonly, + .title = "Toggle Read-Only Mode", + .description = "Toggle read-only mode for the current surface.", + }}, + .equalize_splits => comptime &.{.{ .action = .equalize_splits, .title = "Equalize Splits", @@ -405,6 +563,11 @@ fn actionCommands(action: Action.Key) []const Command { .title = "Close Other Tabs", .description = "Close all tabs in this window except the current one.", }, + .{ + .action = .{ .close_tab = .right }, + .title = "Close Tabs to the Right", + .description = "Close all tabs to the right of the current one.", + }, }, .close_window => comptime &.{.{ @@ -449,6 +612,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle secure input mode.", }}, + .toggle_mouse_reporting => comptime &.{.{ + .action = .toggle_mouse_reporting, + .title = "Toggle Mouse Reporting", + .description = "Toggle whether mouse events are reported to terminal applications.", + }}, + .check_for_updates => comptime &.{.{ .action = .check_for_updates, .title = "Check for Updates", @@ -487,6 +656,8 @@ fn actionCommands(action: Action.Key) []const Command { .esc, .cursor_key, .set_font_size, + .search, + .scroll_to_row, .scroll_page_fractional, .scroll_page_lines, .adjust_selection, diff --git a/src/input/config.zig b/src/input/config.zig new file mode 100644 index 000000000..fd839a20e --- /dev/null +++ b/src/input/config.zig @@ -0,0 +1,8 @@ +/// Determines the macOS option key behavior. See the config +/// `macos-option-as-alt` for a lot more details. +pub const OptionAsAlt = enum(c_int) { + false, + true, + left, + right, +}; diff --git a/src/input/function_keys.zig b/src/input/function_keys.zig index 8c89b39bd..efe86d9e3 100644 --- a/src/input/function_keys.zig +++ b/src/input/function_keys.zig @@ -293,6 +293,11 @@ fn pcStyle(comptime fmt: []const u8) []Entry { test "keys" { const testing = std.testing; + switch (@import("terminal_options").artifact) { + .ghostty => {}, + // Don't want to bring in termio into libghostty-vt + .lib => return error.SkipZigTest, + } // Force resolution for comptime evaluation. _ = keys; diff --git a/src/input/key.zig b/src/input/key.zig index a3814fb55..54c7491ae 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -2,7 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const cimgui = @import("cimgui"); -const config = @import("../config.zig"); +const OptionAsAlt = @import("config.zig").OptionAsAlt; /// A generic key input event. This is the information that is necessary /// regardless of apprt in order to generate the proper terminal @@ -146,7 +146,7 @@ pub const Mods = packed struct(Mods.Backing) { /// Return the mods to use for key translation. This handles settings /// like macos-option-as-alt. The translation mods should be used for /// translation but never sent back in for the key callback. - pub fn translation(self: Mods, option_as_alt: config.OptionAsAlt) Mods { + pub fn translation(self: Mods, option_as_alt: OptionAsAlt) Mods { var result = self; // macos-option-as-alt for darwin diff --git a/src/input/KeyEncoder.zig b/src/input/key_encode.zig similarity index 59% rename from src/input/KeyEncoder.zig rename to src/input/key_encode.zig index b5f18b5a2..736df58a0 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/key_encode.zig @@ -1,86 +1,138 @@ -/// KeyEncoder is responsible for processing keyboard input and generating -/// the proper VT sequence for any events. -/// -/// A new KeyEncoder should be created for each individual key press. -/// These encoders are not meant to be reused. -const KeyEncoder = @This(); - const std = @import("std"); const builtin = @import("builtin"); const testing = std.testing; - -const key = @import("key.zig"); -const config = @import("../config.zig"); +const KittyFlags = @import("../terminal/kitty/key.zig").Flags; +const OptionAsAlt = @import("config.zig").OptionAsAlt; +const Terminal = @import("../terminal/Terminal.zig"); const function_keys = @import("function_keys.zig"); -const terminal = @import("../terminal/main.zig"); +const key = @import("key.zig"); const KittyEntry = @import("kitty.zig").Entry; const kitty_entries = @import("kitty.zig").entries; -const KittyFlags = terminal.kitty.KeyFlags; -const log = std.log.scoped(.key_encoder); +/// Options that affect key encoding behavior. This is a mix of behavior +/// from terminal state as well as application configuration. +pub const Options = struct { + /// Terminal DEC mode 1 + cursor_key_application: bool = false, -event: key.KeyEvent, + /// Terminal DEC mode 66 + keypad_key_application: bool = false, -/// The state of various modes of a terminal that impact encoding. -macos_option_as_alt: config.OptionAsAlt = .false, -alt_esc_prefix: bool = false, -cursor_key_application: bool = false, -keypad_key_application: bool = false, -ignore_keypad_with_numlock: bool = false, -modify_other_keys_state_2: bool = false, -kitty_flags: KittyFlags = .{}, + /// Terminal DEC mode 1035 + ignore_keypad_with_numlock: bool = false, -/// Perform the proper encoding depending on the terminal state. + /// Terminal DEC mode 1036 + alt_esc_prefix: bool = false, + + /// xterm "modifyOtherKeys mode 2". Details here: + /// https://invisible-island.net/xterm/modified-keys.html + modify_other_keys_state_2: bool = false, + + /// Kitty keyboard protocol flags. + kitty_flags: KittyFlags = .disabled, + + /// Determines whether the "option" key on macOS is treated + /// as "alt" or not. See the Ghostty `macos_option-as-alt` config + /// docs for a more detailed description of why this is needed. + macos_option_as_alt: OptionAsAlt = .false, + + pub const default: Options = .{ + .cursor_key_application = false, + .keypad_key_application = false, + .ignore_keypad_with_numlock = false, + .alt_esc_prefix = false, + .modify_other_keys_state_2 = false, + .kitty_flags = .disabled, + .macos_option_as_alt = .false, + }; + + /// Initialize our options from the terminal state. + /// + /// Note that `macos_option_as_alt` cannot be determined from + /// terminal state so it must be set manually after this call. + pub fn fromTerminal(t: *const Terminal) Options { + return .{ + .alt_esc_prefix = t.modes.get(.alt_esc_prefix), + .cursor_key_application = t.modes.get(.cursor_keys), + .keypad_key_application = t.modes.get(.keypad_keys), + .ignore_keypad_with_numlock = t.modes.get(.ignore_keypad_with_numlock), + .modify_other_keys_state_2 = t.flags.modify_other_keys_2, + .kitty_flags = t.screens.active.kitty_keyboard.current(), + + // These can't be known from the terminal state. + .macos_option_as_alt = .false, + }; + } +}; + +/// Encode the key event to the writer in the proper format given +/// the options. For example, this will properly encode a key press +/// such as "ctrl+A" to Kitty format if Kitty encoding is enabled. +/// +/// Not all key events will result in output. It is up to the caller +/// to use a writer that can track whether any output was written if +/// they care about that. pub fn encode( - self: *const KeyEncoder, - buf: []u8, -) ![]const u8 { - // log.warn("KEYENCODER self={}", .{self.*}); - if (self.kitty_flags.int() != 0) return try self.kitty(buf); - return try self.legacy(buf); + writer: *std.Io.Writer, + event: key.KeyEvent, + opts: Options, +) std.Io.Writer.Error!void { + //std.log.warn("KEYENCODER event={} opts={}", .{ event, opts }); + return if (opts.kitty_flags.int() != 0) try kitty( + writer, + event, + opts, + ) else try legacy( + writer, + event, + opts, + ); } /// Perform Kitty keyboard protocol encoding of the key event. fn kitty( - self: *const KeyEncoder, - buf: []u8, -) ![]const u8 { + writer: *std.Io.Writer, + event: key.KeyEvent, + opts: Options, +) std.Io.Writer.Error!void { // This should never happen but we'll check anyway. - if (self.kitty_flags.int() == 0) return try self.legacy(buf); + if (opts.kitty_flags.int() == 0) return try legacy( + writer, + event, + opts, + ); // We only processed "press" events unless report events is active - if (self.event.action == .release) { - if (!self.kitty_flags.report_events) { - return ""; - } + if (event.action == .release) { + if (!opts.kitty_flags.report_events) return; // Enter, backspace, and tab do not report release events unless "report // all" is set - if (!self.kitty_flags.report_all) { - switch (self.event.key) { - .enter, .backspace, .tab => return "", + if (!opts.kitty_flags.report_all) { + switch (event.key) { + .enter, .backspace, .tab => return, else => {}, } } } - const all_mods = self.event.mods; - const effective_mods = self.event.effectiveMods(); + const all_mods = event.mods; + const effective_mods = event.effectiveMods(); const binding_mods = effective_mods.binding(); // Find the entry for this key in the kitty table. const entry_: ?KittyEntry = entry: { // Functional or predefined keys for (kitty_entries) |entry| { - if (entry.key == self.event.key) break :entry entry; + if (entry.key == event.key) break :entry entry; } // Otherwise, we use our unicode codepoint from UTF8. We // always use the unshifted value. - if (self.event.unshifted_codepoint > 0) { + if (event.unshifted_codepoint > 0) { break :entry .{ - .key = self.event.key, - .code = self.event.unshifted_codepoint, + .key = event.key, + .code = event.unshifted_codepoint, .final = 'u', .modifier = false, }; @@ -91,32 +143,32 @@ fn kitty( preprocessing: { // When composing, the only keys sent are plain modifiers. - if (self.event.composing) { + if (event.composing) { if (entry_) |entry| { if (entry.modifier) break :preprocessing; } - return ""; + return; } // IME confirmation still sends an enter key so if we have enter // and UTF8 text we just send it directly since we assume that is // whats happening. See legacy()'s similar logic for more details // on how to verify this. - if (self.event.utf8.len > 0) utf8: { - switch (self.event.key) { + if (event.utf8.len > 0) utf8: { + switch (event.key) { else => {}, inline .enter, .backspace => |tag| { // See legacy for why we handle this this way. - if (isControlUtf8(self.event.utf8)) break :utf8; - if (comptime tag == .backspace) return ""; - return try copyToBuf(buf, self.event.utf8); + if (isControlUtf8(event.utf8)) break :utf8; + if (comptime tag == .backspace) return; + return try writer.writeAll(event.utf8); }, } } // If we're reporting all then we always send CSI sequences. - if (!self.kitty_flags.report_all) { + if (!opts.kitty_flags.report_all) { // Quote: // The only exceptions are the Enter, Tab and Backspace keys which // still generate the same bytes as in legacy mode this is to allow the @@ -126,64 +178,74 @@ fn kitty( // Quote ("report all" mode): // Note that all keys are reported as escape codes, including Enter, // Tab, Backspace etc. - if (effective_mods.empty()) { - switch (self.event.key) { - .enter => return try copyToBuf(buf, "\r"), - .tab => return try copyToBuf(buf, "\t"), - .backspace => return try copyToBuf(buf, "\x7F"), + if (binding_mods.empty()) { + switch (event.key) { + .enter => return try writer.writeByte('\r'), + .tab => return try writer.writeByte('\t'), + .backspace => return try writer.writeByte(0x7F), else => {}, } } // Send plain-text non-modified text directly to the terminal. // We don't send release events because those are specially encoded. - if (self.event.utf8.len > 0 and + if (event.utf8.len > 0 and binding_mods.empty() and - self.event.action != .release) + event.action != .release) plain_text: { // We only do this for printable characters. We should // inspect the real unicode codepoint properties here but // the real world issue is usually control characters. - const view = try std.unicode.Utf8View.init(self.event.utf8); + const view = std.unicode.Utf8View.init(event.utf8) catch { + // Invalid UTF-8 so let's fallback to encoding the + // key press as if it didn't produce UTF-8 text. I'm + // not sure what should happen here according to the spec, + // since it doesn't specify this behavior. Presumably + // this is a caller bug. + break :plain_text; + }; var it = view.iterator(); while (it.nextCodepoint()) |cp| { if (isControl(cp)) break :plain_text; } - return try copyToBuf(buf, self.event.utf8); + return try writer.writeAll(event.utf8); } } } - const entry = entry_ orelse return ""; + const entry = entry_ orelse return; // If this is just a modifier we require "report all" to send the sequence. - if (entry.modifier and !self.kitty_flags.report_all) return ""; + if (entry.modifier and !opts.kitty_flags.report_all) return; const seq: KittySequence = seq: { var seq: KittySequence = .{ .key = entry.code, .final = entry.final, .mods = .fromInput( - self.event.action, - self.event.key, + event.action, + event.key, all_mods, ), }; - if (self.kitty_flags.report_events) { - seq.event = switch (self.event.action) { + if (opts.kitty_flags.report_events) { + seq.event = switch (event.action) { .press => .press, .release => .release, .repeat => .repeat, }; } - if (self.kitty_flags.report_alternates) alternates: { + if (opts.kitty_flags.report_alternates) alternates: { // Break early if this is a control key if (isControl(seq.key)) break :alternates; - const view = try std.unicode.Utf8View.init(self.event.utf8); + const view = std.unicode.Utf8View.init(event.utf8) catch { + // Assume invalid UTF-8 means no UTF-8. + break :alternates; + }; var it = view.iterator(); // If we have a codepoint in our UTF-8 sequence, then we can @@ -198,7 +260,7 @@ fn kitty( // Set the base layout key. We only report this if this codepoint // differs from our pressed key. - if (self.event.key.codepoint()) |base| { + if (event.key.codepoint()) |base| { if (base != seq.key and (cp1 != base and !has_cp2)) { @@ -208,20 +270,20 @@ fn kitty( } else { // No UTF-8 so we can't report a shifted key but we can still // report a base layout key. - if (self.event.key.codepoint()) |base| { + if (event.key.codepoint()) |base| { if (base != seq.key) seq.alternates[1] = base; } } } - if (self.kitty_flags.report_associated and + if (opts.kitty_flags.report_associated and seq.event != .release) associated: { // Determine if the Alt modifier should be treated as an actual // modifier (in which case it prevents associated text) or as // the macOS Option key, which does not prevent associated text. const alt_prevents_text = if (comptime builtin.os.tag == .macos) - switch (self.macos_option_as_alt) { + switch (opts.macos_option_as_alt) { .left => all_mods.sides.alt == .left, .right => all_mods.sides.alt == .right, .true => true, @@ -232,13 +294,13 @@ fn kitty( if (seq.mods.preventsText(alt_prevents_text)) break :associated; - seq.text = self.event.utf8; + seq.text = event.utf8; } break :seq seq; }; - return try seq.encode(buf); + return try seq.encode(writer); } /// Perform legacy encoding of the key event. "Legacy" in this case @@ -248,28 +310,28 @@ fn kitty( /// meant to be extensions that do not change any existing behavior /// and therefore safe to combine. fn legacy( - self: *const KeyEncoder, - buf: []u8, -) ![]const u8 { - const all_mods = self.event.mods; - const effective_mods = self.event.effectiveMods(); + writer: *std.Io.Writer, + event: key.KeyEvent, + opts: Options, +) std.Io.Writer.Error!void { + const all_mods = event.mods; + const effective_mods = event.effectiveMods(); const binding_mods = effective_mods.binding(); // Legacy encoding only does press/repeat - if (self.event.action != .press and - self.event.action != .repeat) return ""; + if (event.action != .press and event.action != .repeat) return; // If we're in a dead key state then we never emit a sequence. - if (self.event.composing) return ""; + if (event.composing) return; // If we match a PC style function key then that is our result. if (pcStyleFunctionKey( - self.event.key, + event.key, all_mods, - self.cursor_key_application, - self.keypad_key_application, - self.ignore_keypad_with_numlock, - self.modify_other_keys_state_2, + opts.cursor_key_application, + opts.keypad_key_application, + opts.ignore_keypad_with_numlock, + opts.modify_other_keys_state_2, )) |sequence| pc_style: { // If we have UTF-8 text, then we never emit PC style function // keys. Many function keys (escape, enter, backspace) have @@ -280,65 +342,68 @@ fn legacy( // - Korean: escape commits the dead key state // - Korean: backspace should delete a single preedit char // - if (self.event.utf8.len > 0) utf8: { - switch (self.event.key) { + if (event.utf8.len > 0) utf8: { + switch (event.key) { else => {}, inline .backspace, .enter, .escape => |tag| { // We want to ignore control characters. This is because // some apprts (macOS) will send control characters as // UTF-8 encodings and we handle that manually. - if (isControlUtf8(self.event.utf8)) break :utf8; + if (isControlUtf8(event.utf8)) break :utf8; // Backspace encodes nothing because we modified IME. // Enter/escape don't encode the PC-style encoding // because we want to encode committed text. - if (comptime tag == .backspace) return ""; + if (comptime tag == .backspace) return; break :pc_style; }, } } - return copyToBuf(buf, sequence); + return try writer.writeAll(sequence); } // If we match a control sequence, we output that directly. For // ctrlSeq we have to use all mods because we want it to only // match ctrl+. if (ctrlSeq( - self.event.key, - self.event.utf8, - self.event.unshifted_codepoint, + event.key, + event.utf8, + event.unshifted_codepoint, all_mods, )) |char| { // C0 sequences support alt-as-esc prefixing. if (binding_mods.alt) { - if (buf.len < 2) return error.OutOfMemory; - buf[0] = 0x1B; - buf[1] = char; - return buf[0..2]; + try writer.writeByte(0x1B); + try writer.writeByte(char); + return; } - if (buf.len < 1) return error.OutOfMemory; - buf[0] = char; - return buf[0..1]; + try writer.writeByte(char); + return; } // If we have no UTF8 text then the only possibility is the // alt-prefix handling of unshifted codepoints... so we process that. - const utf8 = self.event.utf8; + const utf8 = event.utf8; if (utf8.len == 0) { - if (try self.legacyAltPrefix(binding_mods, all_mods)) |byte| { - return try std.fmt.bufPrint(buf, "\x1B{c}", .{byte}); - } - - return ""; + if (try legacyAltPrefix( + event, + binding_mods, + all_mods, + opts, + )) |byte| try writer.print("\x1B{c}", .{byte}); + return; } // In modify other keys state 2, we send the CSI 27 sequence // for any char with a modifier. Ctrl sequences like Ctrl+a // are already handled above. - if (self.modify_other_keys_state_2) modify_other: { - const view = try std.unicode.Utf8View.init(utf8); + if (opts.modify_other_keys_state_2) modify_other: { + const view = std.unicode.Utf8View.init(utf8) catch { + // Assume invalid UTF-8 means we no UTF-8. + break :modify_other; + }; var it = view.iterator(); const codepoint = it.nextCodepoint() orelse break :modify_other; @@ -346,6 +411,22 @@ fn legacy( // ever be a multi-codepoint sequence that triggers this. if (it.nextCodepoint() != null) break :modify_other; + // The mods we encode for this are just the binding mods (shift, ctrl, + // super, alt unless it is actually option). + const mods = mods: { + var mods_binding = event.mods.binding(); + if (comptime builtin.target.os.tag.isDarwin()) alt: { + switch (opts.macos_option_as_alt) { + .false => {}, + .true => break :alt, + .left => if (event.mods.sides.alt == .left) break :alt, + .right => if (event.mods.sides.alt == .right) break :alt, + } + mods_binding.alt = false; + } + break :mods mods_binding; + }; + // This copies xterm's `ModifyOtherKeys` function that returns // whether modify other keys should be encoded for the given // input. @@ -355,7 +436,7 @@ fn legacy( break :should_modify true; // If we have anything other than shift pressed, encode. - var mods_no_shift = binding_mods; + var mods_no_shift = mods; mods_no_shift.shift = false; if (!mods_no_shift.empty()) break :should_modify true; @@ -370,9 +451,8 @@ fn legacy( if (should_modify) { for (function_keys.modifiers, 2..) |modset, code| { - if (!binding_mods.equal(modset)) continue; - return try std.fmt.bufPrint( - buf, + if (!mods.equal(modset)) continue; + return try writer.print( "\x1B[27;{};{}~", .{ code, codepoint }, ); @@ -383,17 +463,17 @@ fn legacy( // Let's see if we should apply fixterms to this codepoint. // At this stage of key processing, we only need to apply fixterms // to unicode codepoints if we have ctrl set. - if (self.event.mods.ctrl) csiu: { + if (event.mods.ctrl) csiu: { // Important: we want to use the original mods here, not the // effective mods. The fixterms spec states the shifted chars // should be sent uppercase but Kitty changes that behavior // so we'll send all the mods. const csi_u_mods, const char = mods: { - var mods = CsiUMods.fromInput(self.event.mods); + var mods = CsiUMods.fromInput(event.mods); // Get our codepoint. If we have more than one codepoint this // can't be valid CSIu. - const view = std.unicode.Utf8View.init(self.event.utf8) catch break :csiu; + const view = std.unicode.Utf8View.init(event.utf8) catch break :csiu; var it = view.iterator(); var char = it.nextCodepoint() orelse break :csiu; if (it.nextCodepoint() != null) break :csiu; @@ -414,25 +494,27 @@ fn legacy( // then we consider shift. Otherwise, we do not because the // shift key was used to obtain the character. This is specified // by fixterms. - if (self.event.unshifted_codepoint != char) { + if (event.unshifted_codepoint != char) { mods.shift = false; } break :mods .{ mods, char }; }; - const result = try std.fmt.bufPrint( - buf, + return try writer.print( "\x1B[{};{}u", .{ char, csi_u_mods.seqInt() }, ); - // std.log.warn("CSI_U: {s}", .{result}); - return result; } // If we have alt-pressed and alt-esc-prefix is enabled, then // we need to prefix the utf8 sequence with an esc. - if (try self.legacyAltPrefix(binding_mods, all_mods)) |byte| { - return try std.fmt.bufPrint(buf, "\x1B{c}", .{byte}); + if (try legacyAltPrefix( + event, + binding_mods, + all_mods, + opts, + )) |byte| { + return try writer.print("\x1B{c}", .{byte}); } // If we are on macOS, command+keys do not encode text. It isn't @@ -445,25 +527,26 @@ fn legacy( // For example on Gnome Console Super+b will encode a "b" character // with legacy encoding. if ((comptime builtin.os.tag == .macos) and all_mods.super) { - return ""; + return; } - return try copyToBuf(buf, utf8); + return try writer.writeAll(utf8); } fn legacyAltPrefix( - self: *const KeyEncoder, + event: key.KeyEvent, binding_mods: key.Mods, mods: key.Mods, + opts: Options, ) !?u8 { // This only takes effect with alt pressed - if (!binding_mods.alt or !self.alt_esc_prefix) return null; + if (!binding_mods.alt or !opts.alt_esc_prefix) return null; // On macOS, we only handle option like alt in certain // circumstances. Otherwise, macOS does a unicode translation // and we allow that to happen. if (comptime builtin.os.tag == .macos) { - switch (self.macos_option_as_alt) { + switch (opts.macos_option_as_alt) { .false => return null, .left => if (mods.sides.alt == .right) return null, .right => if (mods.sides.alt == .left) return null, @@ -472,7 +555,7 @@ fn legacyAltPrefix( } // Otherwise, we require utf8 to already have the byte represented. - const utf8 = self.event.utf8; + const utf8 = event.utf8; if (utf8.len == 1) { if (std.math.cast(u8, utf8[0])) |byte| { return byte; @@ -480,10 +563,10 @@ fn legacyAltPrefix( } // If UTF8 isn't set, we will allow unshifted codepoints through. - if (self.event.unshifted_codepoint > 0) { + if (event.unshifted_codepoint > 0) { if (std.math.cast( u8, - self.event.unshifted_codepoint, + event.unshifted_codepoint, )) |byte| { return byte; } @@ -897,19 +980,18 @@ const KittySequence = struct { release = 3, }; - pub fn encode(self: KittySequence, buf: []u8) ![]const u8 { - if (self.final == 'u' or self.final == '~') return try self.encodeFull(buf); - return try self.encodeSpecial(buf); + pub fn encode( + self: KittySequence, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + if (self.final == 'u' or self.final == '~') return try self.encodeFull(writer); + return try self.encodeSpecial(writer); } - fn encodeFull(self: KittySequence, buf: []u8) ![]const u8 { - // Boilerplate to basically create a string builder that writes - // over our buffer (but no more). - var fba = std.heap.FixedBufferAllocator.init(buf); - const alloc = fba.allocator(); - var builder = try std.ArrayListUnmanaged(u8).initCapacity(alloc, buf.len); - const writer = builder.writer(alloc); - + fn encodeFull( + self: KittySequence, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { // Key section try writer.print("\x1B[{d}", .{self.key}); // Write our alternates @@ -937,8 +1019,11 @@ const KittySequence = struct { } // Text section - if (self.text.len > 0) { - const view = try std.unicode.Utf8View.init(self.text); + if (self.text.len > 0) text: { + const view = std.unicode.Utf8View.init(self.text) catch { + // Assume invalid UTF-8 means we have no text. + break :text; + }; var it = view.iterator(); var count: usize = 0; while (it.nextCodepoint()) |cp| { @@ -960,13 +1045,15 @@ const KittySequence = struct { } try writer.print("{c}", .{self.final}); - return builder.items; } - fn encodeSpecial(self: KittySequence, buf: []u8) ![]const u8 { + fn encodeSpecial( + self: KittySequence, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { const mods = self.mods.seqInt(); if (self.event != .none) { - return try std.fmt.bufPrint(buf, "\x1B[1;{d}:{d}{c}", .{ + return try writer.print("\x1B[1;{d}:{d}{c}", .{ mods, @intFromEnum(self.event), self.final, @@ -974,13 +1061,13 @@ const KittySequence = struct { } if (mods > 1) { - return try std.fmt.bufPrint(buf, "\x1B[1;{d}{c}", .{ + return try writer.print("\x1B[1;{d}{c}", .{ mods, self.final, }); } - return try std.fmt.bufPrint(buf, "\x1B[{c}", .{self.final}); + return try writer.print("\x1B[{c}", .{self.final}); } }; @@ -989,27 +1076,30 @@ test "KittySequence: backspace" { // Plain { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u' }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[127u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127u", writer.buffered()); } // Release event { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .event = .release }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[127;1:3u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;1:3u", writer.buffered()); } // Shift { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .mods = .{ .shift = true }, }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[127;2u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;2u", writer.buffered()); } } @@ -1018,221 +1108,255 @@ test "KittySequence: text" { // Plain { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .text = "A", }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[127;;65u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;;65u", writer.buffered()); } // Release { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .event = .release, .text = "A", }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[127;1:3;65u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;1:3;65u", writer.buffered()); } // Shift { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .mods = .{ .shift = true }, .text = "A", }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[127;2;65u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;2;65u", writer.buffered()); } } - +// test "KittySequence: text with control characters" { var buf: [128]u8 = undefined; // By itself { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .text = "\n", }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1b[127u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1b[127u", writer.buffered()); } // With other printables { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .text = "A\n", }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1b[127;;65u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1b[127;;65u", writer.buffered()); } } - +// test "KittySequence: special no mods" { var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 1, .final = 'A' }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[A", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[A", writer.buffered()); } test "KittySequence: special mods only" { var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 1, .final = 'A', .mods = .{ .shift = true } }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[1;2A", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[1;2A", writer.buffered()); } test "KittySequence: special mods and event" { var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 1, .final = 'A', .event = .release, .mods = .{ .shift = true }, }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[1;2:3A", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[1;2:3A", writer.buffered()); } test "kitty: plain text" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_a, - .mods = .{}, - .utf8 = "abcd", - }, - + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .mods = .{}, + .utf8 = "abcd", + }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("abcd", actual); + }); + try testing.expectEqualStrings("abcd", writer.buffered()); } test "kitty: repeat with just disambiguate" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_a, - .action = .repeat, - .mods = .{}, - .utf8 = "a", - }, - + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .action = .repeat, + .mods = .{}, + .utf8 = "a", + }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("a", actual); + }); + try testing.expectEqualStrings("a", writer.buffered()); } - +// test "kitty: enter, backspace, tab" { var buf: [128]u8 = undefined; { - var enc: KeyEncoder = .{ - .event = .{ .key = .enter, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .enter, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\r", actual); + }); + try testing.expectEqualStrings("\r", writer.buffered()); } { - var enc: KeyEncoder = .{ - .event = .{ .key = .backspace, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .backspace, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x7f", actual); + }); + try testing.expectEqualStrings("\x7f", writer.buffered()); } { - var enc: KeyEncoder = .{ - .event = .{ .key = .tab, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .tab, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\t", actual); + }); + try testing.expectEqualStrings("\t", writer.buffered()); } // No release events if "report_all" is not set { - var enc: KeyEncoder = .{ - .event = .{ .action = .release, .key = .enter, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .enter, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("", actual); + }); + try testing.expectEqualStrings("", writer.buffered()); } { - var enc: KeyEncoder = .{ - .event = .{ .action = .release, .key = .backspace, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .backspace, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("", actual); + }); + try testing.expectEqualStrings("", writer.buffered()); } { - var enc: KeyEncoder = .{ - .event = .{ .action = .release, .key = .tab, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .tab, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("", actual); + }); + try testing.expectEqualStrings("", writer.buffered()); } // Release events if "report_all" is set { - var enc: KeyEncoder = .{ - .event = .{ .action = .release, .key = .enter, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .enter, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, .report_all = true, }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[13;1:3u", actual); + }); + try testing.expectEqualStrings("\x1b[13;1:3u", writer.buffered()); } { - var enc: KeyEncoder = .{ - .event = .{ .action = .release, .key = .backspace, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .backspace, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, .report_all = true, }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[127;1:3u", actual); + }); + try testing.expectEqualStrings("\x1b[127;1:3u", writer.buffered()); } { - var enc: KeyEncoder = .{ - .event = .{ .action = .release, .key = .tab, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .tab, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, .report_all = true, }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[9;1:3u", actual); + }); + try testing.expectEqualStrings("\x1b[9;1:3u", writer.buffered()); } } +test "kitty: shift+backspace emits CSI u" { + // Backspace with shift modifier should emit CSI u sequence, not raw 0x7F. + // This is important for programs that want to distinguish shift+backspace. + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .backspace, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[127;2u", writer.buffered()); +} + +test "kitty: shift+enter emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .enter, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[13;2u", writer.buffered()); +} + +test "kitty: shift+tab emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .tab, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[9;2u", writer.buffered()); +} + test "kitty: enter with all flags" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ .key = .enter, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .enter, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1240,15 +1364,15 @@ test "kitty: enter with all flags" { .report_all = true, .report_associated = true, }, - }; - const actual = try enc.kitty(&buf); + }); + const actual = writer.buffered(); try testing.expectEqualStrings("[13u", actual[1..]); } - +// test "kitty: ctrl with all flags" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ .key = .control_left, .mods = .{ .ctrl = true }, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .control_left, .mods = .{ .ctrl = true }, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1256,20 +1380,20 @@ test "kitty: ctrl with all flags" { .report_all = true, .report_associated = true, }, - }; - const actual = try enc.kitty(&buf); + }); + const actual = writer.buffered(); try testing.expectEqualStrings("[57442;5u", actual[1..]); } test "kitty: ctrl release with ctrl mod set" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .action = .release, - .key = .control_left, - .mods = .{ .ctrl = true }, - .utf8 = "", - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .release, + .key = .control_left, + .mods = .{ .ctrl = true }, + .utf8 = "", + }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1277,210 +1401,191 @@ test "kitty: ctrl release with ctrl mod set" { .report_all = true, .report_associated = true, }, - }; - const actual = try enc.kitty(&buf); + }); + const actual = writer.buffered(); try testing.expectEqualStrings("[57442;5:3u", actual[1..]); } test "kitty: delete" { var buf: [128]u8 = undefined; { - var enc: KeyEncoder = .{ - .event = .{ .key = .delete, .mods = .{}, .utf8 = "\x7F" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .delete, .mods = .{}, .utf8 = "\x7F" }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[3~", actual); + }); + try testing.expectEqualStrings("\x1b[3~", writer.buffered()); } } test "kitty: composing with no modifier" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_a, - .mods = .{ .shift = true }, - .composing = true, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .mods = .{ .shift = true }, + .composing = true, + }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("", actual); + }); + try testing.expectEqualStrings("", writer.buffered()); } test "kitty: composing with modifier" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .shift_left, - .mods = .{ .shift = true }, - .composing = true, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .shift_left, + .mods = .{ .shift = true }, + .composing = true, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[57441;2u", actual); + }); + try testing.expectEqualStrings("\x1b[57441;2u", writer.buffered()); } test "kitty: shift+a on US keyboard" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_a, - .mods = .{ .shift = true }, - .utf8 = "A", - .unshifted_codepoint = 97, // lowercase A - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .mods = .{ .shift = true }, + .utf8 = "A", + .unshifted_codepoint = 97, // lowercase A + }, .{ .kitty_flags = .{ .disambiguate = true, .report_alternates = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[97:65;2u", actual); + }); + try testing.expectEqualStrings("\x1b[97:65;2u", writer.buffered()); } test "kitty: matching unshifted codepoint" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_a, - .mods = .{ .shift = true }, - .utf8 = "A", - .unshifted_codepoint = 65, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .mods = .{ .shift = true }, + .utf8 = "A", + .unshifted_codepoint = 65, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_alternates = true, }, - }; - + }); // WARNING: This is not a valid encoding. This is a hypothetical encoding // just to test that our logic is correct around matching unshifted // codepoints. We get an alternate here because the unshifted_codepoint does // not match the base key - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[65::97;2u", actual); + try testing.expectEqualStrings("\x1b[65::97;2u", writer.buffered()); } test "kitty: report alternates with caps" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_j, - .mods = .{ .caps_lock = true }, - .utf8 = "J", - .unshifted_codepoint = 106, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_j, + .mods = .{ .caps_lock = true }, + .utf8 = "J", + .unshifted_codepoint = 106, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[106;65;74u", actual); + }); + try testing.expectEqualStrings("\x1b[106;65;74u", writer.buffered()); } test "kitty: report alternates colon (shift+';')" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .semicolon, - .mods = .{ .shift = true }, - .utf8 = ":", - .unshifted_codepoint = ';', - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .semicolon, + .mods = .{ .shift = true }, + .utf8 = ":", + .unshifted_codepoint = ';', + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[59:58;2;58u", actual); + }); + try testing.expectEqualStrings("\x1b[59:58;2;58u", writer.buffered()); } test "kitty: report alternates with ru layout" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .semicolon, - .mods = .{}, - .utf8 = "ч", - .unshifted_codepoint = 1095, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .semicolon, + .mods = .{}, + .utf8 = "ч", + .unshifted_codepoint = 1095, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[1095::59;;1095u", actual); + }); + try testing.expectEqualStrings("\x1b[1095::59;;1095u", writer.buffered()); } test "kitty: report alternates with ru layout shifted" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .semicolon, - .mods = .{ .shift = true }, - .utf8 = "Ч", - .unshifted_codepoint = 1095, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .semicolon, + .mods = .{ .shift = true }, + .utf8 = "Ч", + .unshifted_codepoint = 1095, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[1095:1063:59;2;1063u", actual); + }); + try testing.expectEqualStrings("\x1b[1095:1063:59;2;1063u", writer.buffered()); } test "kitty: report alternates with ru layout caps lock" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .semicolon, - .mods = .{ .caps_lock = true }, - .utf8 = "Ч", - .unshifted_codepoint = 1095, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .semicolon, + .mods = .{ .caps_lock = true }, + .utf8 = "Ч", + .unshifted_codepoint = 1095, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[1095::59;65;1063u", actual); + }); + try testing.expectEqualStrings("\x1b[1095::59;65;1063u", writer.buffered()); } test "kitty: report alternates with hu layout release" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .action = .release, - .key = .bracket_left, - .mods = .{ .ctrl = true }, - .utf8 = "", - .unshifted_codepoint = 337, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .release, + .key = .bracket_left, + .mods = .{ .ctrl = true }, + .utf8 = "", + .unshifted_codepoint = 337, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, @@ -1488,88 +1593,75 @@ test "kitty: report alternates with hu layout release" { .report_associated = true, .report_events = true, }, - }; - - const actual = try enc.kitty(&buf); + }); + const actual = writer.buffered(); try testing.expectEqualStrings("[337::91;5:3u", actual[1..]); } // macOS generates utf8 text for arrow keys. test "kitty: up arrow with utf8" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .arrow_up, - .mods = .{}, - .utf8 = &.{30}, - }, - + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .arrow_up, + .mods = .{}, + .utf8 = &.{30}, + }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[A", actual); + }); + try testing.expectEqualStrings("\x1b[A", writer.buffered()); } test "kitty: shift+tab" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .tab, - .mods = .{ .shift = true }, - .utf8 = "", // tab - }, - + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .tab, + .mods = .{ .shift = true }, + .utf8 = "", // tab + }, .{ .kitty_flags = .{ .disambiguate = true, .report_alternates = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[9;2u", actual); + }); + try testing.expectEqualStrings("\x1b[9;2u", writer.buffered()); } test "kitty: left shift" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .shift_left, - .mods = .{}, - .utf8 = "", - }, - + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .shift_left, + .mods = .{}, + .utf8 = "", + }, .{ .kitty_flags = .{ .disambiguate = true, .report_alternates = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("", actual); + }); + try testing.expectEqualStrings("", writer.buffered()); } test "kitty: left shift with report all" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .shift_left, - .mods = .{}, - .utf8 = "", - }, - + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .shift_left, + .mods = .{}, + .utf8 = "", + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[57441u", actual); + }); + try testing.expectEqualStrings("\x1b[57441u", writer.buffered()); } test "kitty: report associated with alt text on macOS with option" { if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_w, - .mods = .{ .alt = true }, - .utf8 = "∑", - .unshifted_codepoint = 119, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_w, + .mods = .{ .alt = true }, + .utf8 = "∑", + .unshifted_codepoint = 119, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, @@ -1577,10 +1669,8 @@ test "kitty: report associated with alt text on macOS with option" { .report_associated = true, }, .macos_option_as_alt = .false, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[119;3;8721u", actual); + }); + try testing.expectEqualStrings("\x1b[119;3;8721u", writer.buffered()); } test "kitty: report associated with alt text on macOS with alt" { @@ -1589,13 +1679,13 @@ test "kitty: report associated with alt text on macOS with alt" { { // With Alt modifier var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_w, - .mods = .{ .alt = true }, - .utf8 = "∑", - .unshifted_codepoint = 119, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_w, + .mods = .{ .alt = true }, + .utf8 = "∑", + .unshifted_codepoint = 119, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, @@ -1603,22 +1693,20 @@ test "kitty: report associated with alt text on macOS with alt" { .report_associated = true, }, .macos_option_as_alt = .true, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[119;3u", actual); + }); + try testing.expectEqualStrings("\x1b[119;3u", writer.buffered()); } { // Without Alt modifier var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_w, - .mods = .{}, - .utf8 = "∑", - .unshifted_codepoint = 119, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_w, + .mods = .{}, + .utf8 = "∑", + .unshifted_codepoint = 119, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, @@ -1626,65 +1714,59 @@ test "kitty: report associated with alt text on macOS with alt" { .report_associated = true, }, .macos_option_as_alt = .true, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[119;;8721u", actual); + }); + try testing.expectEqualStrings("\x1b[119;;8721u", writer.buffered()); } } test "kitty: report associated with modifiers" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_j, - .mods = .{ .ctrl = true }, - .utf8 = "j", - .unshifted_codepoint = 106, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_j, + .mods = .{ .ctrl = true }, + .utf8 = "j", + .unshifted_codepoint = 106, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[106;5u", actual); + }); + try testing.expectEqualStrings("\x1b[106;5u", writer.buffered()); } test "kitty: report associated" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_j, - .mods = .{ .shift = true }, - .utf8 = "J", - .unshifted_codepoint = 106, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_j, + .mods = .{ .shift = true }, + .utf8 = "J", + .unshifted_codepoint = 106, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[106:74;2;74u", actual); + }); + try testing.expectEqualStrings("\x1b[106:74;2;74u", writer.buffered()); } test "kitty: report associated on release" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .action = .release, - .key = .key_j, - .mods = .{ .shift = true }, - .utf8 = "J", - .unshifted_codepoint = 106, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .release, + .key = .key_j, + .mods = .{ .shift = true }, + .utf8 = "J", + .unshifted_codepoint = 106, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, @@ -1692,54 +1774,53 @@ test "kitty: report associated on release" { .report_associated = true, .report_events = true, }, - }; - - const actual = try enc.kitty(&buf); + }); + const actual = writer.buffered(); try testing.expectEqualStrings("[106:74;2:3u", actual[1..]); } test "kitty: alternates omit control characters" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .delete, - .mods = .{}, - .utf8 = &.{0x7F}, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .delete, + .mods = .{}, + .utf8 = &.{0x7F}, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_alternates = true, .report_all = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[3~", actual); + }); + try testing.expectEqualStrings("\x1b[3~", writer.buffered()); } test "kitty: enter with utf8 (dead key state)" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .enter, - .utf8 = "A", - .unshifted_codepoint = 0x0D, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .enter, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_alternates = true, .report_all = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("A", actual); + }); + try testing.expectEqualStrings("A", writer.buffered()); } test "kitty: keypad number" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ .key = .numpad_1, .mods = .{}, .utf8 = "1" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .numpad_1, + .mods = .{}, + .utf8 = "1", + }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1747,19 +1828,19 @@ test "kitty: keypad number" { .report_all = true, .report_associated = true, }, - }; - const actual = try enc.kitty(&buf); + }); + const actual = writer.buffered(); try testing.expectEqualStrings("[57400;;49u", actual[1..]); } test "kitty: backspace with utf8 (dead key state)" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .backspace, - .utf8 = "A", - .unshifted_codepoint = 0x0D, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .backspace, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1767,261 +1848,268 @@ test "kitty: backspace with utf8 (dead key state)" { .report_all = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("", actual); + }); + try testing.expectEqualStrings("", writer.buffered()); } test "legacy: backspace with utf8 (dead key state)" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .backspace, - .utf8 = "A", - .unshifted_codepoint = 0x0D, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .backspace, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{}); + try testing.expectEqualStrings("", writer.buffered()); } test "legacy: enter with utf8 (dead key state)" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .enter, - .utf8 = "A", - .unshifted_codepoint = 0x0D, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("A", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .enter, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{}); + try testing.expectEqualStrings("A", writer.buffered()); } test "legacy: esc with utf8 (dead key state)" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .escape, - .utf8 = "A", - .unshifted_codepoint = 0x0D, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("A", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .escape, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{}); + try testing.expectEqualStrings("A", writer.buffered()); } test "legacy: ctrl+shift+minus (underscore on US)" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .minus, - .mods = .{ .ctrl = true, .shift = true }, - .utf8 = "_", - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1F", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .minus, + .mods = .{ .ctrl = true, .shift = true }, + .utf8 = "_", + }, .{}); + try testing.expectEqualStrings("\x1F", writer.buffered()); } test "legacy: ctrl+alt+c" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_c, - .mods = .{ .ctrl = true, .alt = true }, - .utf8 = "c", - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b\x03", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_c, + .mods = .{ .ctrl = true, .alt = true }, + .utf8 = "c", + }, .{}); + try testing.expectEqualStrings("\x1b\x03", writer.buffered()); } test "legacy: alt+c" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_c, - .utf8 = "c", - .mods = .{ .alt = true }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_c, + .utf8 = "c", + .mods = .{ .alt = true }, + }, .{ .alt_esc_prefix = true, .macos_option_as_alt = .true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1Bc", actual); + }); + try testing.expectEqualStrings("\x1Bc", writer.buffered()); } test "legacy: alt+e only unshifted" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_e, - .unshifted_codepoint = 'e', - .mods = .{ .alt = true }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_e, + .unshifted_codepoint = 'e', + .mods = .{ .alt = true }, + }, .{ .alt_esc_prefix = true, .macos_option_as_alt = .true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1Be", actual); + }); + try testing.expectEqualStrings("\x1Be", writer.buffered()); } test "legacy: alt+x macos" { if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_c, - .utf8 = "≈", - .unshifted_codepoint = 'c', - .mods = .{ .alt = true }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_c, + .utf8 = "≈", + .unshifted_codepoint = 'c', + .mods = .{ .alt = true }, + }, .{ .alt_esc_prefix = true, .macos_option_as_alt = .true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1Bc", actual); + }); + try testing.expectEqualStrings("\x1Bc", writer.buffered()); } test "legacy: shift+alt+. macos" { if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .period, - .utf8 = ">", - .unshifted_codepoint = '.', - .mods = .{ .alt = true, .shift = true }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .period, + .utf8 = ">", + .unshifted_codepoint = '.', + .mods = .{ .alt = true, .shift = true }, + }, .{ .alt_esc_prefix = true, .macos_option_as_alt = .true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1B>", actual); + }); + try testing.expectEqualStrings("\x1B>", writer.buffered()); } test "legacy: alt+ф" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_f, - .utf8 = "ф", - .mods = .{ .alt = true }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_f, + .utf8 = "ф", + .mods = .{ .alt = true }, + }, .{ .alt_esc_prefix = true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("ф", actual); + }); + try testing.expectEqualStrings("ф", writer.buffered()); } test "legacy: ctrl+c" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_c, - .mods = .{ .ctrl = true }, - .utf8 = "c", - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x03", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_c, + .mods = .{ .ctrl = true }, + .utf8 = "c", + }, .{}); + try testing.expectEqualStrings("\x03", writer.buffered()); } test "legacy: ctrl+space" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .space, - .mods = .{ .ctrl = true }, - .utf8 = " ", - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x00", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .space, + .mods = .{ .ctrl = true }, + .utf8 = " ", + }, .{}); + try testing.expectEqualStrings("\x00", writer.buffered()); } test "legacy: ctrl+shift+backspace" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .backspace, - .mods = .{ .ctrl = true, .shift = true }, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x08", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .backspace, + .mods = .{ .ctrl = true, .shift = true }, + }, .{}); + try testing.expectEqualStrings("\x08", writer.buffered()); } test "legacy: ctrl+shift+char with modify other state 2" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_h, - .mods = .{ .ctrl = true, .shift = true }, - .utf8 = "H", - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_h, + .mods = .{ .ctrl = true, .shift = true }, + .utf8 = "H", + }, .{ .modify_other_keys_state_2 = true, - }; + }); + try testing.expectEqualStrings("\x1b[27;6;72~", writer.buffered()); +} - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[27;6;72~", actual); +test "legacy: ctrl+shift+char with modify other state 2 and consumed mods" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_h, + .mods = .{ .ctrl = true, .shift = true }, + .consumed_mods = .{ .shift = true }, + .utf8 = "H", + }, .{ + .modify_other_keys_state_2 = true, + }); + try testing.expectEqualStrings("\x1b[27;6;72~", writer.buffered()); +} + +test "legacy: alt+digit with modify other state 2" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .digit_8, + .mods = .{ .alt = true }, + .consumed_mods = .{}, + .utf8 = "8", + }, .{ + .modify_other_keys_state_2 = true, + .macos_option_as_alt = .true, + }); + try testing.expectEqualStrings("\x1b[27;3;56~", writer.buffered()); +} + +test "legacy: alt+digit with modify other state 2 and macos-option-as-alt = false" { + if (comptime builtin.os.tag != .macos) return error.SkipZigTest; + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .digit_8, + .mods = .{ .alt = true }, + .consumed_mods = .{ .alt = true }, + .utf8 = "[", // common translation of option+8 with European keyboard layouts + }, .{ + .modify_other_keys_state_2 = true, + .macos_option_as_alt = .false, + }); + try testing.expectEqualStrings("[", writer.buffered()); } test "legacy: fixterm awkward letters" { var buf: [128]u8 = undefined; { - var enc: KeyEncoder = .{ .event = .{ + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ .key = .key_i, .mods = .{ .ctrl = true }, .utf8 = "i", - } }; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[105;5u", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[105;5u", writer.buffered()); } { - var enc: KeyEncoder = .{ .event = .{ + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ .key = .key_m, .mods = .{ .ctrl = true }, .utf8 = "m", - } }; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[109;5u", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[109;5u", writer.buffered()); } { - var enc: KeyEncoder = .{ .event = .{ + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ .key = .bracket_left, .mods = .{ .ctrl = true }, .utf8 = "[", - } }; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[91;5u", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[91;5u", writer.buffered()); } { - var enc: KeyEncoder = .{ .event = .{ + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ .key = .digit_2, .mods = .{ .ctrl = true, .shift = true }, .utf8 = "@", .unshifted_codepoint = '2', - } }; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[64;5u", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[64;5u", writer.buffered()); } } @@ -2030,199 +2118,189 @@ test "legacy: fixterm awkward letters" { test "legacy: ctrl+shift+letter ascii" { var buf: [128]u8 = undefined; { - var enc: KeyEncoder = .{ .event = .{ + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ .key = .key_m, .mods = .{ .ctrl = true, .shift = true }, .utf8 = "M", .unshifted_codepoint = 'm', - } }; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[109;6u", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[109;6u", writer.buffered()); } } test "legacy: shift+function key should use all mods" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .arrow_up, - .mods = .{ .shift = true }, - .consumed_mods = .{ .shift = true }, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[1;2A", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .arrow_up, + .mods = .{ .shift = true }, + .consumed_mods = .{ .shift = true }, + }, .{}); + try testing.expectEqualStrings("\x1b[1;2A", writer.buffered()); } test "legacy: keypad enter" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .numpad_enter, - .mods = .{}, - .consumed_mods = .{}, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\r", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_enter, + .mods = .{}, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\r", writer.buffered()); } test "legacy: keypad 1" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .numpad_1, - .mods = .{}, - .consumed_mods = .{}, - .utf8 = "1", - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("1", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{}, + .consumed_mods = .{}, + .utf8 = "1", + }, .{}); + try testing.expectEqualStrings("1", writer.buffered()); } test "legacy: keypad 1 with application keypad" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .numpad_1, - .mods = .{}, - .consumed_mods = .{}, - .utf8 = "1", - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{}, + .consumed_mods = .{}, + .utf8 = "1", + }, .{ .keypad_key_application = true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1bOq", actual); + }); + try testing.expectEqualStrings("\x1bOq", writer.buffered()); } test "legacy: keypad 1 with application keypad and numlock" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .numpad_1, - .mods = .{ .num_lock = true }, - .consumed_mods = .{}, - .utf8 = "1", - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{ .num_lock = true }, + .consumed_mods = .{}, + .utf8 = "1", + }, .{ .keypad_key_application = true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1bOq", actual); + }); + try testing.expectEqualStrings("\x1bOq", writer.buffered()); } test "legacy: keypad 1 with application keypad and numlock ignore" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .numpad_1, - .mods = .{ .num_lock = false }, - .consumed_mods = .{}, - .utf8 = "1", - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{ .num_lock = false }, + .consumed_mods = .{}, + .utf8 = "1", + }, .{ .keypad_key_application = true, .ignore_keypad_with_numlock = true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("1", actual); + }); + try testing.expectEqualStrings("1", writer.buffered()); } test "legacy: f1" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .f1, - .mods = .{ .ctrl = true }, - .consumed_mods = .{}, - }, - }; // F1 { - enc.event.key = .f1; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[1;5P", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f1, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[1;5P", writer.buffered()); } // F2 { - enc.event.key = .f2; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[1;5Q", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f2, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[1;5Q", writer.buffered()); } // F3 { - enc.event.key = .f3; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[13;5~", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f3, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[13;5~", writer.buffered()); } // F4 { - enc.event.key = .f4; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[1;5S", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f4, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[1;5S", writer.buffered()); } // F5 uses new encoding { - enc.event.key = .f5; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[15;5~", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f5, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[15;5~", writer.buffered()); } } test "legacy: left_shift+tab" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .tab, - .mods = .{ - .shift = true, - .sides = .{ .shift = .left }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .tab, + .mods = .{ + .shift = true, + .sides = .{ .shift = .left }, }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[Z", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[Z", writer.buffered()); } test "legacy: right_shift+tab" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .tab, - .mods = .{ - .shift = true, - .sides = .{ .shift = .right }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .tab, + .mods = .{ + .shift = true, + .sides = .{ .shift = .right }, }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[Z", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[Z", writer.buffered()); } test "legacy: hu layout ctrl+ő sends proper codepoint" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .bracket_left, - .mods = .{ .ctrl = true }, - .utf8 = "ő", - .unshifted_codepoint = 337, - }, - }; - - const actual = try enc.legacy(&buf); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .bracket_left, + .mods = .{ .ctrl = true }, + .utf8 = "ő", + .unshifted_codepoint = 337, + }, .{}); + const actual = writer.buffered(); try testing.expectEqualStrings("[337;5u", actual[1..]); } @@ -2230,46 +2308,37 @@ test "legacy: super-only on macOS with text" { if (comptime builtin.os.tag != .macos) return error.SkipZigTest; var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_b, - .utf8 = "b", - .mods = .{ .super = true }, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_b, + .utf8 = "b", + .mods = .{ .super = true }, + }, .{}); + try testing.expectEqualStrings("", writer.buffered()); } test "legacy: super and other mods on macOS with text" { if (comptime builtin.os.tag != .macos) return error.SkipZigTest; var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_b, - .utf8 = "B", - .mods = .{ .super = true, .shift = true }, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_b, + .utf8 = "B", + .mods = .{ .super = true, .shift = true }, + }, .{}); + try testing.expectEqualStrings("", writer.buffered()); } test "legacy: backspace with DEL utf8" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .backspace, - .utf8 = &.{0x7F}, - .unshifted_codepoint = 0x08, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x7F", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .backspace, + .utf8 = &.{0x7F}, + .unshifted_codepoint = 0x08, + }, .{}); + try testing.expectEqualStrings("\x7F", writer.buffered()); } test "ctrlseq: normal ctrl c" { diff --git a/src/input/keyboard.zig b/src/input/keyboard.zig index 73674df2c..d2882a23a 100644 --- a/src/input/keyboard.zig +++ b/src/input/keyboard.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const OptionAsAlt = @import("../config.zig").OptionAsAlt; +const OptionAsAlt = @import("config.zig").OptionAsAlt; /// Keyboard layouts. /// 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 new file mode 100644 index 000000000..111a783f3 --- /dev/null +++ b/src/input/paste.zig @@ -0,0 +1,145 @@ +const std = @import("std"); +const Terminal = @import("../terminal/Terminal.zig"); + +pub const Options = struct { + /// True if bracketed paste mode is on. + bracketed: bool, + + /// Return the encoding options based on the current terminal state. + pub fn fromTerminal(t: *const Terminal) Options { + return .{ + .bracketed = t.modes.get(.bracketed_paste), + }; + } +}; + +/// Encode the given data for pasting. The resulting value can be written +/// to the pty to perform a paste of the input data. +/// +/// The data can be either a `[]u8` or a `[]const u8`. If the data +/// type is const then `EncodeError` may be returned. If the data type +/// is mutable then this function can't return an error. +/// +/// This slightly complex calling style allows for initially const +/// data to be passed in without an allocation, since it is rare in normal +/// use cases that the data will need to be modified. In the unlikely case +/// data does need to be modified, the caller can make a mutable copy +/// after seeing the error. +/// +/// The data is returned as a set of slices to limit allocations. The caller +/// can combine the slices into a single buffer if desired. +/// +/// WARNING: The input data is not checked for safety. See the `isSafe` +/// function to check if the data is safe to paste. +pub fn encode( + data: anytype, + opts: Options, +) switch (@TypeOf(data)) { + []u8 => [3][]const u8, + []const u8 => Error![3][]const u8, + else => unreachable, +} { + const mutable = @TypeOf(data) == []u8; + + var result: [3][]const u8 = .{ "", data, "" }; + + // Bracketed paste mode (mode 2004) wraps pasted data in + // fenceposts so that the terminal can ignore things like newlines. + if (opts.bracketed) { + result[0] = "\x1b[200~"; + result[2] = "\x1b[201~"; + return result; + } + + // Non-bracketed. We have to replace newline with `\r`. This matches + // the behavior of xterm and other terminals. For `\r\n` this will + // result in `\r\r` which does match xterm. + if (comptime mutable) { + std.mem.replaceScalar(u8, data, '\n', '\r'); + } else if (std.mem.indexOfScalar(u8, data, '\n') != null) { + return Error.MutableRequired; + } + + return result; +} + +pub const Error = error{ + /// Returned if encoding requires a mutable copy of the data. This + /// can only be returned if the input data type is const. + MutableRequired, +}; + +/// Returns true if the data looks safe to paste. Data is considered +/// unsafe if it contains any of the following: +/// +/// - `\n`: Newlines can be used to inject commands. +/// - `\x1b[201~`: This is the end of a bracketed paste. This cane be used +/// to exit a bracketed paste and inject commands. +/// +/// We consider any scenario unsafe regardless of current terminal state. +/// For example, even if bracketed paste mode is not active, we still +/// consider `\x1b[201~` unsafe. The existence of these types of bytes +/// should raise suspicion that the producer of the paste data is +/// acting strangely. +pub fn isSafe(data: []const u8) bool { + return std.mem.indexOf(u8, data, "\n") == null and + std.mem.indexOf(u8, data, "\x1b[201~") == null; +} + +test isSafe { + const testing = std.testing; + try testing.expect(isSafe("hello")); + try testing.expect(!isSafe("hello\n")); + try testing.expect(!isSafe("hello\nworld")); + try testing.expect(!isSafe("he\x1b[201~llo")); +} + +test "encode bracketed" { + const testing = std.testing; + const result = try encode( + @as([]const u8, "hello"), + .{ .bracketed = true }, + ); + try testing.expectEqualStrings("\x1b[200~", result[0]); + try testing.expectEqualStrings("hello", result[1]); + try testing.expectEqualStrings("\x1b[201~", result[2]); +} + +test "encode unbracketed no newlines" { + const testing = std.testing; + const result = try encode( + @as([]const u8, "hello"), + .{ .bracketed = false }, + ); + try testing.expectEqualStrings("", result[0]); + try testing.expectEqualStrings("hello", result[1]); + try testing.expectEqualStrings("", result[2]); +} + +test "encode unbracketed newlines const" { + const testing = std.testing; + try testing.expectError(Error.MutableRequired, encode( + @as([]const u8, "hello\nworld"), + .{ .bracketed = false }, + )); +} + +test "encode unbracketed newlines" { + const testing = std.testing; + const data: []u8 = try testing.allocator.dupe(u8, "hello\nworld"); + defer testing.allocator.free(data); + const result = encode(data, .{ .bracketed = false }); + try testing.expectEqualStrings("", result[0]); + try testing.expectEqualStrings("hello\rworld", result[1]); + try testing.expectEqualStrings("", result[2]); +} + +test "encode unbracketed windows-stye newline" { + const testing = std.testing; + const data: []u8 = try testing.allocator.dupe(u8, "hello\r\nworld"); + defer testing.allocator.free(data); + const result = encode(data, .{ .bracketed = false }); + try testing.expectEqualStrings("", result[0]); + try testing.expectEqualStrings("hello\r\rworld", result[1]); + try testing.expectEqualStrings("", result[2]); +} diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 49b05bd7f..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"); @@ -172,11 +172,7 @@ pub fn init(surface: *Surface) !Inspector { .surface = surface, .key_events = key_buf, .vt_events = vt_events, - .vt_stream = stream: { - var s: inspector.termio.Stream = .init(vt_handler); - s.parser.osc_parser.alloc = surface.alloc; - break :stream s; - }, + .vt_stream = .initAlloc(surface.alloc, vt_handler), }; } @@ -194,7 +190,6 @@ pub fn deinit(self: *Inspector) void { while (it.next()) |v| v.deinit(self.surface.alloc); self.vt_events.deinit(self.surface.alloc); - self.vt_stream.handler.deinit(); self.vt_stream.deinit(); } } @@ -309,7 +304,7 @@ fn renderScreenWindow(self: *Inspector) void { )) return; const t = self.surface.renderer_state.terminal; - const screen = &t.screen; + const screen: *terminal.Screen = t.screens.active; { _ = cimgui.c.igBeginTable( @@ -329,7 +324,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", @tagName(t.active_screen).ptr); + cimgui.c.igText("%s", @tagName(t.screens.active_key).ptr); } } } @@ -779,7 +774,7 @@ fn renderSizeWindow(self: *Inspector) void { { const hover_point: terminal.point.Coordinate = pt: { const p = self.mouse.last_point orelse break :pt .{}; - const pt = t.screen.pages.pointFromPin( + const pt = t.screens.active.pages.pointFromPin( .active, p, ) orelse break :pt .{}; @@ -866,7 +861,7 @@ fn renderSizeWindow(self: *Inspector) void { { const left_click_point: terminal.point.Coordinate = pt: { const p = mouse.left_click_pin orelse break :pt .{}; - const pt = t.screen.pages.pointFromPin( + const pt = t.screens.active.pages.pointFromPin( .active, p.*, ) orelse break :pt .{}; diff --git a/src/inspector/cell.zig b/src/inspector/cell.zig index 9a3112bdd..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"); @@ -130,7 +130,7 @@ pub const Cell = struct { switch (self.style.fg_color) { .none => cimgui.c.igText("default"), .palette => |idx| { - const rgb = t.color_palette.colors[idx]; + const rgb = t.colors.palette.current[idx]; cimgui.c.igValue_Int("Palette", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, @@ -169,7 +169,7 @@ pub const Cell = struct { switch (self.style.bg_color) { .none => cimgui.c.igText("default"), .palette => |idx| { - const rgb = t.color_palette.colors[idx]; + const rgb = t.colors.palette.current[idx]; cimgui.c.igValue_Int("Palette", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, diff --git a/src/inspector/cursor.zig b/src/inspector/cursor.zig index be1cd63fe..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"); @@ -51,7 +50,7 @@ pub fn renderInTable( switch (cursor.style.fg_color) { .none => cimgui.c.igText("default"), .palette => |idx| { - const rgb = t.color_palette.colors[idx]; + const rgb = t.colors.palette.current[idx]; cimgui.c.igValue_Int("Palette", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, @@ -90,7 +89,7 @@ pub fn renderInTable( switch (cursor.style.bg_color) { .none => cimgui.c.igText("default"), .palette => |idx| { - const rgb = t.color_palette.colors[idx]; + const rgb = t.colors.palette.current[idx]; cimgui.c.igValue_Int("Palette", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, 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/inspector/termio.zig b/src/inspector/termio.zig index 03a3b0375..7e2b51ee1 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -64,7 +64,7 @@ pub const VTEvent = struct { return .{ .kind = kind, .str = str, - .cursor = t.screen.cursor, + .cursor = t.screens.active.cursor, .scrolling_region = t.scrolling_region, .metadata = md.unmanaged, }; @@ -264,6 +264,11 @@ pub const VTEvent = struct { if (std.mem.eql(u8, field.name, tag_name)) { const s = if (field.type == void) try alloc.dupeZ(u8, tag_name) + else if (field.type == [:0]const u8 or field.type == []const u8) + try std.fmt.allocPrintSentinel(alloc, "{s}={s}", .{ + tag_name, + @field(value, field.name), + }, 0) else try std.fmt.allocPrintSentinel(alloc, "{s}={}", .{ tag_name, @@ -328,6 +333,15 @@ pub const VTHandler = struct { cimgui.c.ImGuiTextFilter_destroy(self.filter_text); } + pub fn vt( + self: *VTHandler, + comptime action: Stream.Action.Tag, + value: Stream.Action.Value(action), + ) !void { + _ = self; + _ = value; + } + /// This is called with every single terminal action. pub fn handleManually(self: *VTHandler, action: terminal.Parser.Action) !bool { const insp = self.surface.inspector orelse return false; diff --git a/src/lib/allocator.zig b/src/lib/allocator.zig index bcd7f9dcc..fc56033a2 100644 --- a/src/lib/allocator.zig +++ b/src/lib/allocator.zig @@ -2,6 +2,9 @@ const std = @import("std"); const builtin = @import("builtin"); const testing = std.testing; +/// Convenience functions +pub const convenience = @import("allocator/convenience.zig"); + /// Useful alias since they're required to create Zig allocators pub const ZigVTable = std.mem.Allocator.VTable; @@ -20,11 +23,17 @@ pub fn default(c_alloc_: ?*const Allocator) std.mem.Allocator { // If we're given an allocator, use it. if (c_alloc_) |c_alloc| return c_alloc.zig(); + // Tests always use the test allocator so we can detect leaks. + if (comptime builtin.is_test) return testing.allocator; + // If we have libc, use that. We prefer libc if we have it because // its generally fast but also lets the embedder easily override // malloc/free with custom allocators like mimalloc or something. if (comptime builtin.link_libc) return std.heap.c_allocator; + // Wasm + if (comptime builtin.target.cpu.arch.isWasm()) return std.heap.wasm_allocator; + // No libc, use the preferred allocator for releases which is the // Zig SMP allocator. return std.heap.smp_allocator; diff --git a/src/lib/allocator/convenience.zig b/src/lib/allocator/convenience.zig new file mode 100644 index 000000000..0f5f88d29 --- /dev/null +++ b/src/lib/allocator/convenience.zig @@ -0,0 +1,59 @@ +//! This contains convenience functions for allocating various types. +//! +//! The primary use case for this is Wasm builds. Ghostty relies a lot on +//! pointers to various types for ABI compatibility and creating those pointers +//! in Wasm is tedious. This file contains a purely additive set of functions +//! that can be exposed to the Wasm module without changing the API from the +//! C library. +//! +//! Given these are convenience methods, they always use the default allocator. +//! If a caller is using a custom allocator, they have the expertise to +//! allocate these types manually using their custom allocator. + +// Get our default allocator at comptime since it is known. +const default = @import("../allocator.zig").default; +const alloc = default(null); + +pub const Opaque = *anyopaque; + +pub fn allocOpaque() callconv(.c) ?*Opaque { + return alloc.create(*anyopaque) catch return null; +} + +pub fn freeOpaque(ptr: ?*Opaque) callconv(.c) void { + if (ptr) |p| alloc.destroy(p); +} + +pub fn allocU8Array(len: usize) callconv(.c) ?[*]u8 { + const slice = alloc.alloc(u8, len) catch return null; + return slice.ptr; +} + +pub fn freeU8Array(ptr: ?[*]u8, len: usize) callconv(.c) void { + if (ptr) |p| alloc.free(p[0..len]); +} + +pub fn allocU16Array(len: usize) callconv(.c) ?[*]u16 { + const slice = alloc.alloc(u16, len) catch return null; + return slice.ptr; +} + +pub fn freeU16Array(ptr: ?[*]u16, len: usize) callconv(.c) void { + if (ptr) |p| alloc.free(p[0..len]); +} + +pub fn allocU8() callconv(.c) ?*u8 { + return alloc.create(u8) catch return null; +} + +pub fn freeU8(ptr: ?*u8) callconv(.c) void { + if (ptr) |p| alloc.destroy(p); +} + +pub fn allocUsize() callconv(.c) ?*usize { + return alloc.create(usize) catch return null; +} + +pub fn freeUsize(ptr: ?*usize) callconv(.c) void { + if (ptr) |p| alloc.destroy(p); +} diff --git a/src/lib/enum.zig b/src/lib/enum.zig index c3971ebde..6fc759846 100644 --- a/src/lib/enum.zig +++ b/src/lib/enum.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Target = @import("target.zig").Target; /// Create an enum type with the given keys that is C ABI compatible /// if we're targeting C, otherwise a Zig enum with smallest possible @@ -58,11 +59,6 @@ pub fn Enum( return Result; } -pub const Target = union(enum) { - c, - zig, -}; - test "zig" { const testing = std.testing; const T = Enum(.zig, &.{ "a", "b", "c", "d" }); diff --git a/src/lib/main.zig b/src/lib/main.zig index 4ef8dcb2d..5a626b1e8 100644 --- a/src/lib/main.zig +++ b/src/lib/main.zig @@ -1,9 +1,14 @@ const std = @import("std"); const enumpkg = @import("enum.zig"); +const types = @import("types.zig"); +const unionpkg = @import("union.zig"); pub const allocator = @import("allocator.zig"); pub const Enum = enumpkg.Enum; -pub const EnumTarget = enumpkg.Target; +pub const String = types.String; +pub const Struct = @import("struct.zig").Struct; +pub const Target = @import("target.zig").Target; +pub const TaggedUnion = unionpkg.TaggedUnion; test { std.testing.refAllDecls(@This()); diff --git a/src/lib/struct.zig b/src/lib/struct.zig new file mode 100644 index 000000000..d494da2e6 --- /dev/null +++ b/src/lib/struct.zig @@ -0,0 +1,31 @@ +const std = @import("std"); +const Target = @import("target.zig").Target; + +pub fn Struct( + comptime target: Target, + comptime Zig: type, +) type { + return switch (target) { + .zig => Zig, + .c => c: { + const info = @typeInfo(Zig).@"struct"; + var fields: [info.fields.len]std.builtin.Type.StructField = undefined; + for (info.fields, 0..) |field, i| { + fields[i] = .{ + .name = field.name, + .type = field.type, + .default_value_ptr = field.default_value_ptr, + .is_comptime = field.is_comptime, + .alignment = field.alignment, + }; + } + + break :c @Type(.{ .@"struct" = .{ + .layout = .@"extern", + .fields = &fields, + .decls = &.{}, + .is_tuple = info.is_tuple, + } }); + }, + }; +} diff --git a/src/lib/target.zig b/src/lib/target.zig new file mode 100644 index 000000000..8d7a7fb89 --- /dev/null +++ b/src/lib/target.zig @@ -0,0 +1,6 @@ +/// The target for ABI generation. The detection of this is left to the +/// caller since there are multiple ways to do that. +pub const Target = union(enum) { + c, + zig, +}; diff --git a/src/lib/types.zig b/src/lib/types.zig new file mode 100644 index 000000000..758540d12 --- /dev/null +++ b/src/lib/types.zig @@ -0,0 +1,13 @@ +pub const String = extern struct { + ptr: [*]const u8, + len: usize, + + pub fn init(zig: anytype) String { + return switch (@TypeOf(zig)) { + []u8, []const u8 => .{ + .ptr = zig.ptr, + .len = zig.len, + }, + }; + } +}; diff --git a/src/lib/union.zig b/src/lib/union.zig new file mode 100644 index 000000000..924d0e864 --- /dev/null +++ b/src/lib/union.zig @@ -0,0 +1,171 @@ +const std = @import("std"); +const testing = std.testing; +const Target = @import("target.zig").Target; + +/// Create a tagged union type that supports a C ABI and maintains +/// C ABI compatibility when adding new tags. This returns a set of types +/// and functions to augment the given Union type, not create a wholly new +/// union type. +/// +/// The C ABI compatible types and functions are only available when the +/// target produces C values. +/// +/// The `Union` type should be a standard Zig tagged union. The tag type +/// should be explicit (i.e. not `union(enum)`) and the tag type should +/// be an enum created with the `Enum` function in this library, so that +/// automatic C ABI compatibility is ensured. +/// +/// The `Padding` type is a type that is always added to the C union +/// with the key `_padding`. This should be set to a type that has the size +/// and alignment needed to pad the C union to the expected size. This +/// should never change to ensure ABI compatibility. +pub fn TaggedUnion( + comptime target: Target, + comptime Union: type, + comptime Padding: type, +) type { + return struct { + comptime { + switch (target) { + .zig => {}, + + // For ABI compatibility, we expect that this is our union size. + .c => if (@sizeOf(CValue) != @sizeOf(Padding)) { + @compileLog(@sizeOf(CValue)); + @compileError("TaggedUnion CValue size does not match expected fixed size"); + }, + } + } + + /// The tag type. + pub const Tag = @typeInfo(Union).@"union".tag_type.?; + + /// The Zig union. + pub const Zig = Union; + + /// The C ABI compatible tagged union type. + pub const C = switch (target) { + .zig => struct {}, + .c => extern struct { + tag: Tag, + value: CValue, + }, + }; + + /// The C ABI compatible union value type. + pub const CValue = cvalue: { + switch (target) { + .zig => break :cvalue extern struct {}, + .c => {}, + } + + const tag_fields = @typeInfo(Tag).@"enum".fields; + var union_fields: [tag_fields.len + 1]std.builtin.Type.UnionField = undefined; + for (tag_fields, 0..) |field, i| { + const action = @unionInit(Union, field.name, undefined); + const Type = t: { + const Type = @TypeOf(@field(action, field.name)); + // Types can provide custom types for their CValue. + switch (@typeInfo(Type)) { + .@"enum", .@"struct", .@"union" => if (@hasDecl(Type, "C")) break :t Type.C, + else => {}, + } + + break :t Type; + }; + + union_fields[i] = .{ + .name = field.name, + .type = Type, + .alignment = @alignOf(Type), + }; + } + + union_fields[tag_fields.len] = .{ + .name = "_padding", + .type = Padding, + .alignment = @alignOf(Padding), + }; + + break :cvalue @Type(.{ .@"union" = .{ + .layout = .@"extern", + .tag_type = null, + .fields = &union_fields, + .decls = &.{}, + } }); + }; + + /// Convert to C union. + pub fn cval(self: Union) C { + const value: CValue = switch (self) { + inline else => |v, tag| @unionInit( + CValue, + @tagName(tag), + value: { + switch (@typeInfo(@TypeOf(v))) { + .@"enum", .@"struct", .@"union" => if (@hasDecl(@TypeOf(v), "cval")) break :value v.cval(), + else => {}, + } + + break :value v; + }, + ), + }; + + return .{ + .tag = @as(Tag, self), + .value = value, + }; + } + + /// Returns the value type for the given tag. + pub fn Value(comptime tag: Tag) type { + @setEvalBranchQuota(10000); + inline for (@typeInfo(Union).@"union".fields) |field| { + const field_tag = @field(Tag, field.name); + if (field_tag == tag) return field.type; + } + + unreachable; + } + }; +} + +test "TaggedUnion: matching size" { + const Tag = enum(c_int) { a, b }; + const U = TaggedUnion( + .c, + union(Tag) { + a: u32, + b: u64, + }, + u64, + ); + + try testing.expectEqual(8, @sizeOf(U.CValue)); +} + +test "TaggedUnion: padded size" { + const Tag = enum(c_int) { a }; + const U = TaggedUnion( + .c, + union(Tag) { + a: u32, + }, + u64, + ); + + try testing.expectEqual(8, @sizeOf(U.CValue)); +} + +test "TaggedUnion: c conversion" { + const Tag = enum(c_int) { a, b }; + const U = TaggedUnion(.c, union(Tag) { + a: u32, + b: u64, + }, u64); + + const c = U.cval(.{ .a = 42 }); + try testing.expectEqual(Tag.a, c.tag); + try testing.expectEqual(42, c.value.a); +} diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 8c49b4900..03a883e20 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -9,6 +9,9 @@ //! this in the future. const lib = @This(); +const std = @import("std"); +const builtin = @import("builtin"); + // The public API below reproduces a lot of terminal/main.zig but // is separate because (1) we need our root file to be in `src/` // so we can access other directories and (2) we may want to withhold @@ -22,6 +25,8 @@ pub const osc = terminal.osc; 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; @@ -43,14 +48,16 @@ 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 ScreenType = Terminal.ScreenType; +pub const ScreenSet = terminal.ScreenSet; pub const Selection = terminal.Selection; pub const SizeReportStyle = terminal.SizeReportStyle; pub const StringMap = terminal.StringMap; pub const Style = terminal.Style; pub const Terminal = terminal.Terminal; pub const Stream = terminal.Stream; +pub const StreamAction = terminal.StreamAction; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = terminal.CursorStyle; @@ -66,11 +73,55 @@ pub const EraseLine = terminal.EraseLine; pub const TabClear = terminal.TabClear; pub const Attribute = terminal.Attribute; +/// Terminal-specific input encoding is also part of libghostty-vt. +pub const input = struct { + // We have to be careful to only import targeted files within + // the input package because the full package brings in too many + // other dependencies. + const paste = @import("input/paste.zig"); + const key = @import("input/key.zig"); + const key_encode = @import("input/key_encode.zig"); + + // Paste-related APIs + pub const PasteError = paste.Error; + pub const PasteOptions = paste.Options; + pub const isSafePaste = paste.isSafe; + pub const encodePaste = paste.encode; + + // Key encoding + pub const Key = key.Key; + pub const KeyAction = key.Action; + pub const KeyEvent = key.KeyEvent; + pub const KeyMods = key.Mods; + pub const KeyEncodeOptions = key_encode.Options; + pub const encodeKey = key_encode.encode; +}; + comptime { // If we're building the C library (vs. the Zig module) then // we want to reference the C API so that it gets exported. if (@import("root") == lib) { const c = terminal.c_api; + @export(&c.key_event_new, .{ .name = "ghostty_key_event_new" }); + @export(&c.key_event_free, .{ .name = "ghostty_key_event_free" }); + @export(&c.key_event_set_action, .{ .name = "ghostty_key_event_set_action" }); + @export(&c.key_event_get_action, .{ .name = "ghostty_key_event_get_action" }); + @export(&c.key_event_set_key, .{ .name = "ghostty_key_event_set_key" }); + @export(&c.key_event_get_key, .{ .name = "ghostty_key_event_get_key" }); + @export(&c.key_event_set_mods, .{ .name = "ghostty_key_event_set_mods" }); + @export(&c.key_event_get_mods, .{ .name = "ghostty_key_event_get_mods" }); + @export(&c.key_event_set_consumed_mods, .{ .name = "ghostty_key_event_set_consumed_mods" }); + @export(&c.key_event_get_consumed_mods, .{ .name = "ghostty_key_event_get_consumed_mods" }); + @export(&c.key_event_set_composing, .{ .name = "ghostty_key_event_set_composing" }); + @export(&c.key_event_get_composing, .{ .name = "ghostty_key_event_get_composing" }); + @export(&c.key_event_set_utf8, .{ .name = "ghostty_key_event_set_utf8" }); + @export(&c.key_event_get_utf8, .{ .name = "ghostty_key_event_get_utf8" }); + @export(&c.key_event_set_unshifted_codepoint, .{ .name = "ghostty_key_event_set_unshifted_codepoint" }); + @export(&c.key_event_get_unshifted_codepoint, .{ .name = "ghostty_key_event_get_unshifted_codepoint" }); + @export(&c.key_encoder_new, .{ .name = "ghostty_key_encoder_new" }); + @export(&c.key_encoder_free, .{ .name = "ghostty_key_encoder_free" }); + @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); + @export(&c.key_encoder_encode, .{ .name = "ghostty_key_encoder_encode" }); @export(&c.osc_new, .{ .name = "ghostty_osc_new" }); @export(&c.osc_free, .{ .name = "ghostty_osc_free" }); @export(&c.osc_next, .{ .name = "ghostty_osc_next" }); @@ -78,12 +129,61 @@ comptime { @export(&c.osc_end, .{ .name = "ghostty_osc_end" }); @export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" }); @export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" }); + @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); + @export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" }); + @export(&c.sgr_new, .{ .name = "ghostty_sgr_new" }); + @export(&c.sgr_free, .{ .name = "ghostty_sgr_free" }); + @export(&c.sgr_reset, .{ .name = "ghostty_sgr_reset" }); + @export(&c.sgr_set_params, .{ .name = "ghostty_sgr_set_params" }); + @export(&c.sgr_next, .{ .name = "ghostty_sgr_next" }); + @export(&c.sgr_unknown_full, .{ .name = "ghostty_sgr_unknown_full" }); + @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); + @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); + @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); + + // On Wasm we need to export our allocator convenience functions. + if (builtin.target.cpu.arch.isWasm()) { + const alloc = @import("lib/allocator/convenience.zig"); + @export(&alloc.allocOpaque, .{ .name = "ghostty_wasm_alloc_opaque" }); + @export(&alloc.freeOpaque, .{ .name = "ghostty_wasm_free_opaque" }); + @export(&alloc.allocU8Array, .{ .name = "ghostty_wasm_alloc_u8_array" }); + @export(&alloc.freeU8Array, .{ .name = "ghostty_wasm_free_u8_array" }); + @export(&alloc.allocU16Array, .{ .name = "ghostty_wasm_alloc_u16_array" }); + @export(&alloc.freeU16Array, .{ .name = "ghostty_wasm_free_u16_array" }); + @export(&alloc.allocU8, .{ .name = "ghostty_wasm_alloc_u8" }); + @export(&alloc.freeU8, .{ .name = "ghostty_wasm_free_u8" }); + @export(&alloc.allocUsize, .{ .name = "ghostty_wasm_alloc_usize" }); + @export(&alloc.freeUsize, .{ .name = "ghostty_wasm_free_usize" }); + @export(&c.wasm_alloc_sgr_attribute, .{ .name = "ghostty_wasm_alloc_sgr_attribute" }); + @export(&c.wasm_free_sgr_attribute, .{ .name = "ghostty_wasm_free_sgr_attribute" }); + } } } +pub const std_options: std.Options = options: { + if (builtin.target.cpu.arch.isWasm()) break :options .{ + // Wasm builds we specifically want to optimize for space with small + // releases so we bump up to warn. Everything else acts pretty normal. + .log_level = switch (builtin.mode) { + .Debug => .debug, + .ReleaseSmall => .warn, + else => .info, + }, + + // Wasm doesn't have access to stdio so we have a custom log function. + .logFn = @import("os/wasm/log.zig").log, + }; + + // For everything else we currently use defaults. Longer term I'm + // SURE this isn't right (e.g. we definitely want to customize the log + // function for the C lib at least). + break :options .{}; +}; + test { _ = terminal; _ = @import("lib/main.zig"); + @import("std").testing.refAllDecls(input); if (comptime terminal.options.c_abi) { _ = terminal.c_api; } 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 decfc609c..72d602989 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"); @@ -124,19 +118,17 @@ fn logFn( comptime format: []const u8, args: anytype, ) void { - // Stuff we can do before the lock - const level_txt = comptime level.asText(); - const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; - - // Lock so we are thread-safe - std.debug.lockStdErr(); - defer std.debug.unlockStdErr(); - // On Mac, we use unified logging. To view this: // // sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"' // - if (builtin.target.os.tag.isDarwin()) { + // macOS logging is thread safe so no need for locks/mutexes + macos: { + if (comptime !builtin.target.os.tag.isDarwin()) break :macos; + if (!state.logging.macos) break :macos; + + const prefix = if (scope == .default) "" else @tagName(scope) ++ ": "; + // Convert our levels to Mac levels const mac_level: macos.os.LogType = switch (level) { .debug => .debug, @@ -149,26 +141,35 @@ fn logFn( // but we shouldn't be logging too much. const logger = macos.os.Log.create(build_config.bundle_id, @tagName(scope)); defer logger.release(); - logger.log(std.heap.c_allocator, mac_level, format, args); + logger.log(std.heap.c_allocator, mac_level, prefix ++ format, args); } - switch (state.logging) { - .disabled => {}, + stderr: { + // don't log debug messages to stderr unless we are a debug build + if (comptime builtin.mode != .Debug and level == .debug) break :stderr; - .stderr => { - // Always try default to send to stderr - var buffer: [1024]u8 = undefined; - var stderr = std.fs.File.stderr().writer(&buffer); - const writer = &stderr.interface; - nosuspend writer.print(level_txt ++ prefix ++ format ++ "\n", args) catch return; - // TODO: Do we want to use flushless stderr in the future? - writer.flush() catch {}; - }, + // skip if we are not logging to stderr + if (!state.logging.stderr) break :stderr; + + // Lock so we are thread-safe + var buf: [64]u8 = undefined; + const stderr = std.debug.lockStderrWriter(&buf); + defer std.debug.unlockStderrWriter(); + + const level_txt = comptime level.asText(); + const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; + nosuspend stderr.print(level_txt ++ prefix ++ format ++ "\n", args) catch break :stderr; + nosuspend stderr.flush() catch break :stderr; } } pub const std_options: std.Options = .{ // Our log level is always at least info in every build mode. + // + // Note, we don't lower this to debug even with conditional logging + // via GHOSTTY_LOG because our debug logs are very expensive to + // calculate and we want to make sure they're optimized out in + // builds. .log_level = switch (builtin.mode) { .Debug => .debug, else => .info, @@ -193,6 +194,7 @@ test { _ = @import("crash/main.zig"); _ = @import("datastruct/main.zig"); _ = @import("inspector/main.zig"); + _ = @import("lib/main.zig"); _ = @import("terminal/main.zig"); _ = @import("terminfo/main.zig"); _ = @import("simd/main.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 97c796f8b..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; @@ -19,9 +19,7 @@ pub fn current(alloc: Allocator, pid: std.os.linux.pid_t) !?[]const u8 { defer file.close(); // Read it all into memory -- we don't expect this file to ever be that large. - var reader_buf: [4096]u8 = undefined; - var reader = file.reader(&reader_buf); - const contents = try reader.interface.readAlloc( + const contents = try file.readToEndAlloc( alloc, 1 * 1024 * 1024, // 1MB ); @@ -187,9 +185,7 @@ pub fn controllers(alloc: Allocator, cgroup: []const u8) ![]const u8 { // Read it all into memory -- we don't expect this file to ever // be that large. - var reader_buf: [4096]u8 = undefined; - var reader = file.reader(&reader_buf); - const contents = try reader.interface.readAlloc( + const contents = try file.readToEndAlloc( alloc, 1 * 1024 * 1024, // 1MB ); 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/hostname.zig b/src/os/hostname.zig index a75ca1cbb..f728a2455 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -1,157 +1,100 @@ const std = @import("std"); -const builtin = @import("builtin"); const posix = std.posix; -pub const HostnameParsingError = error{ - NoHostnameInUri, - NoSpaceLeft, -}; - -pub const UrlParsingError = std.Uri.ParseError || error{ - HostnameIsNotMacAddress, - NoSchemeProvided, -}; - -const mac_address_length = 17; - -fn isUriPathSeparator(c: u8) bool { - return switch (c) { - '?', '#' => true, - else => false, - }; -} - -fn isValidMacAddress(mac_address: []const u8) bool { - // A valid mac address has 6 two-character components with 5 colons, e.g. 12:34:56:ab:cd:ef. - if (mac_address.len != 17) { - return false; - } - - for (mac_address, 0..) |c, i| { - if ((i + 1) % 3 == 0) { - if (c != ':') { - return false; - } - } else if (!std.mem.containsAtLeastScalar(u8, "0123456789ABCDEFabcdef", 1, c)) { - return false; - } - } - - return true; -} - -/// Parses the provided url to a `std.Uri` struct. This is very specific to getting hostname and -/// path information for Ghostty's PWD reporting functionality. Takes into account that on macOS -/// the url passed to this function might have a mac address as its hostname and parses it -/// correctly. -pub fn parseUrl(url: []const u8) UrlParsingError!std.Uri { - return std.Uri.parse(url) catch |e| { - // The mac-address-as-hostname issue is specific to macOS so we just return an error if we - // hit it on other platforms. - if (comptime builtin.os.tag != .macos) return e; - - // It's possible this is a mac address on macOS where the last 2 characters in the - // address are non-digits, e.g. 'ff', and thus an invalid port. - // - // Example: file://12:34:56:78:90:12/path/to/file - if (e != error.InvalidPort) return e; - - const url_without_scheme_start = std.mem.indexOf(u8, url, "://") orelse { - return error.NoSchemeProvided; - }; - const scheme = url[0..url_without_scheme_start]; - const url_without_scheme = url[url_without_scheme_start + 3 ..]; - - // The first '/' after the scheme marks the end of the hostname. If the first '/' - // following the end of the scheme is not at the right position this is not a - // valid mac address. - if (url_without_scheme.len != mac_address_length and - std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != mac_address_length) - { - return error.HostnameIsNotMacAddress; - } - - // At this point we may have a mac address as the hostname. - const mac_address = url_without_scheme[0..mac_address_length]; - - if (!isValidMacAddress(mac_address)) { - return error.HostnameIsNotMacAddress; - } - - var uri_path_end_idx: usize = mac_address_length; - while (uri_path_end_idx < url_without_scheme.len and - !isUriPathSeparator(url_without_scheme[uri_path_end_idx])) - { - uri_path_end_idx += 1; - } - - // Same compliance factor as std.Uri.parse(), i.e. not at all compliant with the URI - // spec. - return .{ - .scheme = scheme, - .host = .{ .percent_encoded = mac_address }, - .path = .{ - .percent_encoded = url_without_scheme[mac_address_length..uri_path_end_idx], - }, - }; - }; -} - -/// Print the hostname from a file URI into a buffer. -pub fn bufPrintHostnameFromFileUri( - buf: []u8, - uri: std.Uri, -) HostnameParsingError![]const u8 { - // Get the raw string of the URI. Its unclear to me if the various - // tags of this enum guarantee no percent-encoding so we just - // check all of it. This isn't a performance critical path. - const host_component = uri.host orelse return error.NoHostnameInUri; - const host: []const u8 = switch (host_component) { - .raw => |v| v, - .percent_encoded => |v| v, - }; - - // When the "Private Wi-Fi address" setting is toggled on macOS the hostname - // is set to a random mac address, e.g. '12:34:56:78:90:ab'. - // The URI will be parsed as if the last set of digits is a port number, so - // we need to make sure that part is included when it's set. - - // We're only interested in special port handling when the current hostname is a - // partial MAC address that's potentially missing the last component. - // If that's not the case we just return the plain URI hostname directly. - // NOTE: This implementation is not sufficient to verify a valid mac address, but - // it's probably sufficient for this specific purpose. - if (host.len != 14 or std.mem.count(u8, host, ":") != 4) return host; - - // If we don't have a port then we can return the hostname as-is because - // it's not a partial MAC-address. - const port = uri.port orelse return host; - - // If the port is not a 1 or 2-digit number we're not looking at a partial - // MAC-address, and instead just a regular port so we return the plain - // URI hostname. - if (port > 99) return host; - - var fbs = std.io.fixedBufferStream(buf); - try std.fmt.format( - fbs.writer(), - // Make sure "port" is always 2 digits, prefixed with a 0 when "port" is a 1-digit number. - "{s}:{d:0>2}", - .{ host, port }, - ); - - return fbs.getWritten(); -} - pub const LocalHostnameValidationError = error{ PermissionDenied, Unexpected, }; +/// Validates a hostname according to [RFC 1123](https://www.rfc-editor.org/rfc/rfc1123) +/// +/// std.net.isValidHostname is (currently) too generous. It considers strings like +/// ".example.com", "exa..mple.com", and "-example.com" to be valid hostnames, which +/// is incorrect. +pub fn isValid(hostname: []const u8) bool { + if (hostname.len == 0) return false; + if (hostname[0] == '.') return false; + + // Ignore trailing dot (FQDN). It doesn't count toward our length. + const end = if (hostname[hostname.len - 1] == '.') end: { + if (hostname.len == 1) return false; + break :end hostname.len - 1; + } else hostname.len; + + if (end > 253) return false; + + // Hostnames are divided into dot-separated "labels", which: + // + // - Start with a letter or digit + // - Can contain letters, digits, or hyphens + // - Must end with a letter or digit + // - Have a minimum of 1 character and a maximum of 63 + var label_start: usize = 0; + var label_len: usize = 0; + for (hostname[0..end], 0..) |c, i| { + switch (c) { + '.' => { + if (label_len == 0 or label_len > 63) return false; + if (!std.ascii.isAlphanumeric(hostname[label_start])) return false; + if (!std.ascii.isAlphanumeric(hostname[i - 1])) return false; + + label_start = i + 1; + label_len = 0; + }, + '-' => { + label_len += 1; + }, + else => { + if (!std.ascii.isAlphanumeric(c)) return false; + label_len += 1; + }, + } + } + + // Validate the final label + if (label_len == 0 or label_len > 63) return false; + if (!std.ascii.isAlphanumeric(hostname[label_start])) return false; + if (!std.ascii.isAlphanumeric(hostname[end - 1])) return false; + + return true; +} + +test isValid { + const testing = std.testing; + + // Valid hostnames + try testing.expect(isValid("example")); + try testing.expect(isValid("example.com")); + try testing.expect(isValid("www.example.com")); + try testing.expect(isValid("sub.domain.example.com")); + try testing.expect(isValid("example.com.")); + try testing.expect(isValid("host-name.example.com.")); + try testing.expect(isValid("123.example.com.")); + try testing.expect(isValid("a-b.com")); + try testing.expect(isValid("a.b.c.d.e.f.g")); + try testing.expect(isValid("127.0.0.1")); // Also a valid hostname + try testing.expect(isValid("a" ** 63 ++ ".com")); // Label exactly 63 chars (valid) + try testing.expect(isValid("a." ** 126 ++ "a")); // Total length 253 (valid) + + // Invalid hostnames + try testing.expect(!isValid("")); + try testing.expect(!isValid(".example.com")); + try testing.expect(!isValid("example.com..")); + try testing.expect(!isValid("host..domain")); + try testing.expect(!isValid("-hostname")); + try testing.expect(!isValid("hostname-")); + try testing.expect(!isValid("a.-.b")); + try testing.expect(!isValid("host_name.com")); + try testing.expect(!isValid(".")); + try testing.expect(!isValid("..")); + try testing.expect(!isValid("a" ** 64 ++ ".com")); // Label length 64 (too long) + try testing.expect(!isValid("a." ** 126 ++ "ab")); // Total length 254 (too long) +} + /// Checks if a hostname is local to the current machine. This matches /// both "localhost" and the current hostname of the machine (as returned /// by `gethostname`). -pub fn isLocalHostname(hostname: []const u8) LocalHostnameValidationError!bool { +pub fn isLocal(hostname: []const u8) LocalHostnameValidationError!bool { // A 'localhost' hostname is always considered local. if (std.mem.eql(u8, "localhost", hostname)) return true; @@ -161,185 +104,19 @@ pub fn isLocalHostname(hostname: []const u8) LocalHostnameValidationError!bool { return std.mem.eql(u8, hostname, ourHostname); } -test parseUrl { - // 1. Typical hostnames. - - var uri = try parseUrl("file://personal.computer/home/test/"); - - try std.testing.expectEqualStrings("file", uri.scheme); - try std.testing.expectEqualStrings("personal.computer", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); - try std.testing.expect(uri.port == null); - - uri = try parseUrl("kitty-shell-cwd://personal.computer/home/test/"); - - try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); - try std.testing.expectEqualStrings("personal.computer", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); - try std.testing.expect(uri.port == null); - - // 2. Hostnames that are mac addresses. - - // Numerical mac addresses. - - uri = try parseUrl("file://12:34:56:78:90:12/home/test/"); - - try std.testing.expectEqualStrings("file", uri.scheme); - try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); - try std.testing.expect(uri.port == 12); - - uri = try parseUrl("kitty-shell-cwd://12:34:56:78:90:12/home/test/"); - - try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); - try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); - try std.testing.expect(uri.port == 12); - - // Alphabetical mac addresses. - - uri = try parseUrl("file://ab:cd:ef:ab:cd:ef/home/test/"); - - try std.testing.expectEqualStrings("file", uri.scheme); - try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); - try std.testing.expect(uri.port == null); - - uri = try parseUrl("kitty-shell-cwd://ab:cd:ef:ab:cd:ef/home/test/"); - - try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); - try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); - try std.testing.expect(uri.port == null); - - // 3. Hostnames that are mac addresses with no path. - - // Numerical mac addresses. - - uri = try parseUrl("file://12:34:56:78:90:12"); - - try std.testing.expectEqualStrings("file", uri.scheme); - try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("", uri.path.percent_encoded); - try std.testing.expect(uri.port == 12); - - uri = try parseUrl("kitty-shell-cwd://12:34:56:78:90:12"); - - try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); - try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("", uri.path.percent_encoded); - try std.testing.expect(uri.port == 12); - - // Alphabetical mac addresses. - - uri = try parseUrl("file://ab:cd:ef:ab:cd:ef"); - - try std.testing.expectEqualStrings("file", uri.scheme); - try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("", uri.path.percent_encoded); - try std.testing.expect(uri.port == null); - - uri = try parseUrl("kitty-shell-cwd://ab:cd:ef:ab:cd:ef"); - - try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); - try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("", uri.path.percent_encoded); - try std.testing.expect(uri.port == null); +test "isLocal returns true when provided hostname is localhost" { + try std.testing.expect(try isLocal("localhost")); } -test "parseUrl succeeds even if path component is missing" { - const uri = try parseUrl("file://12:34:56:78:90:ab"); - - try std.testing.expectEqualStrings("file", uri.scheme); - try std.testing.expectEqualStrings("12:34:56:78:90:ab", uri.host.?.percent_encoded); - try std.testing.expect(uri.path.isEmpty()); - try std.testing.expect(uri.port == null); -} - -test "bufPrintHostnameFromFileUri succeeds with ascii hostname" { - const uri = try std.Uri.parse("file://localhost/"); - - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const actual = try bufPrintHostnameFromFileUri(&buf, uri); - try std.testing.expectEqualStrings("localhost", actual); -} - -test "bufPrintHostnameFromFileUri succeeds with hostname as mac address" { - const uri = try std.Uri.parse("file://12:34:56:78:90:12"); - - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const actual = try bufPrintHostnameFromFileUri(&buf, uri); - try std.testing.expectEqualStrings("12:34:56:78:90:12", actual); -} - -test "bufPrintHostnameFromFileUri succeeds with hostname as mac address with the last component as ascii" { - const uri = try parseUrl("file://12:34:56:78:90:ab"); - - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const actual = try bufPrintHostnameFromFileUri(&buf, uri); - try std.testing.expectEqualStrings("12:34:56:78:90:ab", actual); -} - -test "bufPrintHostnameFromFileUri succeeds with hostname as a mac address and the last section is < 10" { - const uri = try std.Uri.parse("file://12:34:56:78:90:05"); - - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const actual = try bufPrintHostnameFromFileUri(&buf, uri); - try std.testing.expectEqualStrings("12:34:56:78:90:05", actual); -} - -test "bufPrintHostnameFromFileUri returns only hostname when there is a port component in the URI" { - // First: try with a non-2-digit port, to test general port handling. - const four_port_uri = try std.Uri.parse("file://has-a-port:1234"); - - var four_port_buf: [posix.HOST_NAME_MAX]u8 = undefined; - const four_port_actual = try bufPrintHostnameFromFileUri(&four_port_buf, four_port_uri); - try std.testing.expectEqualStrings("has-a-port", four_port_actual); - - // Second: try with a 2-digit port to test mac-address handling. - const two_port_uri = try std.Uri.parse("file://has-a-port:12"); - - var two_port_buf: [posix.HOST_NAME_MAX]u8 = undefined; - const two_port_actual = try bufPrintHostnameFromFileUri(&two_port_buf, two_port_uri); - try std.testing.expectEqualStrings("has-a-port", two_port_actual); - - // Third: try with a mac-address that has a port-component added to it to test mac-address handling. - const mac_with_port_uri = try std.Uri.parse("file://12:34:56:78:90:12:1234"); - - var mac_with_port_buf: [posix.HOST_NAME_MAX]u8 = undefined; - const mac_with_port_actual = try bufPrintHostnameFromFileUri(&mac_with_port_buf, mac_with_port_uri); - try std.testing.expectEqualStrings("12:34:56:78:90:12", mac_with_port_actual); -} - -test "bufPrintHostnameFromFileUri returns NoHostnameInUri error when hostname is missing from uri" { - const uri = try std.Uri.parse("file:///"); - - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const actual = bufPrintHostnameFromFileUri(&buf, uri); - try std.testing.expectError(HostnameParsingError.NoHostnameInUri, actual); -} - -test "bufPrintHostnameFromFileUri returns NoSpaceLeft error when provided buffer has insufficient size" { - const uri = try std.Uri.parse("file://12:34:56:78:90:12/"); - - var buf: [5]u8 = undefined; - const actual = bufPrintHostnameFromFileUri(&buf, uri); - try std.testing.expectError(HostnameParsingError.NoSpaceLeft, actual); -} - -test "isLocalHostname returns true when provided hostname is localhost" { - try std.testing.expect(try isLocalHostname("localhost")); -} - -test "isLocalHostname returns true when hostname is local" { +test "isLocal returns true when hostname is local" { var buf: [posix.HOST_NAME_MAX]u8 = undefined; const localHostname = try posix.gethostname(&buf); - try std.testing.expect(try isLocalHostname(localHostname)); + try std.testing.expect(try isLocal(localHostname)); } -test "isLocalHostname returns false when hostname is not local" { +test "isLocal returns false when hostname is not local" { try std.testing.expectEqual( false, - try isLocalHostname("not-the-local-hostname"), + try isLocal("not-the-local-hostname"), ); } diff --git a/src/os/i18n_locales.zig b/src/os/i18n_locales.zig index 0fca223b9..ac1673a94 100644 --- a/src/os/i18n_locales.zig +++ b/src/os/i18n_locales.zig @@ -52,4 +52,5 @@ pub const locales = [_][:0]const u8{ "he_IL.UTF-8", "zh_TW.UTF-8", "hr_HR.UTF-8", + "lt_LT.UTF-8", }; diff --git a/src/os/locale.zig b/src/os/locale.zig index b391d690f..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"); @@ -83,6 +83,11 @@ fn setLangFromCocoa() void { const lang = locale.getProperty(objc.Object, "languageCode"); const country = locale.getProperty(objc.Object, "countryCode"); + if (lang.value == null or country.value == null) { + log.warn("languageCode or countryCode not found. Locale may be incorrect.", .{}); + return; + } + // Get our UTF8 string values const c_lang = lang.getProperty([*:0]const u8, "UTF8String"); const c_country = country.getProperty([*:0]const u8, "UTF8String"); 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/main.zig b/src/os/main.zig index af851f673..c105f6143 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -29,6 +29,7 @@ pub const xdg = @import("xdg.zig"); pub const windows = @import("windows.zig"); pub const macos = @import("macos.zig"); pub const shell = @import("shell.zig"); +pub const uri = @import("uri.zig"); // Functions and types pub const CFReleaseThread = @import("cf_release_thread.zig"); @@ -67,6 +68,8 @@ pub const getKernelInfo = kernel_info.getKernelInfo; test { _ = i18n; _ = path; + _ = uri; + _ = shell; if (comptime builtin.os.tag == .linux) { _ = kernel_info; 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/open.zig b/src/os/open.zig index 9b069c80f..28d1c23ee 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -34,7 +34,7 @@ pub fn open( .macos => .init( switch (kind) { .text => &.{ "open", "-t", url }, - .unknown => &.{ "open", url }, + .html, .unknown => &.{ "open", url }, }, alloc, ), diff --git a/src/os/shell.zig b/src/os/shell.zig index a6f23e843..fe8f1b2fd 100644 --- a/src/os/shell.zig +++ b/src/os/shell.zig @@ -1,12 +1,87 @@ const std = @import("std"); const testing = std.testing; +const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; +/// Builder for constructing space-separated shell command strings. +/// Uses a caller-provided allocator (typically with stackFallback). +pub const ShellCommandBuilder = struct { + buffer: std.Io.Writer.Allocating, + + pub fn init(allocator: Allocator) ShellCommandBuilder { + return .{ .buffer = .init(allocator) }; + } + + pub fn deinit(self: *ShellCommandBuilder) void { + self.buffer.deinit(); + } + + /// Append an argument to the command with automatic space separation. + pub fn appendArg(self: *ShellCommandBuilder, arg: []const u8) (Allocator.Error || Writer.Error)!void { + if (arg.len == 0) return; + if (self.buffer.written().len > 0) { + try self.buffer.writer.writeByte(' '); + } + try self.buffer.writer.writeAll(arg); + } + + /// Get the final null-terminated command string, transferring ownership to caller. + /// Calling deinit() after this is safe but unnecessary. + pub fn toOwnedSlice(self: *ShellCommandBuilder) Allocator.Error![:0]const u8 { + return try self.buffer.toOwnedSliceSentinel(0); + } +}; + +test ShellCommandBuilder { + // Empty command + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try testing.expectEqualStrings("", cmd.buffer.written()); + } + + // Single arg + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try testing.expectEqualStrings("bash", cmd.buffer.written()); + } + + // Multiple args + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try cmd.appendArg("--posix"); + try cmd.appendArg("-l"); + try testing.expectEqualStrings("bash --posix -l", cmd.buffer.written()); + } + + // Empty arg + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try cmd.appendArg(""); + try testing.expectEqualStrings("bash", cmd.buffer.written()); + } + + // toOwnedSlice + { + var cmd = ShellCommandBuilder.init(testing.allocator); + try cmd.appendArg("bash"); + try cmd.appendArg("--posix"); + const result = try cmd.toOwnedSlice(); + defer testing.allocator.free(result); + try testing.expectEqualStrings("bash --posix", result); + try testing.expectEqual(@as(u8, 0), result[result.len]); + } +} + /// Writer that escapes characters that shells treat specially to reduce the /// risk of injection attacks or other such weirdness. Specifically excludes /// linefeeds so that they can be used to delineate lists of file paths. -/// -/// T should be a Zig type that follows the `std.Io.Writer` interface. pub const ShellEscapeWriter = struct { writer: Writer, child: *Writer, @@ -33,7 +108,7 @@ pub const ShellEscapeWriter = struct { var count: usize = 0; for (data[0 .. data.len - 1]) |chunk| try self.writeEscaped(chunk, &count); - for (0..splat) |_| try self.writeEscaped(data[data.len], &count); + for (0..splat) |_| try self.writeEscaped(data[data.len - 1], &count); return count; } @@ -67,7 +142,7 @@ pub const ShellEscapeWriter = struct { test "shell escape 1" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("abc"); try testing.expectEqualStrings("abc", writer.buffered()); } @@ -75,7 +150,7 @@ test "shell escape 1" { test "shell escape 2" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a c"); try testing.expectEqualStrings("a\\ c", writer.buffered()); } @@ -83,7 +158,7 @@ test "shell escape 2" { test "shell escape 3" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a?c"); try testing.expectEqualStrings("a\\?c", writer.buffered()); } @@ -91,7 +166,7 @@ test "shell escape 3" { test "shell escape 4" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a\\c"); try testing.expectEqualStrings("a\\\\c", writer.buffered()); } @@ -99,7 +174,7 @@ test "shell escape 4" { test "shell escape 5" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a|c"); try testing.expectEqualStrings("a\\|c", writer.buffered()); } @@ -107,7 +182,7 @@ test "shell escape 5" { test "shell escape 6" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a\"c"); try testing.expectEqualStrings("a\\\"c", writer.buffered()); } @@ -115,7 +190,7 @@ test "shell escape 6" { test "shell escape 7" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a(1)"); try testing.expectEqualStrings("a\\(1\\)", writer.buffered()); } diff --git a/src/os/string_encoding.zig b/src/os/string_encoding.zig new file mode 100644 index 000000000..042001ea7 --- /dev/null +++ b/src/os/string_encoding.zig @@ -0,0 +1,280 @@ +const std = @import("std"); + +/// Do an in-place decode of a string that has been encoded in the same way +/// that `bash`'s `printf %q` encodes a string. This is safe because a string +/// can only get shorter after decoding. This destructively modifies the buffer +/// given to it. If an error is returned the buffer may be in an unusable state. +pub fn printfQDecode(buf: [:0]u8) error{DecodeError}![:0]const u8 { + const data: [:0]u8 = data: { + // Strip off `$''` quoting. + if (std.mem.startsWith(u8, buf, "$'")) { + if (buf.len < 3 or !std.mem.endsWith(u8, buf, "'")) return error.DecodeError; + buf[buf.len - 1] = 0; + break :data buf[2 .. buf.len - 1 :0]; + } + // Strip off `''` quoting. + if (std.mem.startsWith(u8, buf, "'")) { + if (buf.len < 2 or !std.mem.endsWith(u8, buf, "'")) return error.DecodeError; + buf[buf.len - 1] = 0; + break :data buf[1 .. buf.len - 1 :0]; + } + break :data buf; + }; + + var src: usize = 0; + var dst: usize = 0; + + while (src < data.len) { + switch (data[src]) { + else => { + data[dst] = data[src]; + src += 1; + dst += 1; + }, + '\\' => { + if (src + 1 >= data.len) return error.DecodeError; + switch (data[src + 1]) { + ' ', + '\\', + '"', + '\'', + '$', + => |c| { + data[dst] = c; + src += 2; + dst += 1; + }, + 'e' => { + data[dst] = std.ascii.control_code.esc; + src += 2; + dst += 1; + }, + 'n' => { + data[dst] = std.ascii.control_code.lf; + src += 2; + dst += 1; + }, + 'r' => { + data[dst] = std.ascii.control_code.cr; + src += 2; + dst += 1; + }, + 't' => { + data[dst] = std.ascii.control_code.ht; + src += 2; + dst += 1; + }, + 'v' => { + data[dst] = std.ascii.control_code.vt; + src += 2; + dst += 1; + }, + else => return error.DecodeError, + } + }, + } + } + + data[dst] = 0; + return data[0..dst :0]; +} + +test "printf_q 1" { + const s: [:0]const u8 = "bobr\\ kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try printfQDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa", dst); +} + +test "printf_q 2" { + const s: [:0]const u8 = "bobr\\nkurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try printfQDecode(&src); + try std.testing.expectEqualStrings("bobr\nkurwa", dst); +} + +test "printf_q 3" { + const s: [:0]const u8 = "bobr\\dkurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 4" { + const s: [:0]const u8 = "bobr kurwa\\"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 5" { + const s: [:0]const u8 = "$'bobr kurwa'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try printfQDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa", dst); +} + +test "printf_q 6" { + const s: [:0]const u8 = "'bobr kurwa'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try printfQDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa", dst); +} + +test "printf_q 7" { + const s: [:0]const u8 = "$'bobr kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 8" { + const s: [:0]const u8 = "$'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 9" { + const s: [:0]const u8 = "'bobr kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 10" { + const s: [:0]const u8 = "'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +/// Do an in-place decode of a string that has been URL percent encoded. +/// This is safe because a string can only get shorter after decoding. This +/// destructively modifies the buffer given to it. If an error is returned the +/// buffer may be in an unusable state. +pub fn urlPercentDecode(buf: [:0]u8) error{DecodeError}![:0]const u8 { + var src: usize = 0; + var dst: usize = 0; + while (src < buf.len) { + switch (buf[src]) { + else => { + buf[dst] = buf[src]; + src += 1; + dst += 1; + }, + '%' => { + if (src + 2 >= buf.len) return error.DecodeError; + switch (buf[src + 1]) { + '0'...'9', 'a'...'f', 'A'...'F' => { + switch (buf[src + 2]) { + '0'...'9', 'a'...'f', 'A'...'F' => { + buf[dst] = std.math.shl(u8, hex(buf[src + 1]), 4) | hex(buf[src + 2]); + src += 3; + dst += 1; + }, + else => return error.DecodeError, + } + }, + else => return error.DecodeError, + } + }, + } + } + buf[dst] = 0; + return buf[0..dst :0]; +} + +inline fn hex(c: u8) u4 { + switch (c) { + '0'...'9' => return @truncate(c - '0'), + 'a'...'f' => return @truncate(c - 'a' + 10), + 'A'...'F' => return @truncate(c - 'A' + 10), + else => unreachable, + } +} + +test "singles percent" { + for (0..255) |c| { + var buf_: [4]u8 = undefined; + const buf = try std.fmt.bufPrintZ(&buf_, "%{x:0>2}", .{c}); + const decoded = try urlPercentDecode(buf); + try std.testing.expectEqual(1, decoded.len); + try std.testing.expectEqual(c, decoded[0]); + } + for (0..255) |c| { + var buf_: [4]u8 = undefined; + const buf = try std.fmt.bufPrintZ(&buf_, "%{X:0>2}", .{c}); + const decoded = try urlPercentDecode(buf); + try std.testing.expectEqual(1, decoded.len); + try std.testing.expectEqual(c, decoded[0]); + } +} + +test "percent 1" { + const s: [:0]const u8 = "bobr%20kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try urlPercentDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa", dst); +} + +test "percent 2" { + const s: [:0]const u8 = "bobr%2kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} + +test "percent 3" { + const s: [:0]const u8 = "bobr%kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} + +test "percent 4" { + const s: [:0]const u8 = "bobr%%kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} + +test "percent 5" { + const s: [:0]const u8 = "bobr%20kurwa%20"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try urlPercentDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa ", dst); +} + +test "percent 6" { + const s: [:0]const u8 = "bobr%20kurwa%2"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} + +test "percent 7" { + const s: [:0]const u8 = "bobr%20kurwa%"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} + +/// Is the given character valid in URI percent encoding? +fn isValidChar(c: u8) bool { + return switch (c) { + ' ', ';', '=' => false, + else => return std.ascii.isPrint(c), + }; +} + +/// Write data to the writer after URI percent encoding. +pub fn urlPercentEncode(writer: *std.Io.Writer, data: []const u8) std.Io.Writer.Error!void { + try std.Uri.Component.percentEncode(writer, data, isValidChar); +} diff --git a/src/os/uri.zig b/src/os/uri.zig new file mode 100644 index 000000000..3d674870c --- /dev/null +++ b/src/os/uri.zig @@ -0,0 +1,204 @@ +const std = @import("std"); + +pub const ParseOptions = struct { + /// Parse MAC addresses in the host component. + /// + /// This is useful when the "Private Wi-Fi address" is enabled on macOS, + /// which sets the hostname to a rotating MAC address (12:34:56:ab:cd:ef). + mac_address: bool = false, + + /// Return the full, raw, unencoded path string. Any query and fragment + /// values will be return as part of the path instead of as distinct + /// fields. + raw_path: bool = false, +}; + +pub const ParseError = std.Uri.ParseError || error{InvalidMacAddress}; + +/// Parses a URI from the given string. +/// +/// This extends std.Uri.parse with some additional ParseOptions. +pub fn parse(text: []const u8, options: ParseOptions) ParseError!std.Uri { + var uri = std.Uri.parse(text) catch |err| uri: { + // We can attempt to re-parse the text as a URI that has a MAC address + // in its host field (which tripped up std.Uri.parse's port parsing): + // + // file://12:34:56:78:90:aa/path/to/file + // ^^ InvalidPort + // + if (err != error.InvalidPort or !options.mac_address) return err; + + // We can assume that the initial Uri.parse already validated the + // scheme, so we only need to find its bounds within the string. + const scheme_end = std.mem.indexOf(u8, text, "://") orelse { + return error.InvalidFormat; + }; + const scheme = text[0..scheme_end]; + + // We similarly find the bounds of the host component by looking + // for the first slash (/) after the scheme. This is all we need + // for this case because the resulting slice can be unambiguously + // determined to be a MAC address (or not). + const host_start = scheme_end + "://".len; + const host_end = std.mem.indexOfScalarPos(u8, text, host_start, '/') orelse text.len; + const mac_address = text[host_start..host_end]; + if (!isValidMacAddress(mac_address)) return error.InvalidMacAddress; + + // Parse the rest of the text (starting with the path component) as a + // partial URI and then add our MAC address as its host component. + var uri = try std.Uri.parseAfterScheme(scheme, text[host_end..]); + uri.host = .{ .percent_encoded = mac_address }; + break :uri uri; + }; + + // When MAC address parsing is enabled, we need to handle the case where + // std.Uri.parse parsed the address's last octet as a numeric port number. + // We use a few heuristics to identify this case (14 characters, 4 colons) + // and then "repair" the result by reassign the .host component to the full + // MAC address and clearing the .port component. + // + // 12:34:56:78:90:99 -> [12:34:56:78:90, 99] -> 12:34:56:78:90:99 + // (original host) (parsed host + port) (restored host) + // + if (options.mac_address and uri.host != null) mac: { + const host = uri.host.?.percent_encoded; + if (host.len != 14 or std.mem.count(u8, host, ":") != 4) break :mac; + + const port = uri.port orelse break :mac; + if (port > 99) break :mac; + + // std.Uri.parse returns slices pointing into the original text string. + const host_start = @intFromPtr(host.ptr) - @intFromPtr(text.ptr); + const path_start = @intFromPtr(uri.path.percent_encoded.ptr) - @intFromPtr(text.ptr); + const mac_address = text[host_start..path_start]; + if (!isValidMacAddress(mac_address)) return error.InvalidMacAddress; + + uri.host = .{ .percent_encoded = mac_address }; + uri.port = null; + } + + // When the raw_path option is active, return everything after the authority + // (host) in the .path component, including any query and fragment values. + if (options.raw_path) { + // std.Uri.parse returns slices pointing into the original text string. + const path_start = @intFromPtr(uri.path.percent_encoded.ptr) - @intFromPtr(text.ptr); + uri.path = .{ .raw = text[path_start..] }; + uri.query = null; + uri.fragment = null; + } + + return uri; +} + +test "parse: mac_address" { + const testing = @import("std").testing; + + // Numeric MAC address without a port + const uri1 = try parse("file://00:12:34:56:78:90/path", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri1.scheme); + try testing.expectEqualStrings("00:12:34:56:78:90", uri1.host.?.percent_encoded); + try testing.expectEqualStrings("/path", uri1.path.percent_encoded); + try testing.expectEqual(null, uri1.port); + + // Numeric MAC address with a port + const uri2 = try parse("file://00:12:34:56:78:90:999/path", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri2.scheme); + try testing.expectEqualStrings("00:12:34:56:78:90", uri2.host.?.percent_encoded); + try testing.expectEqualStrings("/path", uri2.path.percent_encoded); + try testing.expectEqual(999, uri2.port); + + // Alphabetic MAC address without a port + const uri3 = try parse("file://ab:cd:ef:ab:cd:ef/path", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri3.scheme); + try testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri3.host.?.percent_encoded); + try testing.expectEqualStrings("/path", uri3.path.percent_encoded); + try testing.expectEqual(null, uri3.port); + + // Alphabetic MAC address with a port + const uri4 = try parse("file://ab:cd:ef:ab:cd:ef:999/path", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri4.scheme); + try testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri4.host.?.percent_encoded); + try testing.expectEqualStrings("/path", uri4.path.percent_encoded); + try testing.expectEqual(999, uri4.port); + + // Numeric MAC address without a path component + const uri5 = try parse("file://00:12:34:56:78:90", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri5.scheme); + try testing.expectEqualStrings("00:12:34:56:78:90", uri5.host.?.percent_encoded); + try testing.expect(uri5.path.isEmpty()); + + // Alphabetic MAC address without a path component + const uri6 = try parse("file://ab:cd:ef:ab:cd:ef", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri6.scheme); + try testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri6.host.?.percent_encoded); + try testing.expect(uri6.path.isEmpty()); + + // Invalid MAC addresses + try testing.expectError(error.InvalidMacAddress, parse( + "file://zz:zz:zz:zz:zz:00/path", + .{ .mac_address = true }, + )); + try testing.expectError(error.InvalidMacAddress, parse( + "file://zz:zz:zz:zz:zz:zz/path", + .{ .mac_address = true }, + )); +} + +test "parse: raw_path" { + const testing = @import("std").testing; + + const text = "file://localhost/path??#fragment"; + var buf: [256]u8 = undefined; + + const uri1 = try parse(text, .{ .raw_path = false }); + try testing.expectEqualStrings("file", uri1.scheme); + try testing.expectEqualStrings("localhost", uri1.host.?.percent_encoded); + try testing.expectEqualStrings("/path", try uri1.path.toRaw(&buf)); + try testing.expectEqualStrings("?", uri1.query.?.percent_encoded); + try testing.expectEqualStrings("fragment", uri1.fragment.?.percent_encoded); + + const uri2 = try parse(text, .{ .raw_path = true }); + try testing.expectEqualStrings("file", uri2.scheme); + try testing.expectEqualStrings("localhost", uri2.host.?.percent_encoded); + try testing.expectEqualStrings("/path??#fragment", try uri2.path.toRaw(&buf)); + try testing.expectEqual(null, uri2.query); + try testing.expectEqual(null, uri2.fragment); + + const uri3 = try parse("file://localhost", .{ .raw_path = true }); + try testing.expectEqualStrings("file", uri3.scheme); + try testing.expectEqualStrings("localhost", uri3.host.?.percent_encoded); + try testing.expect(uri3.path.isEmpty()); + try testing.expectEqual(null, uri3.query); + try testing.expectEqual(null, uri3.fragment); +} + +/// Checks if a string represents a valid MAC address, e.g. 12:34:56:ab:cd:ef. +fn isValidMacAddress(s: []const u8) bool { + if (s.len != 17) return false; + + for (s, 0..) |c, i| { + if (i % 3 == 2) { + if (c != ':') return false; + } else { + switch (c) { + '0'...'9', 'A'...'F', 'a'...'f' => {}, + else => return false, + } + } + } + + return true; +} + +test isValidMacAddress { + const testing = @import("std").testing; + + try testing.expect(isValidMacAddress("01:23:45:67:89:Aa")); + try testing.expect(isValidMacAddress("Aa:Bb:Cc:Dd:Ee:Ff")); + + try testing.expect(!isValidMacAddress("")); + try testing.expect(!isValidMacAddress("00:23:45")); + try testing.expect(!isValidMacAddress("00:23:45:Xx:Yy:Zz")); + try testing.expect(!isValidMacAddress("01-23-45-67-89-Aa")); + try testing.expect(!isValidMacAddress("01:23:45:67:89:Aa:Bb")); +} diff --git a/src/os/wasm.zig b/src/os/wasm.zig index 73a5922cf..3d0b90e9a 100644 --- a/src/os/wasm.zig +++ b/src/os/wasm.zig @@ -23,93 +23,3 @@ pub const alloc = if (builtin.is_test) std.testing.allocator else std.heap.wasm_allocator; - -/// For host-owned allocations: -/// We need to keep track of our own pointer lengths because Zig -/// allocators usually don't do this and we need to be able to send -/// a direct pointer back to the host system. A more appropriate thing -/// to do would be to probably make a custom allocator that keeps track -/// of size. -var allocs: std.AutoHashMapUnmanaged([*]u8, usize) = .{}; - -/// Allocate len bytes and return a pointer to the memory in the host. -/// The data is not zeroed. -pub export fn malloc(len: usize) ?[*]u8 { - return alloc_(len) catch return null; -} - -fn alloc_(len: usize) ![*]u8 { - // Create the allocation - const slice = try alloc.alloc(u8, len); - errdefer alloc.free(slice); - - // Store the size so we can deallocate later - try allocs.putNoClobber(alloc, slice.ptr, slice.len); - errdefer _ = allocs.remove(slice.ptr); - - return slice.ptr; -} - -/// Free an allocation from malloc. -pub export fn free(ptr: ?[*]u8) void { - if (ptr) |v| { - if (allocs.get(v)) |len| { - const slice = v[0..len]; - alloc.free(slice); - _ = allocs.remove(v); - } - } -} - -/// Convert an allocated pointer of any type to a host-owned pointer. -/// This pushes the responsibility to free it to the host. The returned -/// pointer will match the pointer but is typed correctly for returning -/// to the host. -pub fn toHostOwned(ptr: anytype) ![*]u8 { - // Convert our pointer to a byte array - const info = @typeInfo(@TypeOf(ptr)).pointer; - const T = info.child; - const size = @sizeOf(T); - const casted = @as([*]u8, @ptrFromInt(@intFromPtr(ptr))); - - // Store the information about it - try allocs.putNoClobber(alloc, casted, size); - errdefer _ = allocs.remove(casted); - - return casted; -} - -/// Returns true if the value is host owned. -pub fn isHostOwned(ptr: anytype) bool { - const casted = @as([*]u8, @ptrFromInt(@intFromPtr(ptr))); - return allocs.contains(casted); -} - -/// Convert a pointer back to a module-owned value. The caller is expected -/// to cast or have the valid pointer for alloc calls. -pub fn toModuleOwned(ptr: anytype) void { - const casted = @as([*]u8, @ptrFromInt(@intFromPtr(ptr))); - _ = allocs.remove(casted); -} - -test "basics" { - const testing = std.testing; - const buf = malloc(32).?; - try testing.expect(allocs.size == 1); - free(buf); - try testing.expect(allocs.size == 0); -} - -test "toHostOwned" { - const testing = std.testing; - - const Point = struct { x: u32 = 0, y: u32 = 0 }; - const p = try alloc.create(Point); - errdefer alloc.destroy(p); - const ptr = try toHostOwned(p); - try testing.expect(allocs.size == 1); - try testing.expect(isHostOwned(p)); - try testing.expect(isHostOwned(ptr)); - free(ptr); - try testing.expect(allocs.size == 0); -} diff --git a/src/os/wasm/log.zig b/src/os/wasm/log.zig index d81571229..faa885c6e 100644 --- a/src/os/wasm/log.zig +++ b/src/os/wasm/log.zig @@ -1,15 +1,11 @@ const std = @import("std"); -const builtin = @import("builtin"); const wasm = @import("../wasm.zig"); -const wasm_target = @import("target.zig"); // Use the correct implementation -pub const log = if (wasm_target.target) |target| switch (target) { - .browser => Browser.log, -} else @compileError("wasm target required"); +pub const log = Freestanding.log; -/// Browser implementation calls an extern "log" function. -pub const Browser = struct { +/// Freestanding implementation calls an extern "log" function. +pub const Freestanding = struct { // The function std.log will call. pub fn log( comptime level: std.log.Level, diff --git a/src/os/wasm/target.zig b/src/os/wasm/target.zig index cd8b2dd33..a6a29e208 100644 --- a/src/os/wasm/target.zig +++ b/src/os/wasm/target.zig @@ -10,7 +10,7 @@ pub const Target = enum { }; /// Our specific target platform. -pub const target: ?Target = if (!builtin.target.isWasm()) null else target: { +pub const target: ?Target = if (!builtin.target.cpu.arch.isWasm()) null else target: { const result = @as(Target, @enumFromInt(@intFromEnum(options.wasm_target))); // This maybe isn't necessary but I don't know if enums without a specific // tag type and value are guaranteed to be the same between build.zig 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..ecef74600 100644 --- a/src/quirks.zig +++ b/src/quirks.zig @@ -7,6 +7,7 @@ //! [1]: https://github.com/WebKit/WebKit/blob/main/Source/WebCore/page/Quirks.cpp const std = @import("std"); +const builtin = @import("builtin"); const font = @import("font/main.zig"); @@ -27,3 +28,30 @@ 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 const inlineAssert = switch (builtin.mode) { + // In debug builds we just use std.debug.assert because this + // fixes up stack traces. `inline` causes broken stack traces. This + // is probably a Zig compiler bug but until it is fixed we have to + // do this for development sanity. + .Debug => std.debug.assert, + + .ReleaseSmall, .ReleaseSafe, .ReleaseFast => (struct { + inline fn assert(ok: bool) void { + if (!ok) unreachable; + } + }).assert, +}; 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 210c2e337..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"); @@ -437,21 +436,6 @@ fn drainMailbox(self: *Thread) !void { grid.set.deref(grid.old_key); }, - .foreground_color => |color| { - self.renderer.foreground_color = color; - self.renderer.markDirty(); - }, - - .background_color => |color| { - self.renderer.background_color = color; - self.renderer.markDirty(); - }, - - .cursor_color => |color| { - self.renderer.cursor_color = color; - self.renderer.markDirty(); - }, - .resize => |v| self.renderer.setScreenSize(v), .change_config => |config| { @@ -466,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 8c0215673..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"); @@ -238,6 +238,7 @@ pub fn isCovering(cp: u21) bool { /// Returns true of the codepoint is a "symbol-like" character, which /// for now we define as anything in a private use area, and anything /// in several unicode blocks: +/// - Arrows /// - Dingbats /// - Emoticons /// - Miscellaneous Symbols @@ -254,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(); @@ -270,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; } @@ -291,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; @@ -523,108 +515,171 @@ test "Cell constraint widths" { const testing = std.testing; const alloc = testing.allocator; - var s = try terminal.Screen.init(alloc, 4, 1, 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 287b83450..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.screen.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.screen.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.screen.cursor.cursor_style = .bar; + 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" { @@ -92,19 +92,17 @@ test "cursor: blinking disabled" { var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); - term.screen.cursor.cursor_style = .bar; + 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" { @@ -113,20 +111,18 @@ test "cursor: explicitly not visible" { var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); - term.screen.cursor.cursor_style = .bar; + term.screens.active.cursor.cursor_style = .bar; 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 d66a32286..39eec7b43 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; @@ -114,41 +116,28 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// True if the window is focused focused: bool, - /// The foreground color set by an OSC 10 sequence. If unset then - /// default_foreground_color is used. - foreground_color: ?terminal.color.RGB, + /// The most recent scrollbar state. We use this as a cache to + /// determine if we need to notify the apprt that there was a + /// scrollbar change. + scrollbar: terminal.Scrollbar, + scrollbar_dirty: bool, - /// Foreground color set in the user's config file. - default_foreground_color: terminal.color.RGB, - - /// The background color set by an OSC 11 sequence. If unset then - /// default_background_color is used. - background_color: ?terminal.color.RGB, - - /// Background color set in the user's config file. - default_background_color: terminal.color.RGB, - - /// The cursor color set by an OSC 12 sequence. If unset then - /// default_cursor_color is used. - cursor_color: ?terminal.color.RGB, - - /// Default cursor color when no color is set explicitly by an OSC 12 command. - /// This is cursor color as set in the user's config, if any. If no cursor color - /// is set in the user's config, then the cursor color is determined by the - /// current foreground color. - default_cursor_color: ?configpkg.Config.TerminalColor, + /// 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. @@ -225,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. @@ -540,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, @@ -554,6 +561,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { vsync: bool, colorspace: configpkg.Config.WindowColorspace, blending: configpkg.Config.AlphaBlending, + background_blur: configpkg.Config.BackgroundBlur, pub fn init( alloc_gpa: Allocator, @@ -611,6 +619,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, @@ -622,6 +634,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .vsync = config.@"window-vsync", .colorspace = config.@"window-colorspace", .blending = config.@"alpha-blending", + .background_blur = config.@"background-blur", .arena = arena, }; } @@ -683,12 +696,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .grid_metrics = font_critical.metrics, .size = options.size, .focused = true, - .foreground_color = null, - .default_foreground_color = options.config.foreground, - .background_color = null, - .default_background_color = options.config.background, - .cursor_color = null, - .default_cursor_color = options.config.cursor_color, + .scrollbar = .zero, + .scrollbar_dirty = false, + .search_matches = null, + .search_selected_match = null, + .search_matches_dirty = false, // Render state .cells = .{}, @@ -706,6 +718,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { options.config.background.r, options.config.background.g, options.config.background.b, + // Note that if we're on macOS with glass effects + // we'll disable background opacity but we handle + // that in updateFrame. @intFromFloat(@round(options.config.background_opacity * 255.0)), }, .bools = .{ @@ -760,6 +775,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) { @@ -957,8 +975,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. @@ -1060,6 +1078,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Update relevant uniforms self.updateFontGridUniforms(); + + // 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.markDirty(); } /// Update uniforms that are based on the font grid. @@ -1078,18 +1101,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, - screen: terminal.Screen, - screen_type: terminal.ScreenType, + links: terminal.RenderState.CellSet, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, - cursor_style: ?renderer.CursorStyle, - color_palette: terminal.color.Palette, - - /// If true, rebuild the full screen. - full_rebuild: bool, + scrollbar: terminal.Scrollbar, }; // Update all our data as tightly as possible within the mutex. @@ -1098,8 +1132,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(); @@ -1111,67 +1144,27 @@ pub fn Renderer(comptime GraphicsAPI: type) type { return; } - // Swap bg/fg if the terminal is reversed - const bg = self.background_color orelse self.default_background_color; - const fg = self.foreground_color orelse self.default_foreground_color; - defer { - if (self.background_color) |*c| { - c.* = bg; - } else { - self.default_background_color = bg; - } + // Update our terminal state + try self.terminal_state.update(self.alloc, state.terminal); - if (self.foreground_color) |*c| { - c.* = fg; - } else { - self.default_foreground_color = fg; - } + // 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; } - if (state.terminal.modes.get(.reverse_colors)) { - if (self.background_color) |*c| { - c.* = fg; - } else { - self.default_background_color = fg; - } - - if (self.foreground_color) |*c| { - c.* = bg; - } else { - self.default_foreground_color = bg; - } - } - - // Get the viewport pin so that we can compare it to the current. - const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?; - - // We used to share terminal state, but we've since learned through - // analysis that it is faster to copy the terminal state than to - // hold the lock while rebuilding GPU cells. - var screen_copy = try state.terminal.screen.clone( - self.alloc, - .{ .viewport = .{} }, - null, - ); - errdefer screen_copy.deinit(); - - // Whether to draw our cursor or not. - const cursor_style = if (state.terminal.flags.password_input) - .lock - else - renderer.cursorStyle( - state, - self.focused, - cursor_blink_visible, - ); + // Get our 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 + // can be expensive) and also makes it so we don't need another + // cross-thread mailbox message within the IO path. + const scrollbar = state.terminal.screens.active.pages.scrollbar(); // 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 @@ -1180,81 +1173,107 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // If we have any virtual references, we must also rebuild our // kitty state on every frame because any cell change can move // an image. - if (state.terminal.screen.kitty_images.dirty or + if (state.terminal.screens.active.kitty_images.dirty or self.image_virtual) { try self.prepKittyGraphics(state.terminal); } - // If we have any terminal dirty flags set then we need to rebuild - // the entire screen. This can be optimized in the future. - const full_rebuild: bool = rebuild: { - { - const Int = @typeInfo(terminal.Terminal.Dirty).@"struct".backing_integer.?; - const v: Int = @bitCast(state.terminal.flags.dirty); - if (v > 0) break :rebuild true; - } - { - const Int = @typeInfo(terminal.Screen.Dirty).@"struct".backing_integer.?; - const v: Int = @bitCast(state.terminal.screen.dirty); - if (v > 0) break :rebuild true; - } + // 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.screen.dirty = .{}; - { - var it = state.terminal.screen.pages.pageIterator( - .right_down, - .{ .screen = .{} }, - null, - ); - while (it.next()) |chunk| { - var dirty_set = chunk.node.data.dirtyBitSet(); - dirty_set.unsetAll(); - } - } - - // Update our viewport pin - self.cells_viewport = viewport_pin; - break :critical .{ - .bg = self.background_color orelse self.default_background_color, - .screen = screen_copy, - .screen_type = state.terminal.active_screen, + .links = links, .mouse = state.mouse, .preedit = preedit, - .cursor_style = cursor_style, - .color_palette = state.terminal.color_palette.colors, - .full_rebuild = full_rebuild, + .scrollbar = scrollbar, }; }; - 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, + 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, @@ -1266,13 +1285,32 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.draw_mutex.lock(); defer self.draw_mutex.unlock(); + // The scrollbar is only emitted during draws so we also + // check the scrollbar cache here and update if needed. + // This is pretty fast. + if (!self.scrollbar.eql(critical.scrollbar)) { + self.scrollbar = critical.scrollbar; + self.scrollbar_dirty = true; + } + // 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)), }; + + // If we're on macOS and have glass styles, we remove + // the background opacity because the glass effect handles + // it. + if (comptime builtin.os.tag == .macos) switch (self.config.background_blur) { + .@"macos-glass-regular", + .@"macos-glass-clear", + => self.uniforms.bg_color[3] = 0, + + else => {}, + }; } } @@ -1289,6 +1327,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.draw_mutex.lock(); defer self.draw_mutex.unlock(); + // After the graphics API is complete (so we defer) we want to + // update our scrollbar state. + defer if (self.scrollbar_dirty) { + // Fail instantly if the surface mailbox if full, we'll just + // get it on the next frame. + if (self.surface_mailbox.push(.{ + .scrollbar = self.scrollbar, + }, .instant) > 0) self.scrollbar_dirty = false; + }; + // Let our graphics API do any bookkeeping, etc. // that it needs to do before / after `drawFrame`. self.api.drawFrameStart(); @@ -1636,7 +1684,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.draw_mutex.lock(); defer self.draw_mutex.unlock(); - const storage = &t.screen.kitty_images; + const storage = &t.screens.active.kitty_images; defer storage.dirty = false; // We always clear our previous placements no matter what because @@ -1660,10 +1708,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // The top-left and bottom-right corners of our viewport in screen // points. This lets us determine offsets and containment of placements. - const top = t.screen.pages.getTopLeft(.viewport); - const bot = t.screen.pages.getBottomRight(.viewport).?; - const top_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; - const bot_y = t.screen.pages.pointFromPin(.screen, bot).?.screen.y; + const top = t.screens.active.pages.getTopLeft(.viewport); + const bot = t.screens.active.pages.getBottomRight(.viewport).?; + const top_y = t.screens.active.pages.pointFromPin(.screen, top).?.screen.y; + const bot_y = t.screens.active.pages.pointFromPin(.screen, bot).?.screen.y; // Go through the placements and ensure the image is // on the GPU or else is ready to be sent to the GPU. @@ -1754,7 +1802,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { t: *terminal.Terminal, p: *const terminal.kitty.graphics.unicode.Placement, ) !void { - const storage = &t.screen.kitty_images; + const storage = &t.screens.active.kitty_images; const image = storage.imageById(p.image_id) orelse { log.warn( "missing image for virtual placement, ignoring image_id={}", @@ -1776,7 +1824,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // If our placement is zero sized then we don't do anything. if (rp.dest_width == 0 or rp.dest_height == 0) return; - const viewport: terminal.point.Point = t.screen.pages.pointFromPin( + const viewport: terminal.point.Point = t.screens.active.pages.pointFromPin( .viewport, rp.top_left, ) orelse { @@ -1820,8 +1868,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const rect = p.rect(image.*, t) orelse return; // This is expensive but necessary. - const img_top_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; - const img_bot_y = t.screen.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; + const img_top_y = t.screens.active.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + const img_bot_y = t.screens.active.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; // If the selection isn't within our viewport then skip it. if (img_top_y > bot_y) return; @@ -2064,11 +2112,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.uniforms.bools.use_linear_blending = config.blending.isLinear(); self.uniforms.bools.use_linear_correction = config.blending == .@"linear-corrected"; - // Set our new colors - self.default_background_color = config.background; - self.default_foreground_color = config.foreground; - self.default_cursor_color = config.cursor_color; - const bg_image_config_changed = self.config.bg_image_fit != config.bg_image_fit or self.config.bg_image_position != config.bg_image_position or @@ -2097,7 +2140,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; @@ -2323,14 +2366,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.ScreenType, - mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, - color_palette: *const terminal.color.Palette, + 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(); @@ -2342,21 +2384,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 { @@ -2364,22 +2391,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 @@ -2387,8 +2423,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(); @@ -2410,45 +2445,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. @@ -2461,14 +2500,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, - self.background_color orelse self.default_background_color, + 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, - self.background_color orelse self.default_background_color, + self.uniforms.padding_extend.down = !rowNeverExtendBg( + row, + cells_raw, + cells_style, + &state.colors.palette, + state.colors.background, ); }, } @@ -2476,10 +2521,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); @@ -2487,13 +2538,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: { @@ -2526,7 +2575,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, @@ -2534,7 +2583,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={}", @@ -2545,75 +2594,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 = self.foreground_color orelse self.default_foreground_color, - .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 self.foreground_color orelse self.default_foreground_color; - } + .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 @@ -2625,37 +2699,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 - self.background_color orelse - self.default_background_color; + 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 self.background_color orelse self.default_background_color; - } + .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. @@ -2663,7 +2742,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Set the cell's background color. { - const rgb = bg orelse self.background_color orelse self.default_background_color; + 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 @@ -2673,7 +2752,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; @@ -2714,13 +2793,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 @@ -2729,7 +2813,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( @@ -2760,7 +2844,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, @@ -2768,7 +2852,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={}", @@ -2779,32 +2863,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, @@ -2834,65 +2920,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 (self.cursor_color) |v| break :cursor_color v; + if (state.colors.cursor) |v| break :cursor_color v; // Use our configured color if specified - if (self.default_cursor_color) |v| switch (v) { + 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 = self.foreground_color orelse self.default_foreground_color, - .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 self.background_color orelse self.default_background_color; + 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 self.foreground_color orelse self.default_foreground_color; + 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) { @@ -2908,21 +3020,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 = self.foreground_color orelse self.default_foreground_color, - .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 self.background_color orelse self.default_background_color; + 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 self.background_color orelse self.default_background_color; + } else state.colors.background; self.uniforms.cursor_color = .{ uniform_color.r, @@ -2938,7 +3058,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const range = preedit_range.?; var x = range.x[0]; for (preedit_v.codepoints[range.cp_offset..]) |cp| { - self.addPreeditCell(cp, .{ .x = x, .y = range.y }) catch |err| { + self.addPreeditCell( + cp, + .{ .x = x, .y = range.y }, + state.colors.background, + state.colors.foreground, + ) catch |err| { log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{ x, range.y, @@ -3067,15 +3192,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 @@ -3095,7 +3219,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, + ), }, ); @@ -3124,22 +3252,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: { @@ -3198,7 +3328,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 }, @@ -3213,10 +3343,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, cp: renderer.State.Preedit.Codepoint, coord: terminal.Coordinate, + screen_bg: terminal.color.RGB, + screen_fg: terminal.color.RGB, ) !void { // Preedit is rendered inverted - const bg = self.foreground_color orelse self.default_foreground_color; - const fg = self.background_color orelse self.default_background_color; + const bg = screen_fg; + const fg = screen_bg; // Render the glyph for our preedit text const render_ = self.font_grid.renderCodepoint( 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 9f489ed48..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, 5, 5, 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, 5, 5, 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, 5, 5, 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 = &t.screen; - - try t.printString("ABC"); - try t.screen.startHyperlink("http://example.com", null); - try t.printString("123"); - t.screen.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.screen, - .{ .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.screen, - .{ .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 d6255661f..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. @@ -42,16 +42,6 @@ pub const Message = union(enum) { old_key: font.SharedGridSet.Key, }, - /// Change the foreground color as set by an OSC 10 command, if any. - foreground_color: ?terminal.color.RGB, - - /// Change the background color as set by an OSC 11 command, if any. - background_color: ?terminal.color.RGB, - - /// Change the cursor color. This can be done separately from changing the - /// config file in response to an OSC 12 command. - cursor_color: ?terminal.color.RGB, - /// Changes the size. The screen size might change, padding, grid, etc. resize: renderer.Size, @@ -62,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 d31c36dee..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"); @@ -80,9 +79,7 @@ pub fn loadFromFile( const file = try cwd.openFile(path, .{}); defer file.close(); - var buf: [4096]u8 = undefined; - var reader = file.reader(&buf); - break :src try reader.interface.readAlloc( + break :src try file.readToEndAlloc( alloc, 4 * 1024 * 1024, // 4MB ); diff --git a/src/renderer/size.zig b/src/renderer/size.zig index b26c1581e..d8b529c26 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -44,6 +44,15 @@ pub const Size = struct { self.grid(), self.cell, ); + + // The top/bottom padding is interesting. Subjectively, lots of padding + // at the top looks bad. So instead of always being equal (like left/right), + // we force the top padding to be at most equal to the maximum left padding, + // which is the balanced explicit horizontal padding plus half a cell width. + const max_padding_left = (explicit.left + explicit.right + self.cell.width) / 2; + const vshift = self.padding.top -| max_padding_left; + self.padding.top -= vshift; + self.padding.bottom += vshift; } }; @@ -258,16 +267,12 @@ pub const Padding = struct { const space_right = @as(f32, @floatFromInt(screen.width)) - grid_width; const space_bot = @as(f32, @floatFromInt(screen.height)) - grid_height; - // The left/right padding is just an equal split. + // The padding is split equally along both axes. const padding_right = @floor(space_right / 2); const padding_left = padding_right; - // The top/bottom padding is interesting. Subjectively, lots of padding - // at the top looks bad. So instead of always being equal (like left/right), - // we force the top padding to be at most equal to the left, and the bottom - // padding is the difference thereafter. - const padding_top = @min(padding_left, @floor(space_bot / 2)); - const padding_bot = space_bot - padding_top; + const padding_bot = @floor(space_bot / 2); + const padding_top = padding_bot; const zero = @as(f32, 0); return .{ diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 1fd11091d..9c422ef26 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -78,13 +78,21 @@ on the Fish startup process, see the ### Zsh -For `zsh`, Ghostty sets `ZDOTDIR` so that it loads our configuration -from the `zsh` directory. The existing `ZDOTDIR` is retained so that -after loading the Ghostty shell integration the normal Zsh loading -sequence occurs. +Automatic [Zsh](https://www.zsh.org/) integration works by temporarily setting +`ZDOTDIR` to our `zsh` directory. An existing `ZDOTDIR` environment variable +value will be retained and restored after our shell integration scripts are +run. -```bash +However, if `ZDOTDIR` is set in a system-wide file like `/etc/zshenv`, it will +override Ghostty's `ZDOTDIR` value, preventing the shell integration from being +loaded. In this case, the shell integration needs to be loaded manually. + +To load the Zsh shell integration manually: + +```zsh if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration fi ``` + +Shell integration requires Zsh 5.1+. diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index e910a9885..799d0cff6 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -103,7 +103,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then builtin command sudo "$@"; else - builtin command sudo TERMINFO="$TERMINFO" "$@"; + builtin command sudo --preserve-env=TERMINFO "$@"; fi } fi diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 33473c8b0..e4b449ae5 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -97,7 +97,7 @@ if (not (has-value $arg =)) { break } } - if (not $sudoedit) { set args = [ TERMINFO=$E:TERMINFO $@args ] } + if (not $sudoedit) { set args = [ --preserve-env=TERMINFO $@args ] } (external sudo) $@args } diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index 7042f892a..580e27f45 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -54,16 +54,20 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" if contains cursor $features # Change the cursor to a beam on prompt. function __ghostty_set_cursor_beam --on-event fish_prompt -d "Set cursor shape" - echo -en "\e[5 q" + if not functions -q fish_vi_cursor_handle + echo -en "\e[5 q" + end end function __ghostty_reset_cursor --on-event fish_preexec -d "Reset cursor shape" - echo -en "\e[0 q" + if not functions -q fish_vi_cursor_handle + echo -en "\e[0 q" + end end end # Add Ghostty binary to PATH if the path feature is enabled if contains path $features; and test -n "$GHOSTTY_BIN_DIR" - fish_add_path --append "$GHOSTTY_BIN_DIR" + fish_add_path --global --path --append "$GHOSTTY_BIN_DIR" end # When using sudo shell integration feature, ensure $TERMINFO is set @@ -86,7 +90,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" if test "$sudo_has_sudoedit_flags" = "yes" command sudo $argv else - command sudo TERMINFO="$TERMINFO" $argv + command sudo --preserve-env=TERMINFO $argv end end end 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..febf3e59c 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. @@ -84,9 +93,6 @@ _entrypoint() { _ghostty_deferred_init() { builtin emulate -L zsh -o no_warn_create_global -o no_aliases - # The directory where ghostty-integration is located: /../shell-integration/zsh. - builtin local self_dir="${functions_source[_ghostty_deferred_init]:A:h}" - # Enable semantic markup with OSC 133. _ghostty_precmd() { builtin local -i cmd_status=$? @@ -246,7 +252,7 @@ _ghostty_deferred_init() { if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then builtin command sudo "$@"; else - builtin command sudo TERMINFO="$TERMINFO" "$@"; + builtin command sudo --preserve-env=TERMINFO "$@"; fi } fi 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/synthetic/Bytes.zig b/src/synthetic/Bytes.zig index 8a8207ba9..7d4c34a33 100644 --- a/src/synthetic/Bytes.zig +++ b/src/synthetic/Bytes.zig @@ -1,4 +1,4 @@ -/// Generates bytes. +//! Generates bytes. const Bytes = @This(); const std = @import("std"); @@ -7,9 +7,7 @@ const Generator = @import("Generator.zig"); /// Random number generator. rand: std.Random, -/// The minimum and maximum length of the generated bytes. The maximum -/// length will be capped to the length of the buffer passed in if the -/// buffer length is smaller. +/// The minimum and maximum length of the generated bytes. min_len: usize = 1, max_len: usize = std.math.maxInt(usize), @@ -18,36 +16,104 @@ max_len: usize = std.math.maxInt(usize), /// side effect of the generator, not an intended use case. alphabet: ?[]const u8 = null, -/// Predefined alphabets. -pub const Alphabet = struct { - pub const ascii = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;':\\\",./<>?`~"; -}; +/// Generate an alphabet given a function that returns true/false for a +/// given byte. +pub fn generateAlphabet(comptime func: fn (u8) bool) []const u8 { + @setEvalBranchQuota(3000); + var count = 0; + for (0..256) |c| { + if (func(c)) count += 1; + } + var alphabet: [count]u8 = undefined; + var i = 0; + for (0..256) |c| { + if (func(c)) { + alphabet[i] = c; + i += 1; + } + } + const result = alphabet; + return &result; +} pub fn generator(self: *Bytes) Generator { return .init(self, next); } -pub fn next(self: *Bytes, buf: []u8) Generator.Error![]const u8 { - const len = @min( - self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len), - buf.len, - ); +/// Return a copy of the Bytes, but with a new alphabet. +pub fn newAlphabet(self: *const Bytes, new_alphabet: ?[]const u8) Bytes { + return .{ + .rand = self.rand, + .alphabet = new_alphabet, + .min_len = self.min_len, + .max_len = self.max_len, + }; +} - const result = buf[0..len]; - self.rand.bytes(result); - if (self.alphabet) |alphabet| { - for (result) |*byte| byte.* = alphabet[byte.* % alphabet.len]; +/// Return a copy of the Bytes, but with a new min_len. The new min +/// len cannot be more than the previous max_len. +pub fn atLeast(self: *const Bytes, new_min_len: usize) Bytes { + return .{ + .rand = self.rand, + .alphabet = self.alphabet, + .min_len = @min(self.max_len, new_min_len), + .max_len = self.max_len, + }; +} + +/// Return a copy of the Bytes, but with a new max_len. The new max_len cannot +/// be more the previous max_len. +pub fn atMost(self: *const Bytes, new_max_len: usize) Bytes { + return .{ + .rand = self.rand, + .alphabet = self.alphabet, + .min_len = @min(self.min_len, @min(self.max_len, new_max_len)), + .max_len = @min(self.max_len, new_max_len), + }; +} + +pub fn next(self: *const Bytes, writer: *std.Io.Writer, max_len: usize) std.Io.Writer.Error!void { + _ = try self.atMost(max_len).write(writer); +} + +pub fn format(self: *const Bytes, writer: *std.Io.Writer) std.Io.Writer.Error!void { + _ = try self.write(writer); +} + +/// Write some random data and return the number of bytes written. +pub fn write(self: *const Bytes, writer: *std.Io.Writer) std.Io.Writer.Error!usize { + std.debug.assert(self.min_len >= 1); + std.debug.assert(self.max_len >= self.min_len); + + const len = self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len); + + var buf: [8]u8 = undefined; + + var remaining = len; + while (remaining > 0) { + const data = buf[0..@min(remaining, buf.len)]; + self.rand.bytes(data); + if (self.alphabet) |alphabet| { + for (data) |*byte| byte.* = alphabet[byte.* % alphabet.len]; + } + try writer.writeAll(data); + remaining -= data.len; } - return result; + return len; } test "bytes" { const testing = std.testing; var prng = std.Random.DefaultPrng.init(0); var buf: [256]u8 = undefined; - var v: Bytes = .{ .rand = prng.random() }; + var writer: std.Io.Writer = .fixed(&buf); + var v: Bytes = .{ + .rand = prng.random(), + .min_len = buf.len, + .max_len = buf.len, + }; const gen = v.generator(); - const result = try gen.next(&buf); - try testing.expect(result.len > 0); + try gen.next(&writer, buf.len); + try testing.expectEqual(buf.len, writer.buffered().len); } diff --git a/src/synthetic/Generator.zig b/src/synthetic/Generator.zig index 7478a54c3..28929ecbe 100644 --- a/src/synthetic/Generator.zig +++ b/src/synthetic/Generator.zig @@ -6,27 +6,27 @@ const assert = std.debug.assert; /// For generators, this is the only error that is allowed to be /// returned by the next function. -pub const Error = error{NoSpaceLeft}; +pub const Error = error{WriteFailed}; /// The vtable for the generator. ptr: *anyopaque, -nextFn: *const fn (ptr: *anyopaque, buf: []u8) Error![]const u8, +nextFn: *const fn (ptr: *anyopaque, *std.Io.Writer, usize) Error!void, /// Create a new generator from a pointer and a function pointer. /// This usually is only called by generator implementations, not /// generator users. pub fn init( pointer: anytype, - comptime nextFn: fn (ptr: @TypeOf(pointer), buf: []u8) Error![]const u8, + comptime nextFn: fn (ptr: @TypeOf(pointer), *std.Io.Writer, usize) Error!void, ) Generator { const Ptr = @TypeOf(pointer); assert(@typeInfo(Ptr) == .pointer); // Must be a pointer assert(@typeInfo(Ptr).pointer.size == .one); // Must be a single-item pointer assert(@typeInfo(@typeInfo(Ptr).pointer.child) == .@"struct"); // Must point to a struct const gen = struct { - fn next(ptr: *anyopaque, buf: []u8) Error![]const u8 { + fn next(ptr: *anyopaque, writer: *std.Io.Writer, max_len: usize) Error!void { const self: Ptr = @ptrCast(@alignCast(ptr)); - return try nextFn(self, buf); + try nextFn(self, writer, max_len); } }; @@ -37,6 +37,6 @@ pub fn init( } /// Get the next value from the generator. Returns the data written. -pub fn next(self: Generator, buf: []u8) Error![]const u8 { - return try self.nextFn(self.ptr, buf); +pub fn next(self: Generator, writer: *std.Io.Writer, max_size: usize) Error!void { + try self.nextFn(self.ptr, writer, max_size); } diff --git a/src/synthetic/Osc.zig b/src/synthetic/Osc.zig index 8d5d7d3a2..00de43f7f 100644 --- a/src/synthetic/Osc.zig +++ b/src/synthetic/Osc.zig @@ -5,12 +5,23 @@ const std = @import("std"); const assert = std.debug.assert; const Generator = @import("Generator.zig"); const Bytes = @import("Bytes.zig"); +const urlPercentEncode = @import("../os/string_encoding.zig").urlPercentEncode; /// Valid OSC request kinds that can be generated. pub const ValidKind = enum { change_window_title, prompt_start, prompt_end, + end_of_input, + end_of_command, + rxvt_notify, + mouse_shape, + clipboard_operation, + report_pwd, + hyperlink_start, + hyperlink_end, + conemu_progress, + iterm2_notification, }; /// Invalid OSC request kinds that can be generated. @@ -35,24 +46,37 @@ p_valid: f64 = 1.0, p_valid_kind: std.enums.EnumArray(ValidKind, f64) = .initFill(1.0), p_invalid_kind: std.enums.EnumArray(InvalidKind, f64) = .initFill(1.0), -/// The alphabet for random bytes (omitting 0x1B and 0x07). -const bytes_alphabet: []const u8 = alphabet: { - var alphabet: [256]u8 = undefined; - for (0..alphabet.len) |i| { - if (i == 0x1B or i == 0x07) { - alphabet[i] = @intCast(i + 1); - } else { - alphabet[i] = @intCast(i); - } - } - const result = alphabet; - break :alphabet &result; -}; +fn checkKvAlphabet(c: u8) bool { + return switch (c) { + std.ascii.control_code.esc, std.ascii.control_code.bel, ';', '=' => false, + else => std.ascii.isPrint(c), + }; +} + +/// The alphabet for random bytes in OSC key/value pairs (omitting 0x1B, +/// 0x07, ';', '='). +pub const kv_alphabet = Bytes.generateAlphabet(checkKvAlphabet); + +fn checkOscAlphabet(c: u8) bool { + return switch (c) { + std.ascii.control_code.esc, std.ascii.control_code.bel => false, + else => true, + }; +} + +/// The alphabet for random bytes in OSCs (omitting 0x1B and 0x07). +pub const osc_alphabet = Bytes.generateAlphabet(checkOscAlphabet); +pub const ascii_alphabet = Bytes.generateAlphabet(std.ascii.isPrint); +pub const alphabetic_alphabet = Bytes.generateAlphabet(std.ascii.isAlphabetic); +pub const alphanumeric_alphabet = Bytes.generateAlphabet(std.ascii.isAlphanumeric); pub fn generator(self: *Osc) Generator { return .init(self, next); } +const osc = std.fmt.comptimePrint("{c}]", .{std.ascii.control_code.esc}); +const st = std.fmt.comptimePrint("{c}", .{std.ascii.control_code.bel}); + /// Get the next OSC request in bytes. The generated OSC request will /// have the prefix `ESC ]` and the terminator `BEL` (0x07). /// @@ -63,23 +87,22 @@ pub fn generator(self: *Osc) Generator { /// /// The buffer must be at least 3 bytes long to accommodate the /// prefix and terminator. -pub fn next(self: *Osc, buf: []u8) Generator.Error![]const u8 { - if (buf.len < 3) return error.NoSpaceLeft; - const unwrapped = try self.nextUnwrapped(buf[2 .. buf.len - 1]); - buf[0] = 0x1B; // ESC - buf[1] = ']'; - buf[unwrapped.len + 2] = 0x07; // BEL - return buf[0 .. unwrapped.len + 3]; +pub fn next(self: *Osc, writer: *std.Io.Writer, max_len: usize) Generator.Error!void { + assert(max_len >= 3); + try writer.writeAll(osc); + try self.nextUnwrapped(writer, max_len - (osc.len + st.len)); + try writer.writeAll(st); } -fn nextUnwrapped(self: *Osc, buf: []u8) Generator.Error![]const u8 { +fn nextUnwrapped(self: *Osc, writer: *std.Io.Writer, max_len: usize) Generator.Error!void { return switch (self.chooseValidity()) { .valid => valid: { const Indexer = @TypeOf(self.p_valid_kind).Indexer; const idx = self.rand.weightedIndex(f64, &self.p_valid_kind.values); break :valid try self.nextUnwrappedValidExact( - buf, + writer, Indexer.keyForIndex(idx), + max_len, ); }, @@ -87,70 +110,178 @@ fn nextUnwrapped(self: *Osc, buf: []u8) Generator.Error![]const u8 { const Indexer = @TypeOf(self.p_invalid_kind).Indexer; const idx = self.rand.weightedIndex(f64, &self.p_invalid_kind.values); break :invalid try self.nextUnwrappedInvalidExact( - buf, + writer, Indexer.keyForIndex(idx), + max_len, ); }, }; } -fn nextUnwrappedValidExact(self: *const Osc, buf: []u8, k: ValidKind) Generator.Error![]const u8 { - var fbs = std.io.fixedBufferStream(buf); +fn nextUnwrappedValidExact(self: *const Osc, writer: *std.Io.Writer, k: ValidKind, max_len: usize) Generator.Error!void { switch (k) { - .change_window_title => { - try fbs.writer().writeAll("0;"); // Set window title - var bytes_gen = self.bytes(); - const title = try bytes_gen.next(fbs.buffer[fbs.pos..]); - try fbs.seekBy(@intCast(title.len)); + .change_window_title => change_window_title: { + if (max_len < 3) break :change_window_title; + try writer.print("0;{f}", .{self.bytes().atMost(max_len - 3)}); // Set window title }, - .prompt_start => { - try fbs.writer().writeAll("133;A"); // Start prompt + .prompt_start => prompt_start: { + if (max_len < 4) break :prompt_start; + var remaining = max_len; + + try writer.writeAll("133;A"); // Start prompt + remaining -= 4; // aid - if (self.rand.boolean()) { - var bytes_gen = self.bytes(); - bytes_gen.max_len = 16; - try fbs.writer().writeAll(";aid="); - const aid = try bytes_gen.next(fbs.buffer[fbs.pos..]); - try fbs.seekBy(@intCast(aid.len)); + if (self.rand.boolean()) aid: { + if (remaining < 6) break :aid; + try writer.writeAll(";aid="); + remaining -= 5; + remaining -= try self.bytes().newAlphabet(kv_alphabet).atMost(@min(16, remaining)).write(writer); } // redraw - if (self.rand.boolean()) { - try fbs.writer().writeAll(";redraw="); + if (self.rand.boolean()) redraw: { + if (remaining < 9) break :redraw; + try writer.writeAll(";redraw="); if (self.rand.boolean()) { - try fbs.writer().writeAll("1"); + try writer.writeAll("1"); } else { - try fbs.writer().writeAll("0"); + try writer.writeAll("0"); } + remaining -= 9; } }, - .prompt_end => try fbs.writer().writeAll("133;B"), // End prompt - } + .prompt_end => prompt_end: { + if (max_len < 4) break :prompt_end; + try writer.writeAll("133;B"); // End prompt + }, - return fbs.getWritten(); + .end_of_input => end_of_input: { + if (max_len < 5) break :end_of_input; + var remaining = max_len; + try writer.writeAll("133;C"); // End prompt + remaining -= 5; + if (self.rand.boolean()) cmdline: { + const prefix = ";cmdline_url="; + if (remaining < prefix.len + 1) break :cmdline; + try writer.writeAll(prefix); + remaining -= prefix.len; + var buf: [128]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try self.bytes().newAlphabet(ascii_alphabet).atMost(@min(remaining, buf.len)).format(&w); + try urlPercentEncode(writer, w.buffered()); + remaining -= w.buffered().len; + } + }, + + .end_of_command => end_of_command: { + if (max_len < 4) break :end_of_command; + try writer.writeAll("133;D"); // End prompt + if (self.rand.boolean()) exit_code: { + if (max_len < 7) break :exit_code; + try writer.print(";{d}", .{self.rand.int(u8)}); + } + }, + + .mouse_shape => mouse_shape: { + if (max_len < 4) break :mouse_shape; + try writer.print("22;{f}", .{self.bytes().newAlphabet(alphabetic_alphabet).atMost(@min(32, max_len - 3))}); // Start prompt + }, + + .rxvt_notify => rxvt_notify: { + const prefix = "777;notify;"; + if (max_len < prefix.len) break :rxvt_notify; + var remaining = max_len; + try writer.writeAll(prefix); + remaining -= prefix.len; + remaining -= try self.bytes().newAlphabet(kv_alphabet).atMost(@min(remaining - 2, 32)).write(writer); + try writer.writeByte(';'); + remaining -= 1; + remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer); + }, + + .clipboard_operation => { + try writer.writeAll("52;"); + var remaining = max_len - 3; + if (self.rand.boolean()) { + remaining -= try self.bytes().newAlphabet(alphabetic_alphabet).atMost(1).write(writer); + } + try writer.writeByte(';'); + remaining -= 1; + if (self.rand.boolean()) { + remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer); + } + }, + + .report_pwd => report_pwd: { + const prefix = "7;file://localhost"; + if (max_len < prefix.len) break :report_pwd; + var remaining = max_len; + try writer.writeAll(prefix); + remaining -= prefix.len; + for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| { + try writer.writeByte('/'); + remaining -= 1; + remaining -= try self.bytes().newAlphabet(alphanumeric_alphabet).atMost(@min(16, remaining)).write(writer); + } + }, + + .hyperlink_start => { + try writer.writeAll("8;"); + if (self.rand.boolean()) { + try writer.print("id={f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)}); + } + try writer.writeAll(";https://localhost"); + for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| { + try writer.print("/{f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)}); + } + }, + + .hyperlink_end => hyperlink_end: { + if (max_len < 3) break :hyperlink_end; + try writer.writeAll("8;;"); + }, + + .conemu_progress => { + try writer.writeAll("9;"); + switch (self.rand.intRangeAtMost(u3, 0, 4)) { + 0, 3 => |c| { + try writer.print(";{d}", .{c}); + }, + 1, 2, 4 => |c| { + if (self.rand.boolean()) { + try writer.print(";{d}", .{c}); + } else { + try writer.print(";{d};{d}", .{ c, self.rand.intRangeAtMost(u8, 0, 100) }); + } + }, + else => unreachable, + } + }, + + .iterm2_notification => iterm2_notification: { + if (max_len < 3) break :iterm2_notification; + // add a prefix to ensure that this is not interpreted as a ConEmu OSC + try writer.print("9;_{f}", .{self.bytes().newAlphabet(ascii_alphabet).atMost(max_len - 3)}); + }, + } } fn nextUnwrappedInvalidExact( self: *const Osc, - buf: []u8, + writer: *std.Io.Writer, k: InvalidKind, -) Generator.Error![]const u8 { + max_len: usize, +) Generator.Error!void { switch (k) { .random => { - var bytes_gen = self.bytes(); - return try bytes_gen.next(buf); + try self.bytes().atMost(max_len).format(writer); }, .good_prefix => { - var fbs = std.io.fixedBufferStream(buf); - try fbs.writer().writeAll("133;"); - var bytes_gen = self.bytes(); - const data = try bytes_gen.next(fbs.buffer[fbs.pos..]); - try fbs.seekBy(@intCast(data.len)); - return fbs.getWritten(); + try writer.print("133;{f}", .{self.bytes().atMost(max_len - 4)}); }, } } @@ -158,7 +289,7 @@ fn nextUnwrappedInvalidExact( fn bytes(self: *const Osc) Bytes { return .{ .rand = self.rand, - .alphabet = bytes_alphabet, + .alphabet = osc_alphabet, }; } @@ -177,11 +308,21 @@ const Validity = enum { valid, invalid }; const test_seed = 0xC0FFEEEEEEEEEEEE; test "OSC generator" { + const testing = std.testing; var prng = std.Random.DefaultPrng.init(test_seed); - var buf: [4096]u8 = undefined; - var v: Osc = .{ .rand = prng.random() }; - const gen = v.generator(); - for (0..50) |_| _ = try gen.next(&buf); + var buf: [256]u8 = undefined; + { + var v: Osc = .{ + .rand = prng.random(), + }; + const gen = v.generator(); + for (0..50) |_| { + var writer: std.Io.Writer = .fixed(&buf); + try gen.next(&writer, buf.len); + const result = writer.buffered(); + try testing.expect(result.len > 0); + } + } } test "OSC generator valid" { @@ -195,8 +336,10 @@ test "OSC generator valid" { .p_valid = 1.0, }; for (0..50) |_| { - const seq = try gen.next(&buf); - var parser: terminal.osc.Parser = .init(); + var writer: std.Io.Writer = .fixed(&buf); + try gen.next(&writer, buf.len); + const seq = writer.buffered(); + var parser: terminal.osc.Parser = .init(null); for (seq[2 .. seq.len - 1]) |c| parser.next(c); try testing.expect(parser.end(null) != null); } @@ -213,8 +356,10 @@ test "OSC generator invalid" { .p_valid = 0.0, }; for (0..50) |_| { - const seq = try gen.next(&buf); - var parser: terminal.osc.Parser = .init(); + var writer: std.Io.Writer = .fixed(&buf); + try gen.next(&writer, buf.len); + const seq = writer.buffered(); + var parser: terminal.osc.Parser = .init(null); for (seq[2 .. seq.len - 1]) |c| parser.next(c); try testing.expect(parser.end(null) == null); } diff --git a/src/synthetic/Utf8.zig b/src/synthetic/Utf8.zig index c3ace6505..0d72a8bb2 100644 --- a/src/synthetic/Utf8.zig +++ b/src/synthetic/Utf8.zig @@ -41,13 +41,12 @@ pub fn generator(self: *Utf8) Generator { return .init(self, next); } -pub fn next(self: *Utf8, buf: []u8) Generator.Error![]const u8 { +pub fn next(self: *Utf8, writer: *std.Io.Writer, max_len: usize) Generator.Error!void { const len = @min( self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len), - buf.len, + max_len, ); - const result = buf[0..len]; var rem: usize = len; while (rem > 0) { // Pick a utf8 byte count to generate. @@ -75,9 +74,11 @@ pub fn next(self: *Utf8, buf: []u8) Generator.Error![]const u8 { assert(std.unicode.utf8CodepointSequenceLength( cp, ) catch unreachable == @intFromEnum(utf8_len)); - rem -= std.unicode.utf8Encode( + + var buf: [4]u8 = undefined; + const l = std.unicode.utf8Encode( cp, - result[result.len - rem ..], + &buf, ) catch |err| switch (err) { // Impossible because our generation above is hardcoded to // produce a valid range. If not, a bug. @@ -86,18 +87,22 @@ pub fn next(self: *Utf8, buf: []u8) Generator.Error![]const u8 { // Possible, in which case we redo the loop and encode nothing. error.Utf8CannotEncodeSurrogateHalf => continue, }; + try writer.writeAll(buf[0..l]); + rem -= l; } - - return result; } test "utf8" { const testing = std.testing; var prng = std.Random.DefaultPrng.init(0); var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); var v: Utf8 = .{ .rand = prng.random() }; + v.min_len = buf.len; + v.max_len = buf.len; const gen = v.generator(); - const result = try gen.next(&buf); - try testing.expect(result.len > 0); + try gen.next(&writer, buf.len); + const result = writer.buffered(); + try testing.expectEqual(256, result.len); try testing.expect(std.unicode.utf8ValidateSlice(result)); } diff --git a/src/synthetic/cli.zig b/src/synthetic/cli.zig index b32469aab..d9b6a659d 100644 --- a/src/synthetic/cli.zig +++ b/src/synthetic/cli.zig @@ -100,7 +100,9 @@ fn mainActionImpl( try impl.run(writer, rand); // Always flush - try writer.flush(); + writer.flush() catch |err| switch (err) { + error.WriteFailed => return, + }; } test { diff --git a/src/synthetic/cli/Ascii.zig b/src/synthetic/cli/Ascii.zig index 339bdee2e..d416189ce 100644 --- a/src/synthetic/cli/Ascii.zig +++ b/src/synthetic/cli/Ascii.zig @@ -3,12 +3,21 @@ const Ascii = @This(); const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const synthetic = @import("../main.zig"); +const Bytes = @import("../Bytes.zig"); const log = std.log.scoped(.@"terminal-stream-bench"); pub const Options = struct {}; +fn checkAsciiAlphabet(c: u8) bool { + return switch (c) { + ' ' => false, + else => std.ascii.isPrint(c), + }; +} + +pub const ascii = Bytes.generateAlphabet(checkAsciiAlphabet); + /// Create a new terminal stream handler for the given arguments. pub fn create( alloc: Allocator, @@ -23,18 +32,16 @@ pub fn destroy(self: *Ascii, alloc: Allocator) void { alloc.destroy(self); } -pub fn run(self: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { - _ = self; - - var gen: synthetic.Bytes = .{ +pub fn run(_: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { + var gen: Bytes = .{ .rand = rand, - .alphabet = synthetic.Bytes.Alphabet.ascii, + .alphabet = ascii, + .min_len = 1024, + .max_len = 1024, }; - var buf: [1024]u8 = undefined; while (true) { - const data = try gen.next(&buf); - writer.writeAll(data) catch |err| { + _ = gen.write(writer) catch |err| { const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err); switch (@as(Error, err)) { error.BrokenPipe => return, // stdout closed diff --git a/src/synthetic/cli/Osc.zig b/src/synthetic/cli/Osc.zig index 23d19e4ae..686563fc3 100644 --- a/src/synthetic/cli/Osc.zig +++ b/src/synthetic/cli/Osc.zig @@ -10,6 +10,14 @@ const log = std.log.scoped(.@"terminal-stream-bench"); pub const Options = struct { /// Probability of generating a valid value. @"p-valid": f64 = 0.5, + + style: enum { + /// Write all OSC data, including ESC ] and ST for end-to-end tests + streaming, + /// Only write data, prefixed with a length, used for testing just the + /// OSC parser. + parser, + } = .streaming, }; opts: Options, @@ -37,15 +45,24 @@ pub fn run(self: *Osc, writer: *std.Io.Writer, rand: std.Random) !void { var buf: [1024]u8 = undefined; while (true) { - const data = try gen.next(&buf); - writer.writeAll(data) catch |err| { - const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err); - switch (@as(Error, err)) { - error.BrokenPipe => return, // stdout closed - error.WriteFailed => return, // fixed buffer full - else => return err, - } - }; + var fixed: std.Io.Writer = .fixed(&buf); + try gen.next(&fixed, buf.len); + const data = fixed.buffered(); + switch (self.opts.style) { + .streaming => { + writer.writeAll(data) catch |err| switch (err) { + error.WriteFailed => return, + }; + }, + .parser => { + writer.writeInt(usize, data.len - 3, .little) catch |err| switch (err) { + error.WriteFailed => return, + }; + writer.writeAll(data[2 .. data.len - 1]) catch |err| switch (err) { + error.WriteFailed => return, + }; + }, + } } } diff --git a/src/synthetic/cli/Utf8.zig b/src/synthetic/cli/Utf8.zig index 3c2fddef7..635704755 100644 --- a/src/synthetic/cli/Utf8.zig +++ b/src/synthetic/cli/Utf8.zig @@ -30,10 +30,8 @@ pub fn run(self: *Utf8, writer: *std.Io.Writer, rand: std.Random) !void { .rand = rand, }; - var buf: [1024]u8 = undefined; while (true) { - const data = try gen.next(&buf); - writer.writeAll(data) catch |err| { + gen.next(writer, 1024) catch |err| { const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err); switch (@as(Error, err)) { error.BrokenPipe => return, // stdout closed diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 9bf116598..9e14e2a75 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. @@ -128,6 +146,10 @@ explicit_max_size: usize, /// and at least two pages for our algorithms. min_max_size: usize, +/// The total number of rows represented by this PageList. This is used +/// specifically for scrollbar information so we can have the total size. +total_rows: usize, + /// The list of tracked pins. These are kept up to date automatically. tracked_pins: PinSet, @@ -145,12 +167,35 @@ viewport: Viewport, /// never be access directly; use `viewport`. viewport_pin: *Pin, +/// The row offset from the top that the viewport pin is at. We +/// store the offset from the top because it doesn't change while more +/// data is printed to the terminal. +/// +/// This is null when it isn't calculated. It is calculated on demand +/// when the viewportRowOffset function is called, because it is only +/// required for certain operations such as rendering the scrollbar. +/// +/// In order to make this more efficient, in many places where the value +/// would be invalidated, we update it in-place instead. This is key to +/// keeping our performance decent in normal cases since recalculating +/// this from scratch, depending on the size of the scrollback and position +/// of the pin, can be very expensive. +/// +/// This is only valid if viewport is `pin`. Every other offset is +/// self-evident or quick to calculate. +viewport_pin_row_offset: ?usize, + /// The current desired screen dimensions. I say "desired" because individual /// pages may still be a different size and not yet reflowed since we lazily /// reflow text. cols: size.CellCountInt, rows: size.CellCountInt, +/// If this is true then verifyIntegrity will do nothing. This is +/// only present with runtime safety enabled. +pause_integrity_checks: if (build_options.slow_runtime_safety) usize else void = + if (build_options.slow_runtime_safety) 0 else {}, + /// The viewport location. pub const Viewport = union(enum) { /// The viewport is pinned to the active area. By using a specific marker @@ -237,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); @@ -249,23 +300,30 @@ pub fn init( errdefer tracked_pins.deinit(pool.alloc); try tracked_pins.putNoClobber(pool.alloc, viewport_pin, {}); - return .{ + const result: PageList = .{ .cols = cols, .rows = rows, .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, + .total_rows = rows, .tracked_pins = tracked_pins, .viewport = .{ .active = {} }, .viewport_pin = viewport_pin, + .viewport_pin_row_offset = null, }; + result.assertIntegrity(); + return result; } fn initPages( pool: *MemoryPool, + serial: *u64, cols: size.CellCountInt, rows: size.CellCountInt, ) !struct { List, usize } { @@ -292,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; @@ -299,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); @@ -306,9 +368,110 @@ fn initPages( return .{ page_list, page_size }; } +/// Assert that the PageList is in a valid state. This is a no-op in +/// release builds. +pub inline fn assertIntegrity(self: *const PageList) void { + if (comptime !build_options.slow_runtime_safety) return; + + self.verifyIntegrity() catch |err| { + log.err("PageList integrity check failed: {}", .{err}); + @panic("PageList integrity check failed"); + }; +} + +/// Pause or resume integrity checks. This is useful when you're doing +/// a multi-step operation that temporarily leaves the PageList in an +/// inconsistent state. +pub inline fn pauseIntegrityChecks(self: *PageList, pause: bool) void { + if (comptime !build_options.slow_runtime_safety) return; + if (pause) { + self.pause_integrity_checks += 1; + } else { + self.pause_integrity_checks -= 1; + } +} + +const IntegrityError = error{ + TotalRowsMismatch, + ViewportPinOffsetMismatch, + PageSerialInvalid, +}; + +/// Verify the integrity of the PageList. This is expensive and should +/// only be called in debug/test builds. +fn verifyIntegrity(self: *const PageList) IntegrityError!void { + if (comptime !build_options.slow_runtime_safety) return; + if (self.pause_integrity_checks > 0) return; + + // 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 + if (actual_total != self.total_rows) { + log.warn( + "PageList integrity violation: total_rows mismatch cached={} actual={}", + .{ self.total_rows, actual_total }, + ); + return IntegrityError.TotalRowsMismatch; + } + + // Verify that our viewport pin row offset is correct. + if (self.viewport == .pin) pin: { + const cached_offset = self.viewport_pin_row_offset orelse break :pin; + const actual_offset: usize = offset: { + var offset: usize = 0; + var node = self.pages.last; + while (node) |n| : (node = n.prev) { + offset += n.data.size.rows; + if (n == self.viewport_pin.node) { + offset -= self.viewport_pin.y; + break :offset self.total_rows - offset; + } + } + + log.warn( + "PageList integrity violation: viewport pin not in list", + .{}, + ); + return error.ViewportPinOffsetMismatch; + }; + + if (cached_offset != actual_offset) { + log.warn( + "PageList integrity violation: viewport pin offset mismatch cached={} actual={}", + .{ cached_offset, actual_offset }, + ); + return error.ViewportPinOffsetMismatch; + } + } +} + /// Deinit the pagelist. If you own the memory pool (used clonePool) then /// this will reset the pool and retain capacity. pub fn deinit(self: *PageList) void { + // Verify integrity before cleanup + self.assertIntegrity(); + // Always deallocate our hashmap. self.tracked_pins.deinit(self.pool.alloc); @@ -339,6 +502,8 @@ pub fn deinit(self: *PageList) void { /// This can't fail because we always retain at least enough allocated /// memory to fit the active area. pub fn reset(self: *PageList) void { + defer self.assertIntegrity(); + // We need enough pages/nodes to keep our active area. This should // never fail since we by definition have allocated a page already // that fits our size but I'm not confident to make that assertion. @@ -409,11 +574,17 @@ 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"); + // Our total rows always goes back to the default + self.total_rows = self.rows; + // Update all our tracked pins to point to our first page top-left + // and mark them as garbage, because it got mangled in a way where + // semantically it really doesn't make sense. { var it = self.tracked_pins.iterator(); while (it.next()) |entry| { @@ -421,7 +592,11 @@ pub fn reset(self: *PageList) void { p.node = self.pages.first.?; p.x = 0; p.y = 0; + p.garbage = true; } + + // Our viewport pin is never garbage + self.viewport_pin.garbage = false; } // Move our viewport back to the active area since everything is gone. @@ -515,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| { @@ -523,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); @@ -535,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; @@ -565,14 +744,18 @@ 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, .cols = self.cols, .rows = self.rows, + .total_rows = total_rows, .tracked_pins = tracked_pins, .viewport = .{ .active = {} }, .viewport_pin = viewport_pin, + .viewport_pin_row_offset = null, }; // We always need to have enough rows for our viewport because this is @@ -589,8 +772,12 @@ pub fn clone( const row = &last.data.rows.ptr(last.data.memory)[last.data.size.rows - 1]; last.data.clearCells(row, 0, result.cols); } + + // Update our total rows to be our row size. + result.total_rows = result.rows; } + result.assertIntegrity(); return result; } @@ -617,6 +804,8 @@ pub const Resize = struct { /// Resize /// TODO: docs pub fn resize(self: *PageList, opts: Resize) !void { + defer self.assertIntegrity(); + if (comptime std.debug.runtime_safety) { // Resize does not work with 0 values, this should be protected // upstream @@ -624,6 +813,12 @@ pub fn resize(self: *PageList, opts: Resize) !void { if (opts.rows) |v| assert(v > 0); } + // Resizing (especially with reflow) can cause our row offset to + // become invalid. Rather than do something fancy like we do other + // places and try to update it in place, we just invalidate it because + // its too easy to get the logic wrong in here. + self.viewport_pin_row_offset = null; + if (!opts.reflow) return try self.resizeWithoutReflow(opts); // Recalculate our minimum max size. This allows grow to work properly @@ -658,7 +853,6 @@ pub fn resize(self: *PageList, opts: Resize) !void { copy.cols = self.cols; break :opts copy; }); - try self.resizeCols(cols, opts.cursor); }, } @@ -728,16 +922,21 @@ fn resizeCols( self.pages.first = dst_node; self.pages.last = dst_node; - var dst_cursor = ReflowCursor.init(dst_node); - // Reflow all our rows. - while (it.next()) |row| { - try dst_cursor.reflowRow(self, row); + { + var dst_cursor = ReflowCursor.init(dst_node); + while (it.next()) |row| { + try dst_cursor.reflowRow(self, row); - // Once we're done reflowing a page, destroy it. - if (row.y == row.node.data.size.rows - 1) { - self.destroyNode(row.node); + // Once we're done reflowing a page, destroy it. + if (row.y == row.node.data.size.rows - 1) { + self.destroyNode(row.node); + } } + + // At the end of the reflow, setup our total row cache + // log.warn("total old={} new={}", .{ self.total_rows, dst_cursor.total_rows }); + self.total_rows = dst_cursor.total_rows; } // If our total rows is less than our active rows, we need to grow. @@ -804,6 +1003,9 @@ const ReflowCursor = struct { page_cell: *pagepkg.Cell, new_rows: usize, + /// This is the final row count of the reflowed pages. + total_rows: usize, + fn init(node: *List.Node) ReflowCursor { const page = &node.data; const rows = page.rows.ptr(page.memory); @@ -816,6 +1018,9 @@ const ReflowCursor = struct { .page_row = &rows[0], .page_cell = &rows[0].cells.ptr(page.memory)[0], .new_rows = 0, + + // Initially whatever size our input node is. + .total_rows = node.data.size.rows, }; } @@ -1015,7 +1220,7 @@ const ReflowCursor = struct { // with graphemes then we increase capacity. if (self.page.graphemeCount() >= self.page.graphemeCapacity()) { try self.adjustCapacity(list, .{ - .hyperlink_bytes = cap.grapheme_bytes * 2, + .grapheme_bytes = cap.grapheme_bytes * 2, }); } @@ -1229,12 +1434,21 @@ const ReflowCursor = struct { ) !void { const old_x = self.x; const old_y = self.y; + const old_total_rows = self.total_rows; + + self.* = .init(node: { + // Pause integrity checks because the total row count won't + // be correct during a reflow. + list.pauseIntegrityChecks(true); + defer list.pauseIntegrityChecks(false); + break :node try list.adjustCapacity( + self.node, + adjustment, + ); + }); - self.* = .init(try list.adjustCapacity( - self.node, - adjustment, - )); self.cursorAbsolute(old_x, old_y); + self.total_rows = old_total_rows; } /// True if this cursor is at the bottom of the page by capacity, @@ -1253,11 +1467,6 @@ const ReflowCursor = struct { } } - fn cursorDown(self: *ReflowCursor) void { - assert(self.y + 1 < self.page.size.rows); - self.cursorAbsolute(self.x, self.y + 1); - } - /// Create a new row and move the cursor down. /// /// Asserts that the cursor is on the bottom row of the @@ -1309,6 +1518,12 @@ const ReflowCursor = struct { list: *PageList, cap: Capacity, ) !void { + // The functions below may overwrite self so we need to cache + // our total rows. We add one because no matter what when this + // returns we'll have one more row added. + const new_total_rows: usize = self.total_rows + 1; + defer self.total_rows = new_total_rows; + if (self.bottom()) { try self.cursorNewPage(list, cap); } else { @@ -1374,6 +1589,11 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // destroy pages if we're increasing cols which will free up page_size // so that when we call grow() in the row mods, we won't prune. if (opts.cols) |cols| { + // Any column change without reflow should not result in row counts + // changing. + const old_total_rows = self.total_rows; + defer assert(self.total_rows == old_total_rows); + switch (std.math.order(cols, self.cols)) { .eq => {}, @@ -1442,7 +1662,10 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // behavior because it seemed fine in an ocean of differing behavior // between terminal apps. I'm completely open to changing it as long // as resize behavior isn't regressed in a user-hostile way. - _ = self.trimTrailingBlankRows(self.rows - rows); + const trimmed = self.trimTrailingBlankRows(self.rows - rows); + + // Account for our trimmed rows in the total row cache + self.total_rows -= trimmed; // If we didn't trim enough, just modify our row count and this // will create additional history. @@ -1502,6 +1725,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { } if (build_options.slow_runtime_safety) { + // We never have less rows than our active screen has. assert(self.totalRows() >= self.rows); } } @@ -1673,6 +1897,10 @@ fn trailingBlankLines( /// Trims up to max trailing blank rows from the pagelist and returns the /// number of rows trimmed. A blank row is any row with no text (but may /// have styling). +/// +/// IMPORTANT: This function does NOT update `total_rows`. It returns the +/// number of rows trimmed, and the caller is responsible for decrementing +/// `total_rows` by this amount. fn trimTrailingBlankRows( self: *PageList, max: size.CellCountInt, @@ -1725,6 +1953,11 @@ pub const Scroll = union(enum) { /// the scrollback history. top, + /// Scroll to the given absolute row from the top. A value of zero + /// is the top row. This row will be the first visible row in the viewport. + /// Scrolling into or below the active area will clamp to the active area. + row: usize, + /// Scroll up (negative) or down (positive) by the given number of /// rows. This is clamped to the "top" and "active" top left. delta_row: isize, @@ -1743,22 +1976,171 @@ pub const Scroll = union(enum) { /// pages, etc. This can only be used to move the viewport within the /// previously allocated pages. pub fn scroll(self: *PageList, behavior: Scroll) void { + defer self.assertIntegrity(); + switch (behavior) { - .active => self.viewport = .{ .active = {} }, - .top => self.viewport = .{ .top = {} }, + .active => self.viewport = .active, + .top => self.viewport = .top, .pin => |p| { if (self.pinIsActive(p)) { - self.viewport = .{ .active = {} }; + self.viewport = .active; + return; + } else if (self.pinIsTop(p)) { + self.viewport = .top; return; } self.viewport_pin.* = p; - self.viewport = .{ .pin = {} }; + self.viewport = .pin; + self.viewport_pin_row_offset = null; // invalidate cache + }, + .row => |n| row: { + // If we're at the top, pin the top. + if (n == 0) { + self.viewport = .top; + break :row; + } + + // If we're below the top of the active area, pin the active area. + if (n >= self.total_rows - self.rows) { + self.viewport = .active; + break :row; + } + + // See if there are any other faster paths we can take. + switch (self.viewport) { + .top, .active => {}, + .pin => if (self.viewport_pin_row_offset) |*v| { + // If we have a pin and we already calculated a row offset, + // then we can efficiently calculate the delta and move + // that much from that pin. + const delta: isize = delta: { + const n_isize: isize = @intCast(n); + const v_isize: isize = @intCast(v.*); + break :delta n_isize - v_isize; + }; + self.scroll(.{ .delta_row = delta }); + return; + }, + } + + // We have an accurate row offset so store it to prevent + // calculating this again. + self.viewport_pin_row_offset = n; + self.viewport = .pin; + + // Slow path, we've just got to traverse the linked list and + // get to our row. As a slight speedup, let's pick the traversal + // that's likely faster based on our absolute row and total rows. + const midpoint = self.total_rows / 2; + if (n < midpoint) { + // Iterate forward from the first node. + var node_it = self.pages.first; + var rem: size.CellCountInt = std.math.cast( + size.CellCountInt, + n, + ) orelse { + self.viewport = .active; + break :row; + }; + while (node_it) |node| : (node_it = node.next) { + if (rem < node.data.size.rows) { + self.viewport_pin.* = .{ + .node = node, + .y = rem, + }; + break :row; + } + + rem -= node.data.size.rows; + } + } else { + // Iterate backwards from the last node. + var node_it = self.pages.last; + var rem: size.CellCountInt = std.math.cast( + size.CellCountInt, + self.total_rows - n, + ) orelse { + self.viewport = .active; + break :row; + }; + while (node_it) |node| : (node_it = node.prev) { + if (rem <= node.data.size.rows) { + self.viewport_pin.* = .{ + .node = node, + .y = node.data.size.rows - rem, + }; + break :row; + } + + rem -= node.data.size.rows; + } + } + + // If we reached here, then we couldn't find the offset. + // This feels impossible? Just clamp to active, screw it lol. + self.viewport = .active; }, .delta_prompt => |n| self.scrollPrompt(n), - .delta_row => |n| { - if (n == 0) return; + .delta_row => |n| delta_row: { + switch (self.viewport) { + // If we're at the top and we're scrolling backwards, + // we don't have to do anything, because there's nowhere to go. + .top => if (n <= 0) break :delta_row, + // If we're at active and we're scrolling forwards, we don't + // have to do anything because it'll result in staying in + // the active. + .active => if (n >= 0) break :delta_row, + + // If we're already a pin type, then we can fast-path our + // delta by simply moving the pin. This has the added benefit + // that we can update our row offset cache efficiently, too. + .pin => switch (std.math.order(n, 0)) { + .eq => break :delta_row, + + .lt => switch (self.viewport_pin.upOverflow(@intCast(-n))) { + .offset => |new_pin| { + self.viewport_pin.* = new_pin; + if (self.viewport_pin_row_offset) |*v| { + v.* -= @as(usize, @intCast(-n)); + } + break :delta_row; + }, + + // If we overflow up we're at the top. + .overflow => { + self.viewport = .top; + break :delta_row; + }, + }, + + .gt => switch (self.viewport_pin.downOverflow(@intCast(n))) { + // If we offset its a valid pin but we still have to + // check if we're in the active area. + .offset => |new_pin| { + if (self.pinIsActive(new_pin)) { + self.viewport = .active; + } else { + self.viewport_pin.* = new_pin; + if (self.viewport_pin_row_offset) |*v| { + v.* += @intCast(n); + } + } + break :delta_row; + }, + + // If we overflow down we're at active. + .overflow => { + self.viewport = .active; + break :delta_row; + }, + }, + }, + } + + // Slow path: we have to calculate the new pin by moving + // from our viewport. const top = self.getTopLeft(.viewport); const p: Pin = if (n < 0) switch (top.upOverflow(@intCast(-n))) { .offset => |v| v, @@ -1776,13 +2158,22 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { // active area, you usually expect that the viewport will now // follow the active area. if (self.pinIsActive(p)) { - self.viewport = .{ .active = {} }; + self.viewport = .active; + return; + } + + // If we're at the top, then just set the top. This is a lot + // more efficient everywhere. We must check this after the + // active check above because we prefer active if they overlap. + if (self.pinIsTop(p)) { + self.viewport = .top; return; } // Pin is not active so we need to track it. self.viewport_pin.* = p; - self.viewport = .{ .pin = {} }; + self.viewport = .pin; + self.viewport_pin_row_offset = null; // invalidate cache }, } } @@ -1818,10 +2209,11 @@ fn scrollPrompt(self: *PageList, delta: isize) void { // into the active area. Otherwise, we scroll up to the pin. if (prompt_pin) |p| { if (self.pinIsActive(p)) { - self.viewport = .{ .active = {} }; + self.viewport = .active; } else { self.viewport_pin.* = p; - self.viewport = .{ .pin = {} }; + self.viewport = .pin; + self.viewport_pin_row_offset = null; // invalidate cache } } } @@ -1829,6 +2221,8 @@ fn scrollPrompt(self: *PageList, delta: isize) void { /// Clear the screen by scrolling written contents up into the scrollback. /// This will not update the viewport. pub fn scrollClear(self: *PageList) !void { + defer self.assertIntegrity(); + // Go through the active area backwards to find the first non-empty // row. We use this to determine how many rows to scroll up. const non_empty: usize = non_empty: { @@ -1856,6 +2250,146 @@ pub fn scrollClear(self: *PageList) !void { for (0..non_empty) |_| _ = try self.grow(); } +/// This represents the state necessary to render a scrollbar for this +/// PageList. It has the total size, the offset, and the size of the viewport. +pub const Scrollbar = struct { + /// Total size of the scrollable area. + total: usize, + + /// The offset into the total area that the viewport is at. This is + /// guaranteed to be less than or equal to total. This includes the + /// visible row. + offset: usize, + + /// The length of the visible area. This is including the offset row. + len: usize, + + /// A zero-sized scrollable region. + pub const zero: Scrollbar = .{ + .total = 0, + .offset = 0, + .len = 0, + }; + + // Sync with: ghostty_action_scrollbar_s + pub const C = extern struct { + total: u64, + offset: u64, + len: u64, + }; + + pub fn cval(self: Scrollbar) C { + return .{ + .total = @intCast(self.total), + .offset = @intCast(self.offset), + .len = @intCast(self.len), + }; + } + + /// Comparison for scrollbars. + pub fn eql(self: Scrollbar, other: Scrollbar) bool { + return self.total == other.total and + self.offset == other.offset and + self.len == other.len; + } +}; + +/// Return the scrollbar state for this PageList. +/// +/// This may be expensive to calculate depending on where the viewport +/// is (arbitrary pins are expensive). The caller should take care to only +/// call this as needed and not too frequently. +pub fn scrollbar(self: *PageList) Scrollbar { + return .{ + .total = self.total_rows, + .offset = self.viewportRowOffset(), + .len = self.rows, // Length is always rows + }; +} + +/// Returns the offset of the current viewport from the top of the +/// screen. +/// +/// This is potentially expensive to calculate because if the viewport +/// is a pin and the pin is near the beginning of the scrollback, we +/// will traverse a lot of linked list nodes. +/// +/// The result is cached so repeated calls are cheap. +fn viewportRowOffset(self: *PageList) usize { + return switch (self.viewport) { + .top => 0, + .active => self.total_rows - self.rows, + .pin => pin: { + // We assert integrity on this code path because it verifies + // that the cached value is correct. + defer self.assertIntegrity(); + + // Return cached value if available + if (self.viewport_pin_row_offset) |cached| break :pin cached; + + // Traverse from the end and count rows until we reach the + // viewport pin. We count backwards because most of the time + // a user is scrolling near the active area. + const top_offset: usize = offset: { + var offset: usize = 0; + var node = self.pages.last; + while (node) |n| : (node = n.prev) { + offset += n.data.size.rows; + if (n == self.viewport_pin.node) { + assert(n.data.size.rows > self.viewport_pin.y); + offset -= self.viewport_pin.y; + break :offset self.total_rows - offset; + } + } + + // Invalid pins are not possible. + unreachable; + }; + + // The offset is from the bottom and our cached value and this + // function returns from the top, so we need to invert it. + self.viewport_pin_row_offset = top_offset; + break :pin top_offset; + }, + }; +} + +/// This fixes up the viewport data when rows are removed from the +/// PageList. This will update a viewport to `active` if row removal +/// puts the viewport into the active area, to `top` if the viewport +/// is now at row 0, and updates any row offset caches as necessary. +/// +/// This is unit tested transitively through other tests such as +/// eraseRows. +fn fixupViewport( + self: *PageList, + removed: usize, +) void { + switch (self.viewport) { + .active => {}, + + // For pin, we check if our pin is now in the active area and if so + // we move our viewport back to the active area. + .pin => if (self.pinIsActive(self.viewport_pin.*)) { + self.viewport = .active; + } else if (self.viewport_pin_row_offset) |*v| { + // If we have a cached row offset, we need to update it + // to account for the erased rows. + if (v.* < removed) { + self.viewport = .top; + } else { + v.* -= removed; + } + }, + + // For top, we move back to active if our erasing moved our + // top page into the active area. + .top => if (self.pinIsActive(.{ .node = self.pages.first.? })) { + self.viewport = .active; + }, + } +} + /// Returns the actual max size. This may be greater than the explicit /// value if the explicit value is less than the min_max_size. /// @@ -1887,11 +2421,17 @@ inline fn growRequiredForActive(self: *const PageList) bool { /// /// This returns the newly allocated page node if there is one. pub fn grow(self: *PageList) !?*List.Node { + defer self.assertIntegrity(); + const last = self.pages.last.?; if (last.data.capacity.rows > last.data.size.rows) { // Fast path: we have capacity in the last page. last.data.size.rows += 1; last.data.assertIntegrity(); + + // Increase our total rows by one + self.total_rows += 1; + return null; } @@ -1921,11 +2461,40 @@ pub fn grow(self: *PageList) !?*List.Node { const buf = first.data.memory; @memset(buf, 0); + // Decrease our total row count from the pruned page and then + // add one for our new row. + self.total_rows -= first.data.size.rows; + self.total_rows += 1; + + // If we have a pin viewport cache then we need to update it. + if (self.viewport == .pin) viewport: { + if (self.viewport_pin_row_offset) |*v| { + // If our offset is less than the number of rows in the + // pruned page, then we are now at the top. + if (v.* < first.data.size.rows) { + self.viewport = .top; + break :viewport; + } + + // Otherwise, our viewport pin is below what we pruned + // so we just decrement our offset. + v.* -= first.data.size.rows; + } + } + // Initialize our new page and reinsert it as the last first.data = .initBuf(.init(buf), layout); 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(); @@ -1934,7 +2503,9 @@ pub fn grow(self: *PageList) !?*List.Node { p.node = self.pages.first.?; p.y = 0; p.x = 0; + p.garbage = true; } + self.viewport_pin.garbage = false; // In this case we do NOT need to update page_size because // we're reusing an existing page so nothing has changed. @@ -1954,6 +2525,9 @@ pub fn grow(self: *PageList) !?*List.Node { // verified the case above. next_node.data.assertIntegrity(); + // Record the increased row count + self.total_rows += 1; + return next_node; } @@ -1976,7 +2550,7 @@ pub const AdjustCapacity = struct { pub const AdjustCapacityError = Allocator.Error || Page.CloneFromError; -/// Adjust the capcaity of the given page in the list. This should +/// Adjust the capacity of the given page in the list. This should /// be used in cases where OutOfMemory is returned by some operation /// i.e to increase style counts, grapheme counts, etc. /// @@ -1997,6 +2571,7 @@ pub fn adjustCapacity( node: *List.Node, adjustment: AdjustCapacity, ) AdjustCapacityError!*List.Node { + defer self.assertIntegrity(); const page: *Page = &node.data; // We always start with the base capacity of the existing page. This @@ -2033,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. @@ -2059,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(); @@ -2094,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 @@ -2110,6 +2697,10 @@ inline fn createPageExt( /// Destroy the memory of the given node in the PageList linked list /// and return it to the pool. The node is assumed to already be removed /// from the linked list. +/// +/// IMPORTANT: This function does NOT update `total_rows`. The caller is +/// responsible for accounting for the removed rows. This function only +/// updates `page_size` (byte accounting), not row accounting. fn destroyNode(self: *PageList, node: *List.Node) void { destroyNodeExt(&self.pool, node, &self.page_size); } @@ -2147,6 +2738,7 @@ pub fn eraseRow( self: *PageList, pt: point.Point, ) !void { + defer self.assertIntegrity(); const pn = self.pin(pt).?; var node = pn.node; @@ -2166,11 +2758,14 @@ pub fn eraseRow( } } - { - // 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); - } + // If we have a pinned viewport, we need to adjust for active area. + self.fixupViewport(1); + + // 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. @@ -2203,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 @@ -2236,6 +2830,8 @@ pub fn eraseRowBounded( pt: point.Point, limit: usize, ) !void { + defer self.assertIntegrity(); + // This function has a lot of repeated code in it because it is a hot path. // // To get a better idea of what's happening, read eraseRow first for more @@ -2254,9 +2850,26 @@ 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 + // of in the pin loop below because its unlikely to be true and we + // don't want to run the conditional N times. + if (self.viewport == .pin) viewport: { + if (self.viewport_pin_row_offset) |*v| { + const p = self.viewport_pin; + if (p.node != node or + p.y < pn.y or + p.y > pn.y + limit or + p.y == 0) break :viewport; + v.* -= 1; + } + } // Update pins in the shifted region. const pin_keys = self.tracked_pins.keys(); @@ -2278,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 @@ -2291,6 +2904,18 @@ pub fn eraseRowBounded( // Update tracked pins. { + // See the other places we do something similar in this function + // for a detailed explanation. + if (self.viewport == .pin) viewport: { + if (self.viewport_pin_row_offset) |*v| { + const p = self.viewport_pin; + if (p.node != node or + p.y < pn.y or + p.y == 0) break :viewport; + v.* -= 1; + } + } + const pin_keys = self.tracked_pins.keys(); for (pin_keys) |p| { if (p.node == node and p.y >= pn.y) { @@ -2325,9 +2950,22 @@ 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. + if (self.viewport == .pin) viewport: { + if (self.viewport_pin_row_offset) |*v| { + const p = self.viewport_pin; + if (p.node != node or + p.y > shifted_limit) break :viewport; + v.* -= 1; + } + } // Update pins in the shifted region. const pin_keys = self.tracked_pins.keys(); @@ -2346,13 +2984,22 @@ 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; + // See the other places we do something similar in this function + // for a detailed explanation. + if (self.viewport == .pin) viewport: { + if (self.viewport_pin_row_offset) |*v| { + const p = self.viewport_pin; + if (p.node != node) break :viewport; + v.* -= 1; + } + } + // Update tracked pins. const pin_keys = self.tracked_pins.keys(); for (pin_keys) |p| { @@ -2381,6 +3028,8 @@ pub fn eraseRows( tl_pt: point.Point, bl_pt: ?point.Point, ) void { + defer self.assertIntegrity(); + // The count of rows that was erased. var erased: usize = 0; @@ -2424,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 @@ -2453,12 +3105,11 @@ 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 + self.total_rows -= erased; + // If we deleted active, we need to regrow because one of our invariants // is that we always have full active space. if (tl_pt == .active) { @@ -2473,36 +3124,29 @@ pub fn eraseRows( } // If we have a pinned viewport, we need to adjust for active area. - switch (self.viewport) { - .active => {}, - - // For pin, we check if our pin is now in the active area and if so - // we move our viewport back to the active area. - .pin => if (self.pinIsActive(self.viewport_pin.*)) { - self.viewport = .{ .active = {} }; - }, - - // For top, we move back to active if our erasing moved our - // top page into the active area. - .top => if (self.pinIsActive(.{ .node = self.pages.first.? })) { - self.viewport = .{ .active = {} }; - }, - } + self.fixupViewport(erased); } /// Erase a single page, freeing all its resources. The page can be /// anywhere in the linked list but must NOT be the final page in the /// entire list (i.e. must not make the list empty). +/// +/// IMPORTANT: This function does NOT update `total_rows`. The caller is +/// responsible for accounting for the removed rows before or after calling +/// this function. fn erasePage(self: *PageList, node: *List.Node) void { assert(node.next != null or node.prev != null); - // Update any tracked pins to move to the next page. + // Update any tracked pins to move to the previous or next page. const pin_keys = self.tracked_pins.keys(); for (pin_keys) |p| { if (p.node != node) continue; - p.node = node.next orelse node.prev orelse unreachable; + p.node = node.prev orelse node.next orelse unreachable; p.y = 0; p.x = 0; + + // This doesn't get marked garbage because the tracked pin + // movement is sensical. } // Remove the page from the linked list @@ -2601,6 +3245,11 @@ fn pinIsActive(self: *const PageList, p: Pin) bool { return false; } +/// Returns true if the pin is at the top of the scrollback area. +fn pinIsTop(self: *const PageList, p: Pin) bool { + return p.y == 0 and p.node == self.pages.first.?; +} + /// Convert a pin to a point in the given context. If the pin can't fit /// within the given tag (i.e. its in the history but you requested active), /// then this will return null. @@ -2660,50 +3309,6 @@ pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { }; } -pub const EncodeUtf8Options = struct { - /// The start and end points of the dump, both inclusive. The x will - /// be ignored and the full row will always be dumped. - tl: Pin, - br: ?Pin = null, - - /// If true, this will unwrap soft-wrapped lines. If false, this will - /// dump the screen as it is visually seen in a rendered window. - unwrap: bool = true, - - /// See Page.EncodeUtf8Options. - cell_map: ?*Page.CellMap = null, -}; - -/// Encode the pagelist to utf8 to the given writer. -/// -/// The writer should be buffered; this function does not attempt to -/// efficiently write and often writes one byte at a time. -/// -/// Note: this is tested using Screen.dumpString. This is a function that -/// predates this and is a thin wrapper around it so the tests all live there. -pub fn encodeUtf8( - self: *const PageList, - writer: *std.Io.Writer, - opts: EncodeUtf8Options, -) anyerror!void { - // We don't currently use self at all. There is an argument that this - // function should live on Pin instead but there is some future we might - // need state on here so... letting it go. - _ = self; - - var page_opts: Page.EncodeUtf8Options = .{ - .unwrap = opts.unwrap, - .cell_map = opts.cell_map, - }; - var iter = opts.tl.pageIterator(.right_down, opts.br); - while (iter.next()) |chunk| { - const page: *const Page = &chunk.node.data; - page_opts.start_y = chunk.start; - page_opts.end_y = chunk.end; - page_opts.preceding = try page.encodeUtf8(writer, page_opts); - } -} - /// Log a debug diagram of the page list to the provided writer. /// /// EXAMPLE: @@ -3200,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 { @@ -3301,13 +3910,17 @@ pub fn getBottomRight(self: *const PageList, tag: point.Tag) ?Pin { }, .viewport => viewport: { - const tl = self.getTopLeft(.viewport); - break :viewport tl.down(self.rows - 1).?; + var br = self.getTopLeft(.viewport); + br = br.down(self.rows - 1).?; + br.x = br.node.data.size.cols - 1; + break :viewport br; }, .history => active: { - const tl = self.getTopLeft(.active); - break :active tl.up(1); + var br = self.getTopLeft(.active); + br = br.up(1) orelse return null; + br.x = br.node.data.size.cols - 1; + break :active br; }, }; } @@ -3328,8 +3941,9 @@ fn totalRows(self: *const PageList) usize { return rows; } -/// The total number of pages in this list. -fn totalPages(self: *const PageList) usize { +/// The total number of pages in this list. This should only be used +/// for tests since it is O(N) over the list of pages. +pub fn totalPages(self: *const PageList) usize { var pages: usize = 0; var node_ = self.pages.first; while (node_) |node| { @@ -3341,33 +3955,20 @@ fn totalPages(self: *const PageList) usize { } /// Grow the number of rows available in the page list by n. -/// This is only used for testing so it isn't optimized. +/// This is only used for testing so it isn't optimized in any way. fn growRows(self: *PageList, n: usize) !void { - var page = self.pages.last.?; - var n_rem: usize = n; - if (page.data.size.rows < page.data.capacity.rows) { - const add = @min(n_rem, page.data.capacity.rows - page.data.size.rows); - page.data.size.rows += add; - if (n_rem == add) return; - n_rem -= add; - } - - while (n_rem > 0) { - page = (try self.grow()).?; - const add = @min(n_rem, page.data.capacity.rows); - page.data.size.rows = add; - n_rem -= add; - } + for (0..n) |_| _ = try self.grow(); } /// Clear all dirty bits on all pages. This is not efficient since it /// 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; + } } } @@ -3401,6 +4002,13 @@ pub const Pin = struct { y: size.CellCountInt = 0, x: size.CellCountInt = 0, + /// This is flipped to true for tracked pins that were tracking + /// a page that got pruned for any reason and where the tracked pin + /// couldn't be moved to a sensical location. Users of the tracked + /// pin could use this data and make their own determination of + /// semantics. + garbage: bool = false, + pub inline fn rowAndCell(self: Pin) struct { row: *pagepkg.Row, cell: *pagepkg.Cell, @@ -3441,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 @@ -3851,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. @@ -3896,6 +4444,9 @@ test "PageList" { try testing.expect(s.pages.first != null); try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + // Initial total rows should be our row count + try testing.expectEqual(s.rows, s.total_rows); + // Our viewport pin must be defined. It isn't used until the // viewport is a pin but it prevents undefined access on clone. try testing.expect(s.viewport_pin.node == s.pages.first.?); @@ -3906,6 +4457,13 @@ test "PageList" { .y = 0, .x = 0, }, s.getTopLeft(.active)); + + // Scrollbar should be where we expect it + try testing.expectEqual(Scrollbar{ + .total = s.rows, + .offset = 0, + .len = s.rows, + }, s.scrollbar()); } test "PageList init rows across two pages" { @@ -3929,6 +4487,16 @@ test "PageList init rows across two pages" { try testing.expect(s.viewport == .active); try testing.expect(s.pages.first != null); try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + // Initial total rows should be our row count + try testing.expectEqual(s.rows, s.total_rows); + + // Scrollbar should be where we expect it + try testing.expectEqual(Scrollbar{ + .total = s.rows, + .offset = 0, + .len = s.rows, + }, s.scrollbar()); } test "PageList pointFromPin active no history" { @@ -4116,6 +4684,13 @@ test "PageList active after grow" { .y = 10, } }, pt); } + + // Scrollbar should be in the active area + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 10, + .len = s.rows, + }, s.scrollbar()); } test "PageList grow allows exceeding max size for active area" { @@ -4141,6 +4716,9 @@ test "PageList grow allows exceeding max size for active area" { page.data.size.rows = 1; page.data.capacity.rows = 1; } + + // Avoid integrity check failures + s.total_rows = s.totalRows(); } // Grow our row and ensure we don't prune pages because we need @@ -4192,6 +4770,13 @@ test "PageList grow prune required with a single page" { const new = try s.grow(); try testing.expect(new != null); try testing.expect(new != s.pages.first); + + // Scrollbar should be in the active area + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); } test "PageList scroll top" { @@ -4220,6 +4805,12 @@ test "PageList scroll top" { } }, pt); } + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 0, + .len = s.rows, + }, s.scrollbar()); + try s.growRows(10); { const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); @@ -4229,6 +4820,12 @@ test "PageList scroll top" { } }, pt); } + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 0, + .len = s.rows, + }, s.scrollbar()); + s.scroll(.{ .active = {} }); { const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); @@ -4237,6 +4834,12 @@ test "PageList scroll top" { .y = 20, } }, pt); } + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); } test "PageList scroll delta row back" { @@ -4257,6 +4860,12 @@ test "PageList scroll delta row back" { s.scroll(.{ .delta_row = -1 }); + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows - 1, + .len = s.rows, + }, s.scrollbar()); + { const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); try testing.expectEqual(point.Point{ .screen = .{ @@ -4273,6 +4882,20 @@ test "PageList scroll delta row back" { .y = 9, } }, pt); } + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows - 11, + .len = s.rows, + }, s.scrollbar()); + + s.scroll(.{ .delta_row = -1 }); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows - 12, + .len = s.rows, + }, s.scrollbar()); } test "PageList scroll delta row back overflow" { @@ -4301,6 +4924,12 @@ test "PageList scroll delta row back overflow" { } }, pt); } + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 0, + .len = s.rows, + }, s.scrollbar()); + try s.growRows(10); { const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); @@ -4309,6 +4938,12 @@ test "PageList scroll delta row back overflow" { .y = 0, } }, pt); } + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 0, + .len = s.rows, + }, s.scrollbar()); } test "PageList scroll delta row forward" { @@ -4330,6 +4965,12 @@ test "PageList scroll delta row forward" { s.scroll(.{ .top = {} }); s.scroll(.{ .delta_row = 2 }); + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 2, + .len = s.rows, + }, s.scrollbar()); + { const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); try testing.expectEqual(point.Point{ .screen = .{ @@ -4346,6 +4987,12 @@ test "PageList scroll delta row forward" { .y = 2, } }, pt); } + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 2, + .len = s.rows, + }, s.scrollbar()); } test "PageList scroll delta row forward into active" { @@ -4364,6 +5011,12 @@ test "PageList scroll delta row forward into active" { .y = 0, } }, pt); } + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); } test "PageList scroll delta row back without space preserves active" { @@ -4383,6 +5036,538 @@ test "PageList scroll delta row back without space preserves active" { } try testing.expect(s.viewport == .active); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to pin" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + s.scroll(.{ .pin = s.pin(.{ .screen = .{ + .y = 4, + .x = 2, + } }).? }); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 4, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 4, + } }, pt); + } + + s.scroll(.{ .pin = s.pin(.{ .screen = .{ + .y = 5, + .x = 2, + } }).? }); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 5, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } +} + +test "PageList scroll to pin in active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + s.scroll(.{ .pin = s.pin(.{ .screen = .{ + .y = 30, + .x = 2, + } }).? }); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } +} + +test "PageList scroll to pin at top" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + s.scroll(.{ .pin = s.pin(.{ .screen = .{ + .y = 0, + .x = 2, + } }).? }); + + try testing.expect(s.viewport == .top); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 0, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } +} + +test "PageList scroll to row 0" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + s.scroll(.{ .row = 0 }); + try testing.expect(s.viewport == .top); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 0, + .len = s.rows, + }, s.scrollbar()); + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 0, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row in scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(20); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 20, + } }, pt); + } + + s.scroll(.{ .row = 5 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 5, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 5, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row in middle" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(50); + + const total = s.total_rows; + const midpoint = total / 2; + s.scroll(.{ .row = midpoint }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = midpoint, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = @as(size.CellCountInt, @intCast(midpoint)), + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = @as(size.CellCountInt, @intCast(midpoint)), + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = midpoint, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row at active boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(20); + + const active_start = s.total_rows - s.rows; + + s.scroll(.{ .row = active_start }); + + try testing.expect(s.viewport == .active); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = @as(size.CellCountInt, @intCast(active_start)), + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); + + try s.growRows(10); + + try testing.expect(s.viewport == .active); + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row beyond active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + s.scroll(.{ .row = 1000 }); + + try testing.expect(s.viewport == .active); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row without scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + s.scroll(.{ .row = 5 }); + + try testing.expect(s.viewport == .active); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row then delta" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(30); + + s.scroll(.{ .row = 10 }); + + try testing.expect(s.viewport == .pin); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 10, + .len = s.rows, + }, s.scrollbar()); + + s.scroll(.{ .delta_row = 5 }); + + try testing.expect(s.viewport == .pin); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 15, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 15, + .len = s.rows, + }, s.scrollbar()); + + s.scroll(.{ .delta_row = -3 }); + + try testing.expect(s.viewport == .pin); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 12, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 12, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row with cache fast path down" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(50); + + s.scroll(.{ .row = 10 }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 10, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + // Verify cache is populated + try testing.expect(s.viewport_pin_row_offset != null); + try testing.expectEqual(@as(usize, 10), s.viewport_pin_row_offset.?); + + // Now scroll to a different row - this should use the fast path + s.scroll(.{ .row = 20 }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 20, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 20, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 20, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 20, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row with cache fast path up" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(50); + + s.scroll(.{ .row = 30 }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 30, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 30, + } }, pt); + } + + // Verify cache is populated + try testing.expect(s.viewport_pin_row_offset != null); + try testing.expectEqual(@as(usize, 30), s.viewport_pin_row_offset.?); + + // Now scroll up to a different row - this should use the fast path + s.scroll(.{ .row = 15 }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 15, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 15, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 15, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 15, + .len = s.rows, + }, s.scrollbar()); } test "PageList scroll clear" { @@ -4418,7 +5603,7 @@ test "PageList scroll clear" { } } -test "PageList: jump zero" { +test "PageList: jump zero prompts" { const testing = std.testing; const alloc = testing.allocator; @@ -4438,9 +5623,15 @@ test "PageList: jump zero" { s.scroll(.{ .delta_prompt = 0 }); try testing.expect(s.viewport == .active); + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); } -test "Screen: jump to prompt" { +test "Screen: jump back one prompt" { const testing = std.testing; const alloc = testing.allocator; @@ -4466,6 +5657,12 @@ test "Screen: jump to prompt" { .x = 0, .y = 1, } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 1, + .len = s.rows, + }, s.scrollbar()); } { s.scroll(.{ .delta_prompt = -1 }); @@ -4474,16 +5671,32 @@ test "Screen: jump to prompt" { .x = 0, .y = 1, } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 1, + .len = s.rows, + }, s.scrollbar()); } // Jump forward { s.scroll(.{ .delta_prompt = 1 }); try testing.expect(s.viewport == .active); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); } { s.scroll(.{ .delta_prompt = 1 }); try testing.expect(s.viewport == .active); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); } } @@ -4567,6 +5780,15 @@ test "PageList grow prune scrollback" { defer s.untrackPin(p); try testing.expect(p.node == s.pages.first.?); + // Scroll back to create a pinned viewport (not active) + const pin_y = page1.capacity.rows / 2; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + + // Get the scrollbar state to populate the cache + const scrollbar_before = s.scrollbar(); + try testing.expectEqual(pin_y, scrollbar_before.offset); + // Next should create a new page, but it should reuse our first // page since we're at max size. const new = (try s.grow()).?; @@ -4581,6 +5803,331 @@ test "PageList grow prune scrollback" { try testing.expect(p.node == s.pages.first.?); try testing.expect(p.x == 0); try testing.expect(p.y == 0); + try testing.expect(p.garbage); + + // Verify the viewport offset cache was invalidated. After pruning, + // the offset should have changed because we removed rows from + // the beginning. + { + const scrollbar_after = s.scrollbar(); + const rows_pruned = page1.capacity.rows; + const expected_offset = if (pin_y >= rows_pruned) + pin_y - rows_pruned + else + 0; + try testing.expectEqual(expected_offset, scrollbar_after.offset); + } +} + +test "PageList grow prune scrollback with viewport pin not in pruned page" { + const testing = std.testing; + const alloc = testing.allocator; + + // Zero here forces minimum max size to effectively two pages. + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + // Grow to capacity of first page + const page1_node = s.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + + // Grow and allocate second page, then fill it up + const page2_node = (try s.grow()).?; + const page2 = page2_node.data; + for (0..page2.capacity.rows - page2.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + + // Get our page size + const old_page_size = s.page_size; + + // Scroll back to create a pinned viewport in page2 (NOT page1) + // This is the key difference from the previous test - the viewport + // pin is NOT in the page that will be pruned. + const pin_y = page1.capacity.rows + 5; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expect(s.viewport_pin.node == page2_node); + + // Get the scrollbar state to populate the cache + const scrollbar_before = s.scrollbar(); + try testing.expectEqual(pin_y, scrollbar_before.offset); + + // Next grow will trigger pruning of the first page. + // The viewport_pin.node is page2, not page1, so it won't be moved + // by the pin update loop, but the cached offset still needs to be + // invalidated because rows were removed from the beginning. + const new = (try s.grow()).?; + try testing.expect(s.pages.last.? == new); + try testing.expectEqual(s.page_size, old_page_size); + + // Our first should now be page2 (page1 was pruned) + try testing.expectEqual(page2_node, s.pages.first.?); + + // The viewport pin should still be on page2, unchanged + try testing.expect(s.viewport_pin.node == page2_node); + + // Verify the viewport offset cache was invalidated/updated. + // After pruning, the offset should have decreased by the number + // of rows that were pruned. + const scrollbar_after = s.scrollbar(); + const rows_pruned = page1.capacity.rows; + const expected_offset = pin_y - rows_pruned; + try testing.expectEqual(expected_offset, scrollbar_after.offset); +} + +test "PageList eraseRows invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up several pages worth of history + const page = &s.pages.last.?.data; + { + var cur_page = s.pages.last.?; + for (0..page.capacity.rows * 3) |_| { + if (try s.grow()) |new_page| cur_page = new_page; + } + } + + // Scroll back to create a pinned viewport somewhere in the middle + // of the scrollback + const pin_y = page.capacity.rows; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Erase some history rows BEFORE the viewport pin. + // This removes rows from before our pin, which changes its absolute + // offset from the top, but the cache is not invalidated. + const rows_to_erase = page.capacity.rows / 2; + s.eraseRows( + .{ .history = .{} }, + .{ .history = .{ .y = rows_to_erase - 1 } }, + ); + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y - rows_to_erase, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList eraseRow invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up several pages worth of history + const page = &s.pages.last.?.data; + { + var cur_page = s.pages.last.?; + for (0..page.capacity.rows * 3) |_| { + if (try s.grow()) |new_page| cur_page = new_page; + } + } + + // Scroll back to create a pinned viewport somewhere in the middle + // of the scrollback + const pin_y = page.capacity.rows; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Erase a single row from the history BEFORE the viewport pin. + // This removes one row from before our pin, which changes its absolute + // offset from the top by 1, but the cache is not invalidated. + try s.eraseRow(.{ .history = .{ .y = 0 } }); + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y - 1, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList eraseRowBounded invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up several pages worth of history + const page = &s.pages.last.?.data; + { + var cur_page = s.pages.last.?; + for (0..page.capacity.rows * 3) |_| { + if (try s.grow()) |new_page| cur_page = new_page; + } + } + + // Scroll back to create a pinned viewport somewhere in the middle + // of the scrollback + const pin_y: u16 = 4; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Erase a row from the history BEFORE the viewport pin with a bounded + // shift. This removes one row from before our pin, which changes its + // absolute offset from the top by 1, but the cache is not invalidated. + try s.eraseRowBounded(.{ .history = .{ .y = 0 } }, 10); + + // Verify the scrollbar reflects the change (offset decreased by 1) + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y - 1, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList eraseRowBounded multi-page invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up several pages worth of history + const page = &s.pages.last.?.data; + { + var cur_page = s.pages.last.?; + for (0..page.capacity.rows * 3) |_| { + if (try s.grow()) |new_page| cur_page = new_page; + } + } + + // Scroll back to create a pinned viewport somewhere in the middle + // of the scrollback, after the first page + const pin_y = page.capacity.rows + 1; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Erase a row from the beginning of history with a limit that spans + // across multiple pages. This ensures we hit the code path where + // eraseRowBounded finds the limit boundary in a subsequent page. + const limit = page.capacity.rows + 10; + try s.eraseRowBounded(.{ .history = .{ .y = 0 } }, limit); + + // Verify the scrollbar reflects the change (offset decreased by 1) + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y - 1, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList eraseRowBounded full page shift invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up several pages worth of history + const page = &s.pages.last.?.data; + { + var cur_page = s.pages.last.?; + for (0..page.capacity.rows * 4) |_| { + if (try s.grow()) |new_page| cur_page = new_page; + } + } + + // Scroll back to create a pinned viewport somewhere well beyond + // the first two pages + const pin_y = 5; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Erase a row from the beginning of history with a limit that is + // larger than multiple full pages. This ensures we hit the code path + // where eraseRowBounded continues looping through entire pages, + // rotating all rows in each page until it reaches the limit or + // runs out of pages. + const limit = page.capacity.rows * 2 + 10; + try s.eraseRowBounded(.{ .history = .{ .y = 0 } }, limit); + + // Verify the scrollbar reflects the change (offset decreased by 1) + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y - 1, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList eraseRowBounded exhausts pages invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up several pages worth of history + const page = &s.pages.last.?.data; + { + var cur_page = s.pages.last.?; + for (0..page.capacity.rows * 3) |_| { + if (try s.grow()) |new_page| cur_page = new_page; + } + } + + // Our total rows should include history + const total_rows_before = s.totalRows(); + try testing.expect(total_rows_before > s.rows); + + // Scroll back to create a pinned viewport somewhere in the history, + // well after the erase will complete + const pin_y = page.capacity.rows * 2 + 10; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Erase a row from the beginning of history with a limit that is + // LARGER than all remaining pages combined. This ensures we exhaust + // all pages in the while loop and reach the cleanup code after the loop. + const limit = total_rows_before * 2; + try s.eraseRowBounded(.{ .history = .{ .y = 0 } }, limit); + + // Verify the scrollbar reflects the change (offset decreased by 1) + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y - 1, + .len = s.rows, + }, s.scrollbar()); } test "PageList adjustCapacity to increase styles" { @@ -4712,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; @@ -5017,11 +6597,11 @@ test "PageList erase" { try testing.expectEqual(@as(usize, 6), s.totalPages()); // Our total rows should be large - try testing.expect(s.totalRows() > s.rows); + try testing.expect(s.total_rows > s.rows); // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, null); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // We should be back to just one page try testing.expectEqual(@as(usize, 1), s.totalPages()); @@ -5076,7 +6656,7 @@ test "PageList erase row with tracked pin resets to top-left" { cur_page.data.pauseIntegrityChecks(false); // Our total rows should be large - try testing.expect(s.totalRows() > s.rows); + try testing.expect(s.total_rows > s.rows); // Put a tracked pin in the history const p = try s.trackPin(s.pin(.{ .history = .{} }).?); @@ -5084,7 +6664,7 @@ test "PageList erase row with tracked pin resets to top-left" { // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, null); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // Our pin should move to the first page try testing.expectEqual(s.pages.first.?, p.node); @@ -5105,7 +6685,7 @@ test "PageList erase row with tracked pin shifts" { // Erase only a few rows in our active s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 3 } }); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // Our pin should move to the first page try testing.expectEqual(s.pages.first.?, p.node); @@ -5126,7 +6706,7 @@ test "PageList erase row with tracked pin is erased" { // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 3 } }); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // Our pin should move to the first page try testing.expectEqual(s.pages.first.?, p.node); @@ -5155,9 +6735,8 @@ test "PageList erase resets viewport to active if moves within active" { cur_page.data.pauseIntegrityChecks(false); // Move our viewport to the top - s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); - try testing.expect(s.viewport == .pin); - try testing.expect(s.viewport_pin.node == s.pages.first.?); + s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); + try testing.expect(s.viewport == .top); // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, null); @@ -5185,14 +6764,12 @@ test "PageList erase resets viewport if inside erased page but not active" { cur_page.data.pauseIntegrityChecks(false); // Move our viewport to the top - s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); - try testing.expect(s.viewport == .pin); - try testing.expect(s.viewport_pin.node == s.pages.first.?); + s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); + try testing.expect(s.viewport == .top); // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, .{ .history = .{ .y = 2 } }); - try testing.expect(s.viewport == .pin); - try testing.expect(s.viewport_pin.node == s.pages.first.?); + try testing.expect(s.viewport == .top); } test "PageList erase resets viewport to active if top is inside active" { @@ -5253,7 +6830,7 @@ test "PageList erase a one-row active" { } s.eraseRows(.{ .active = .{} }, .{ .active = .{} }); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // The row should be empty { @@ -5282,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); @@ -5320,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); @@ -5345,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), @@ -5411,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), @@ -6096,14 +7668,16 @@ test "PageList resize (no reflow) more rows contains viewport" { // Set viewport above active by scrolling up one. s.scroll(.{ .delta_row = -1 }); // The viewport should be a pin now. - try testing.expectEqual(Viewport.pin, s.viewport); + try testing.expectEqual(Viewport.top, s.viewport); // Resize try s.resize(.{ .rows = 7, .reflow = false }); try testing.expectEqual(@as(usize, 7), s.rows); try testing.expectEqual(@as(usize, 7), s.totalRows()); - // The viewport should now be active, not a pin. - try testing.expectEqual(Viewport.active, s.viewport); + + // Question: maybe the viewport should actually be in the active + // here and not pinned to the top. + try testing.expectEqual(Viewport.top, s.viewport); } test "PageList resize (no reflow) less cols" { @@ -6657,6 +8231,55 @@ test "PageList resize reflow more cols wrapped rows" { } } +test "PageList resize reflow invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, null); + defer s.deinit(); + try s.growRows(20); + + const page = &s.pages.last.?.data; + for (0..s.rows) |y| { + if (y % 2 == 0) { + const rac = page.getRowAndCell(0, y); + rac.row.wrap = true; + } else { + const rac = page.getRowAndCell(0, y); + rac.row.wrap_continuation = true; + } + + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + } + + // Scroll to a pinned viewport in history + const pin_y = 10; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Resize with reflow - unwrapping rows changes total_rows + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + + // Verify scrollbar cache was invalidated during reflow + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 8, + .len = s.rows, + }, s.scrollbar()); +} + test "PageList resize reflow more cols creates multiple pages" { const testing = std.testing; const alloc = testing.allocator; @@ -9093,6 +10716,29 @@ test "PageList reset across two pages" { try testing.expectEqual(@as(usize, s.rows), s.totalRows()); } +test "PageList reset moves tracked pins and marks them as garbage" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Create a tracked pin into the active area + const p = try s.trackPin(s.pin(.{ .active = .{ + .x = 42, + .y = 12, + } }).?); + defer s.untrackPin(p); + + s.reset(); + + // Our added pin should now be garbage + try testing.expect(p.garbage); + + // Viewport pin should not be garbage because it makes sense. + try testing.expect(!s.viewport_pin.garbage); +} + test "PageList clears history" { const testing = std.testing; const alloc = testing.allocator; @@ -9112,3 +10758,86 @@ test "PageList clears history" { .x = 0, }, s.getTopLeft(.active)); } + +test "PageList resize reflow grapheme map capacity exceeded" { + // This test verifies that when reflowing content with many graphemes, + // the grapheme map capacity is correctly increased when needed. + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + + // Get the grapheme capacity from the page. We need more than this many + // graphemes in a single destination page to trigger capacity increase + // during reflow. Since each source page can only hold this many graphemes, + // we create two source pages with graphemes that will merge into one + // destination page. + const grapheme_capacity = s.pages.first.?.data.graphemeCapacity(); + // Use slightly more than half the capacity per page, so combined they + // exceed the capacity of a single destination page. + const graphemes_per_page = grapheme_capacity / 2 + grapheme_capacity / 4; + + // Grow to the capacity of the first page and add more rows + // so that we have two pages total. + { + const page = &s.pages.first.?.data; + page.pauseIntegrityChecks(true); + for (page.size.rows..page.capacity.rows) |_| { + _ = try s.grow(); + } + page.pauseIntegrityChecks(false); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + try s.growRows(graphemes_per_page); + try testing.expectEqual(@as(usize, 2), s.totalPages()); + + // We now have two pages. + try testing.expect(s.pages.first.? != s.pages.last.?); + try testing.expectEqual(s.pages.last.?, s.pages.first.?.next); + } + + // Add graphemes to both pages. We add graphemes to rows at the END of the + // first page, and graphemes to rows at the START of the second page. + // When reflowing to 2 columns, these rows will wrap and stay together + // on the same destination page, requiring capacity increase. + + // Add graphemes to the end of the first page (last rows) + { + const page = &s.pages.first.?.data; + const start_row = page.size.rows - graphemes_per_page; + for (0..graphemes_per_page) |i| { + const y = start_row + i; + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + try page.appendGrapheme(rac.row, rac.cell, @as(u21, @intCast(0x0301))); + } + } + + // Add graphemes to the beginning of the second page + { + const page = &s.pages.last.?.data; + const count = @min(graphemes_per_page, page.size.rows); + for (0..count) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'B' }, + }; + try page.appendGrapheme(rac.row, rac.cell, @as(u21, @intCast(0x0302))); + } + } + + // Resize to fewer columns to trigger reflow. + // The graphemes from both pages will be copied to destination pages. + // They will all end up in a contiguous region of the destination. + // If the bug exists (hyperlink_bytes increased instead of grapheme_bytes), + // this will fail with GraphemeMapOutOfMemory when we exceed capacity. + try s.resize(.{ .cols = 2, .reflow = true }); + + // Verify the resize succeeded + try testing.expectEqual(@as(usize, 2), s.cols); +} diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index ca2fd3718..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"); @@ -127,6 +125,14 @@ pub const Action = union(enum) { intermediates: []const u8 = "", params: []const u16 = &.{}, final: u8, + + pub const C = extern struct { + intermediates: [*]const u8, + intermediates_len: usize, + params: [*]const u16, + params_len: usize, + final: u8, + }; }; // Implement formatter for logging. This is mostly copied from the @@ -221,7 +227,7 @@ pub fn init() Parser { .params_idx = 0, .param_acc = 0, .param_acc_idx = 0, - .osc_parser = .init(), + .osc_parser = .init(null), .intermediates = undefined, .params = undefined, @@ -304,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; } @@ -340,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 @@ -378,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 a98407af7..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"); @@ -13,6 +13,7 @@ const unicode = @import("../unicode/main.zig"); const Selection = @import("Selection.zig"); const PageList = @import("PageList.zig"); const StringMap = @import("StringMap.zig"); +const ScreenFormatter = @import("formatter.zig").ScreenFormatter; const pagepkg = @import("page.zig"); const point = @import("point.zig"); const size = @import("size.zig"); @@ -87,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, @@ -160,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. @@ -171,7 +172,63 @@ 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 { + cols: size.CellCountInt, + rows: size.CellCountInt, + + /// The maximum size of scrollback in bytes. Zero means unlimited. Any + /// other value will be clamped to support a minimum of the active area. + max_scrollback: usize = 0, + + /// The total storage limit for Kitty images in bytes for this + /// screen. Kitty image storage is per-screen. + kitty_image_storage_limit: usize = 320 * 1000 * 1000, // 320MB + + /// A simple, default terminal. If you rely on specific dimensions or + /// scrollback (or lack of) then do not use this directly. This is just + /// for callers that need some defaults. + pub const default: Options = .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }; }; /// Initialize a new screen. @@ -183,12 +240,15 @@ pub const CharsetState = struct { /// If max scrollback is 0, then no scrollback is kept at all. pub fn init( alloc: Allocator, - cols: size.CellCountInt, - rows: size.CellCountInt, - max_scrollback: usize, + opts: Options, ) !Screen { // Initialize our backing pages. - var pages = try PageList.init(alloc, cols, rows, max_scrollback); + var pages = try PageList.init( + alloc, + opts.cols, + opts.rows, + opts.max_scrollback, + ); errdefer pages.deinit(); // Create our tracked pin for the cursor. @@ -196,10 +256,10 @@ pub fn init( errdefer pages.untrackPin(page_pin); const page_rac = page_pin.rowAndCell(); - return .{ + var result: Screen = .{ .alloc = alloc, .pages = pages, - .no_scrollback = max_scrollback == 0, + .no_scrollback = opts.max_scrollback == 0, .cursor = .{ .x = 0, .y = 0, @@ -208,6 +268,18 @@ pub fn init( .page_cell = page_rac.cell, }, }; + + if (comptime build_options.kitty_graphics) { + // This can't fail because the storage is always empty at this point + // and the only fail-able case is that we have to evict images. + result.kitty_images.setLimit( + alloc, + &result, + opts.kitty_image_storage_limit, + ) catch unreachable; + } + + return result; } pub fn deinit(self: *Screen) void { @@ -714,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 = .{} }); @@ -725,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.*; @@ -814,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. @@ -859,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 @@ -926,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 @@ -943,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. @@ -1012,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, }; @@ -1039,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(); } @@ -1109,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. @@ -1155,6 +1219,7 @@ pub const Scroll = union(enum) { active, top, pin: Pin, + row: usize, delta_row: isize, delta_prompt: isize, }; @@ -1174,6 +1239,7 @@ pub inline fn scroll(self: *Screen, behavior: Scroll) void { .active => self.pages.scroll(.{ .active = {} }), .top => self.pages.scroll(.{ .top = {} }), .pin => |p| self.pages.scroll(.{ .pin = p }), + .row => |v| self.pages.scroll(.{ .row = v }), .delta_row => |v| self.pages.scroll(.{ .delta_row = v }), .delta_prompt => |v| self.pages.scroll(.{ .delta_prompt = v }), } @@ -1235,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); @@ -1254,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, @@ -1286,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) { @@ -1738,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, @@ -2112,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(); + } }, } } @@ -2168,163 +2259,51 @@ pub const SelectionString = struct { /// Returns the raw text associated with a selection. This will unwrap /// soft-wrapped edges. The returned slice is owned by the caller and allocated /// using alloc, not the allocator associated with the screen (unless they match). +/// +/// For more flexibility, use a ScreenFormatter directly. pub fn selectionString( self: *Screen, alloc: Allocator, opts: SelectionString, ) ![:0]const u8 { - // Use an ArrayList so that we can grow the array as we go. We - // build an initial capacity of just our rows in our selection times - // columns. It can be more or less based on graphemes, newlines, etc. - var strbuilder: std.ArrayList(u8) = .empty; - defer strbuilder.deinit(alloc); + // We'll use this as our buffer to build our string. + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); - // If we're building a stringmap, create our builder for the pins. - const MapBuilder = std.ArrayList(Pin); - var mapbuilder: ?MapBuilder = if (opts.map != null) .empty else null; - defer if (mapbuilder) |*b| b.deinit(alloc); + // Create a formatter and use that to emit our text. + var formatter: ScreenFormatter = .init( + self, + .{ + .emit = .plain, + .unwrap = true, + .trim = opts.trim, + }, + ); + formatter.content = .{ .selection = opts.sel }; - const sel_ordered = opts.sel.ordered(self, .forward); - const sel_start: Pin = start: { - var start: Pin = sel_ordered.start(); - const cell = start.rowAndCell().cell; - if (cell.wide == .spacer_tail) start.x -= 1; - break :start start; - }; - const sel_end: Pin = end: { - var end: Pin = sel_ordered.end(); - const cell = end.rowAndCell().cell; - switch (cell.wide) { - .narrow, .wide => {}, - - // We can omit the tail - .spacer_tail => end.x -= 1, - - // With the head we want to include the wrapped wide character. - .spacer_head => if (end.down(1)) |p| { - end = p; - end.x = 0; - }, - } - break :end end; + // If we have a string map, we need to set that up. + var pins: std.ArrayList(Pin) = .empty; + defer pins.deinit(alloc); + if (opts.map != null) formatter.pin_map = .{ + .alloc = alloc, + .map = &pins, }; - var page_it = sel_start.pageIterator(.right_down, sel_end); - while (page_it.next()) |chunk| { - const rows = chunk.rows(); - for (rows, chunk.start.., 0..) |row, y, row_i| { - const cells_ptr = row.cells.ptr(chunk.node.data.memory); + // Emit + try formatter.format(&aw.writer); - const start_x = if ((row_i == 0 or sel_ordered.rectangle) and - sel_start.node == chunk.node) - sel_start.x - else - 0; - const end_x = if ((row_i == rows.len - 1 or sel_ordered.rectangle) and - sel_end.node == chunk.node) - sel_end.x + 1 - else - self.pages.cols; - - const cells = cells_ptr[start_x..end_x]; - for (cells, start_x..) |*cell, x| { - // Skip wide spacers - switch (cell.wide) { - .narrow, .wide => {}, - .spacer_head, .spacer_tail => continue, - } - - var buf: [4]u8 = undefined; - { - const raw: u21 = if (cell.hasText()) cell.content.codepoint else 0; - const char = if (raw > 0) raw else ' '; - const encode_len = try std.unicode.utf8Encode(char, &buf); - try strbuilder.appendSlice(alloc, buf[0..encode_len]); - if (mapbuilder) |*b| { - for (0..encode_len) |_| try b.append(alloc, .{ - .node = chunk.node, - .y = @intCast(y), - .x = @intCast(x), - }); - } - } - if (cell.hasGrapheme()) { - const cps = chunk.node.data.lookupGrapheme(cell).?; - for (cps) |cp| { - const encode_len = try std.unicode.utf8Encode(cp, &buf); - try strbuilder.appendSlice(alloc, buf[0..encode_len]); - if (mapbuilder) |*b| { - for (0..encode_len) |_| try b.append(alloc, .{ - .node = chunk.node, - .y = @intCast(y), - .x = @intCast(x), - }); - } - } - } - } - - const is_final_row = chunk.node == sel_end.node and y == sel_end.y; - - if (!is_final_row and - (!row.wrap or sel_ordered.rectangle)) - { - try strbuilder.append(alloc, '\n'); - if (mapbuilder) |*b| try b.append(alloc, .{ - .node = chunk.node, - .y = @intCast(y), - .x = chunk.node.data.size.cols - 1, - }); - } - } + // Build our final text and if we have a string map set that up. + const text = try aw.toOwnedSliceSentinel(0); + errdefer alloc.free(text); + if (opts.map) |map| { + map.* = .{ + .string = try alloc.dupeZ(u8, text), + .map = try pins.toOwnedSlice(alloc), + }; } + errdefer if (opts.map) |m| m.deinit(alloc); - if (comptime std.debug.runtime_safety) { - if (mapbuilder) |b| assert(strbuilder.items.len == b.items.len); - } - - // If we have a mapbuilder, we need to setup our string map. - if (mapbuilder) |*b| { - var strclone = try strbuilder.clone(alloc); - defer strclone.deinit(alloc); - const str = try strclone.toOwnedSliceSentinel(alloc, 0); - errdefer alloc.free(str); - const map = try b.toOwnedSlice(alloc); - errdefer alloc.free(map); - opts.map.?.* = .{ .string = str, .map = map }; - } - - // Remove any trailing spaces on lines. We could do optimize this by - // doing this in the loop above but this isn't very hot path code and - // this is simple. - if (opts.trim) { - var it = std.mem.tokenizeScalar(u8, strbuilder.items, '\n'); - - // Reset our items. We retain our capacity. Because we're only - // removing bytes, we know that the trimmed string must be no longer - // than the original string so we copy directly back into our - // allocated memory. - strbuilder.clearRetainingCapacity(); - while (it.next()) |line| { - const trimmed = std.mem.trimRight(u8, line, " \t"); - const i = strbuilder.items.len; - strbuilder.items.len += trimmed.len; - std.mem.copyForwards(u8, strbuilder.items[i..], trimmed); - try strbuilder.append(alloc, '\n'); - } - - // Remove all trailing newlines - for (0..strbuilder.items.len) |_| { - if (strbuilder.items[strbuilder.items.len - 1] != '\n') break; - strbuilder.items.len -= 1; - } - } - - // Get our final string - const string = try strbuilder.toOwnedSliceSentinel(alloc, 0); - errdefer alloc.free(string); - - return string; + return text; } pub const SelectLine = struct { @@ -2565,6 +2544,7 @@ pub fn selectWord(self: *Screen, pin: Pin) ?Selection { '`', '|', ':', + ';', ',', '(', ')', @@ -2907,9 +2887,38 @@ pub fn promptPath( pub fn dumpString( self: *const Screen, writer: *std.Io.Writer, - opts: PageList.EncodeUtf8Options, -) anyerror!void { - try self.pages.encodeUtf8(writer, opts); + opts: struct { + /// The start and end points of the dump, both inclusive. The x will + /// be ignored and the full row will always be dumped. + tl: Pin, + br: ?Pin = null, + + /// If true, this will unwrap soft-wrapped lines. If false, this will + /// dump the screen as it is visually seen in a rendered window. + unwrap: bool = true, + }, +) std.Io.Writer.Error!void { + // Create a formatter and use that to emit our text. + var formatter: ScreenFormatter = .init(self, .{ + .emit = .plain, + .unwrap = opts.unwrap, + .trim = false, + }); + + // Set up the selection based on the pins + const tl = opts.tl; + const br = opts.br orelse self.pages.getBottomRight(.screen).?; + + formatter.content = .{ + .selection = Selection.init( + tl, + br, + false, // not rectangle + ), + }; + + // Emit + try formatter.format(writer); } /// You should use dumpString, this is a restricted version mostly for @@ -3107,7 +3116,7 @@ test "Screen read and write" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); @@ -3121,7 +3130,7 @@ test "Screen read and write newline" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); @@ -3135,7 +3144,7 @@ test "Screen read and write scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 2, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 2, .max_scrollback = 1000 }); defer s.deinit(); try s.testWriteString("hello\nworld\ntest"); @@ -3155,7 +3164,7 @@ test "Screen read and write no scrollback small" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 2, 0); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("hello\nworld\ntest"); @@ -3175,7 +3184,7 @@ test "Screen read and write no scrollback large" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 2, 0); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); for (0..1_000) |i| { @@ -3196,13 +3205,13 @@ test "Screen cursorCopy x/y" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 10, 10, 0); + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); s.cursorAbsolute(2, 3); try testing.expect(s.cursor.x == 2); try testing.expect(s.cursor.y == 3); - var s2 = try Screen.init(alloc, 10, 10, 0); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); try s2.cursorCopy(s.cursor, .{}); try testing.expect(s2.cursor.x == 2); @@ -3220,10 +3229,10 @@ test "Screen cursorCopy style deref" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 10, 10, 0); + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); - var s2 = try Screen.init(alloc, 10, 10, 0); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data; @@ -3242,10 +3251,10 @@ test "Screen cursorCopy style deref new page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); - var s2 = try Screen.init(alloc, 10, 10, 2048); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 2048 }); defer s2.deinit(); // We need to get the cursor on a new page. @@ -3315,11 +3324,11 @@ test "Screen cursorCopy style copy" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 10, 10, 0); + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.setAttribute(.{ .bold = {} }); - var s2 = try Screen.init(alloc, 10, 10, 0); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data; try s2.cursorCopy(s.cursor, .{}); @@ -3331,10 +3340,10 @@ test "Screen cursorCopy hyperlink deref" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 10, 10, 0); + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); - var s2 = try Screen.init(alloc, 10, 10, 0); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data; @@ -3353,10 +3362,10 @@ test "Screen cursorCopy hyperlink deref new page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); - var s2 = try Screen.init(alloc, 10, 10, 2048); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 2048 }); defer s2.deinit(); // We need to get the cursor on a new page. @@ -3426,7 +3435,7 @@ test "Screen cursorCopy hyperlink copy" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 10, 10, 0); + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); // Create a hyperlink for the cursor. @@ -3434,7 +3443,7 @@ test "Screen cursorCopy hyperlink copy" { try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); - var s2 = try Screen.init(alloc, 10, 10, 0); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data; @@ -3451,7 +3460,7 @@ test "Screen cursorCopy hyperlink copy disabled" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 10, 10, 0); + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); // Create a hyperlink for the cursor. @@ -3459,7 +3468,7 @@ test "Screen cursorCopy hyperlink copy disabled" { try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); - var s2 = try Screen.init(alloc, 10, 10, 0); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data; @@ -3476,7 +3485,7 @@ test "Screen style basics" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); const page = &s.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count()); @@ -3498,7 +3507,7 @@ test "Screen style reset to default" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); const page = &s.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count()); @@ -3518,7 +3527,7 @@ test "Screen style reset with unset" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); const page = &s.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count()); @@ -3538,7 +3547,7 @@ test "Screen clearRows active one line" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try s.testWriteString("hello, world"); @@ -3553,7 +3562,7 @@ test "Screen clearRows active multi line" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try s.testWriteString("hello\nworld"); @@ -3569,7 +3578,7 @@ test "Screen clearRows active styled line" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try s.setAttribute(.{ .bold = {} }); @@ -3594,7 +3603,7 @@ test "Screen clearRows protected" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try s.testWriteString("UNPROTECTED"); @@ -3622,7 +3631,7 @@ test "Screen eraseRows history" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 5, 5, 1000); + var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 1000 }); defer s.deinit(); try s.testWriteString("1\n2\n3\n4\n5\n6"); @@ -3656,7 +3665,7 @@ test "Screen eraseRows history with more lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 5, 5, 1000); + var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 1000 }); defer s.deinit(); try s.testWriteString("A\nB\nC\n1\n2\n3\n4\n5\n6"); @@ -3690,7 +3699,7 @@ test "Screen eraseRows active partial" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1\n2\n3"); @@ -3719,7 +3728,7 @@ test "Screen: clearPrompt" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); // Set one of the rows to be a prompt @@ -3740,7 +3749,7 @@ test "Screen: clearPrompt continuation" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 4, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 0 }); defer s.deinit(); // Set one of the rows to be a prompt followed by a continuation row @@ -3762,7 +3771,7 @@ test "Screen: clearPrompt consecutive inputs" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); // Set both rows to be inputs @@ -3783,7 +3792,7 @@ test "Screen: clearPrompt no prompt" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -3801,7 +3810,7 @@ test "Screen: cursorDown across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); // Scroll down enough to go to another page @@ -3853,7 +3862,7 @@ test "Screen: cursorUp across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); // Scroll down enough to go to another page @@ -3900,7 +3909,7 @@ test "Screen: cursorAbsolute across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); // Scroll down enough to go to another page @@ -3955,7 +3964,7 @@ test "Screen: cursorAbsolute to page with insufficient capacity" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); // Scroll down enough to go to another page @@ -4022,7 +4031,7 @@ test "Screen: scrolling" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4064,7 +4073,7 @@ test "Screen: scrolling with a single-row screen no scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 1, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 1, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABCD"); @@ -4084,7 +4093,7 @@ test "Screen: scrolling with a single-row screen with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 1, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 1, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD"); @@ -4114,7 +4123,7 @@ test "Screen: scrolling across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.setAttribute(.{ .bold = {} }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4143,7 +4152,7 @@ test "Screen: scroll down from 0" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4162,7 +4171,7 @@ test "Screen: scrollback various cases" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); try s.cursorDownScroll(); @@ -4243,7 +4252,7 @@ test "Screen: scrollback with multi-row delta" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 3); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); @@ -4269,7 +4278,7 @@ test "Screen: scrollback empty" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 50); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 50 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); s.scroll(.{ .delta_row = 1 }); @@ -4284,7 +4293,7 @@ test "Screen: scrollback doesn't move viewport if not at bottom" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 3); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"); @@ -4319,7 +4328,7 @@ test "Screen: scrolling moves selection" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4398,7 +4407,7 @@ test "Screen: scrolling moves viewport" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4423,7 +4432,7 @@ test "Screen: scrolling when viewport is pruned" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 215, 3, 1); + var s = try init(alloc, .{ .cols = 215, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); // Write some to create scrollback and move back into our scrollback. @@ -4449,7 +4458,7 @@ test "Screen: scroll and clear full screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 5); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4476,7 +4485,7 @@ test "Screen: scroll and clear partial screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 5); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); @@ -4503,7 +4512,7 @@ test "Screen: scroll and clear empty screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 5); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); try s.scrollClear(); { @@ -4522,7 +4531,7 @@ test "Screen: scroll and clear ignore blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); try s.scrollClear(); @@ -4565,7 +4574,7 @@ test "Screen: scroll above same page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4624,7 +4633,7 @@ test "Screen: scroll above same page but cursor on previous page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); defer s.deinit(); // We need to get the cursor to a new page @@ -4705,7 +4714,7 @@ test "Screen: scroll above same page but cursor on previous page last row" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); defer s.deinit(); // We need to get the cursor to a new page @@ -4795,7 +4804,7 @@ test "Screen: scroll above creates new page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); // We need to get the cursor to a new page @@ -4867,7 +4876,7 @@ test "Screen: scroll above with cursor on non-final row" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 4, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 4, .max_scrollback = 10 }); defer s.deinit(); // Get the cursor to be 2 rows above a new page @@ -4944,7 +4953,7 @@ test "Screen: scroll above no scrollback bottom of page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const first_page_size = s.pages.pages.first.?.data.capacity.rows; @@ -5009,7 +5018,7 @@ test "Screen: clone" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); { @@ -5051,7 +5060,7 @@ test "Screen: clone partial" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); { @@ -5080,7 +5089,7 @@ test "Screen: clone partial cursor out of bounds" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); { @@ -5113,7 +5122,7 @@ test "Screen: clone contains full selection" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5150,7 +5159,7 @@ test "Screen: clone contains none of selection" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5177,7 +5186,7 @@ test "Screen: clone contains selection start cutoff" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5214,7 +5223,7 @@ test "Screen: clone contains selection end cutoff" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5251,7 +5260,7 @@ test "Screen: clone contains selection end cutoff reversed" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5288,7 +5297,7 @@ test "Screen: clone contains subset of selection" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 4, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); @@ -5325,7 +5334,7 @@ test "Screen: clone contains subset of rectangle selection" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 4, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); @@ -5364,7 +5373,7 @@ test "Screen: clone basic" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5401,7 +5410,7 @@ test "Screen: clone empty viewport" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); { @@ -5423,7 +5432,7 @@ test "Screen: clone one line viewport" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABC"); @@ -5446,7 +5455,7 @@ test "Screen: clone empty active" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); { @@ -5468,7 +5477,7 @@ test "Screen: clone one line active with extra space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABC"); @@ -5491,7 +5500,7 @@ test "Screen: clear history with no history" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 3); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); try testing.expect(s.pages.viewport == .active); @@ -5515,7 +5524,7 @@ test "Screen: clear history" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 3); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); try testing.expect(s.pages.viewport == .active); @@ -5549,7 +5558,7 @@ test "Screen: clear above cursor" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 3); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); s.clearRows( @@ -5576,7 +5585,7 @@ test "Screen: clear above cursor with history" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 3); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); @@ -5604,7 +5613,7 @@ test "Screen: resize (no reflow) more rows" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5622,7 +5631,7 @@ test "Screen: resize (no reflow) less rows" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5645,7 +5654,7 @@ test "Screen: resize (no reflow) less rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD"; try s.testWriteString(str); @@ -5680,7 +5689,7 @@ test "Screen: resize (no reflow) more rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD"; try s.testWriteString(str); @@ -5715,7 +5724,7 @@ test "Screen: resize (no reflow) more cols" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5732,7 +5741,7 @@ test "Screen: resize (no reflow) less cols" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5750,7 +5759,7 @@ test "Screen: resize (no reflow) more rows with scrollback cursor end" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 7, 3, 2); + var s = try init(alloc, .{ .cols = 7, .rows = 3, .max_scrollback = 2 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -5767,7 +5776,7 @@ test "Screen: resize (no reflow) less rows with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 7, 3, 2); + var s = try init(alloc, .{ .cols = 7, .rows = 3, .max_scrollback = 2 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -5786,7 +5795,7 @@ test "Screen: resize (no reflow) less rows with empty trailing" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str); @@ -5810,7 +5819,7 @@ test "Screen: resize (no reflow) more rows with soft wrapping" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 3, 3); + var s = try init(alloc, .{ .cols = 2, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); const str = "1A2B\n3C4E\n5F6G"; try s.testWriteString(str); @@ -5851,7 +5860,7 @@ test "Screen: resize more rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5878,7 +5887,7 @@ test "Screen: resize more rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 10); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5905,7 +5914,7 @@ test "Screen: resize more rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -5950,7 +5959,7 @@ test "Screen: resize more cols no reflow" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5979,7 +5988,7 @@ test "Screen: resize more cols perfect split" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); @@ -5997,7 +6006,7 @@ test "Screen: resize (no reflow) more cols with scrollback scrolled up" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str); @@ -6030,7 +6039,7 @@ test "Screen: resize (no reflow) less cols with scrollback scrolled up" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str); @@ -6074,7 +6083,7 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); // Set one of the rows to be a prompt @@ -6115,7 +6124,7 @@ test "Screen: resize more cols with reflow that fits full width" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6155,7 +6164,7 @@ test "Screen: resize more cols with reflow that ends in newline" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 6, 3, 0); + var s = try init(alloc, .{ .cols = 6, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6200,7 +6209,7 @@ test "Screen: resize more cols with reflow that forces more wrapping" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6241,7 +6250,7 @@ test "Screen: resize more cols with reflow that unwraps multiple times" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); @@ -6282,7 +6291,7 @@ test "Screen: resize more cols with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH"; try s.testWriteString(str); @@ -6326,7 +6335,7 @@ test "Screen: resize more cols with reflow" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 3, 5); + var s = try init(alloc, .{ .cols = 2, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1ABC\n2DEF\n3ABC\n4DEF"; try s.testWriteString(str); @@ -6367,7 +6376,7 @@ test "Screen: resize more rows and cols with wrapping" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 4, 0); + var s = try init(alloc, .{ .cols = 2, .rows = 4, .max_scrollback = 0 }); defer s.deinit(); const str = "1A2B\n3C4D"; try s.testWriteString(str); @@ -6400,7 +6409,7 @@ test "Screen: resize less rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6431,7 +6440,7 @@ test "Screen: resize less rows moving cursor" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6471,7 +6480,7 @@ test "Screen: resize less rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 10); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6494,7 +6503,7 @@ test "Screen: resize less rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -6525,7 +6534,7 @@ test "Screen: resize less rows with full scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 3); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); const str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -6565,7 +6574,7 @@ test "Screen: resize less cols no reflow" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1AB\n2EF\n3IJ"; try s.testWriteString(str); @@ -6594,7 +6603,7 @@ test "Screen: resize less cols with reflow but row space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); const str = "1ABCD"; try s.testWriteString(str); @@ -6632,7 +6641,7 @@ test "Screen: resize less cols with reflow with trimmed rows" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -6656,7 +6665,7 @@ test "Screen: resize less cols with reflow with trimmed rows and scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -6680,7 +6689,7 @@ test "Screen: resize less cols with reflow previously wrapped" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "3IJKL4ABCD5EFGH"; try s.testWriteString(str); @@ -6713,7 +6722,7 @@ test "Screen: resize less cols with reflow and scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1A\n2B\n3C\n4D\n5E"; try s.testWriteString(str); @@ -6746,7 +6755,7 @@ test "Screen: resize less cols with reflow previously wrapped and scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 2); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 2 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL4ABCD5EFGH"; try s.testWriteString(str); @@ -6800,7 +6809,7 @@ test "Screen: resize less cols with scrollback keeps cursor row" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1A\n2B\n3C\n4D\n5E"; try s.testWriteString(str); @@ -6829,7 +6838,7 @@ test "Screen: resize more rows, less cols with reflow with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 3); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); const str = "1ABCD\n2EFGH3IJKL\n4MNOP"; try s.testWriteString(str); @@ -6870,7 +6879,7 @@ test "Screen: resize more rows then shrink again" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 10); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); const str = "1ABC"; try s.testWriteString(str); @@ -6919,7 +6928,7 @@ test "Screen: resize less cols to eliminate wide char" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 1, 0); + var s = try init(alloc, .{ .cols = 2, .rows = 1, .max_scrollback = 0 }); defer s.deinit(); const str = "😀"; try s.testWriteString(str); @@ -6954,7 +6963,7 @@ test "Screen: resize less cols to wrap wide char" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 3, 0); + var s = try init(alloc, .{ .cols = 3, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "x😀"; try s.testWriteString(str); @@ -6993,7 +7002,7 @@ test "Screen: resize less cols to eliminate wide char with row space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 2, 0); + var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); const str = "😀"; try s.testWriteString(str); @@ -7026,7 +7035,7 @@ test "Screen: resize more cols with wide spacer head" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 2, 0); + var s = try init(alloc, .{ .cols = 3, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); const str = " 😀"; try s.testWriteString(str); @@ -7079,7 +7088,7 @@ test "Screen: resize more cols with wide spacer head multiple lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 3, 0); + var s = try init(alloc, .{ .cols = 3, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "xxxyy😀"; try s.testWriteString(str); @@ -7130,7 +7139,7 @@ test "Screen: resize more cols requiring a wide spacer head" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 2, 0); + var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); const str = "xx😀"; try s.testWriteString(str); @@ -7181,7 +7190,7 @@ test "Screen: select untracked" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456"); @@ -7201,7 +7210,7 @@ test "Screen: selectAll" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); { @@ -7237,7 +7246,7 @@ test "Screen: selectLine" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456"); @@ -7318,7 +7327,7 @@ test "Screen: selectLine across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123"); @@ -7344,7 +7353,7 @@ test "Screen: selectLine across full soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABCD2EFGH\n3IJKL"); @@ -7369,7 +7378,7 @@ test "Screen: selectLine across soft-wrap ignores blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123"); @@ -7429,7 +7438,7 @@ test "Screen: selectLine disabled whitespace trimming" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123"); @@ -7478,7 +7487,7 @@ test "Screen: selectLine with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 3, 5); + var s = try init(alloc, .{ .cols = 2, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); @@ -7522,7 +7531,7 @@ test "Screen: selectLine semantic prompt boundary" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteSemanticString("ABCDE\n", .unknown); try s.testWriteSemanticString("A ", .prompt); @@ -7571,7 +7580,7 @@ test "Screen: selectWord" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456"); @@ -7686,7 +7695,7 @@ test "Screen: selectWord across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(" 1234012\n 123"); @@ -7752,7 +7761,7 @@ test "Screen: selectWord whitespace across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1 1\n 123"); @@ -7819,6 +7828,7 @@ test "Screen: selectWord with character boundary" { " `abc` \n123", " |abc| \n123", " :abc: \n123", + " ;abc; \n123", " ,abc, \n123", " (abc( \n123", " )abc) \n123", @@ -7832,7 +7842,7 @@ test "Screen: selectWord with character boundary" { }; for (cases) |case| { - var s = try init(alloc, 20, 10, 0); + var s = try init(alloc, .{ .cols = 20, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(case); @@ -7912,7 +7922,7 @@ test "Screen: selectOutput" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 15, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); // zig fmt: off @@ -7986,7 +7996,7 @@ test "Screen: selectOutput" { // input / prompt at y = 0, pt.y = 0 { s.deinit(); - s = try init(alloc, 10, 5, 0); + s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); try s.testWriteSemanticString("$ ", .prompt); try s.testWriteSemanticString("input1\n", .input); try s.testWriteSemanticString("output1\n", .command); @@ -8002,7 +8012,7 @@ test "Screen: selectPrompt basics" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 15, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); // zig fmt: off @@ -8077,7 +8087,7 @@ test "Screen: selectPrompt prompt at start" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 15, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); // zig fmt: off @@ -8121,7 +8131,7 @@ test "Screen: selectPrompt prompt at end" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 15, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); // zig fmt: off @@ -8165,7 +8175,7 @@ test "Screen: promptPath" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 15, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); // zig fmt: off @@ -8240,7 +8250,7 @@ test "Screen: selectionString basic" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -8265,7 +8275,7 @@ test "Screen: selectionString start outside of written area" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -8290,7 +8300,7 @@ test "Screen: selectionString end outside of written area" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -8315,7 +8325,7 @@ test "Screen: selectionString trim space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1AB \n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -8352,7 +8362,7 @@ test "Screen: selectionString trim empty line" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1AB \n\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -8380,7 +8390,7 @@ test "Screen: selectionString trim empty line" { .trim = false, }); defer alloc.free(contents); - const expected = "1AB \n \n2EF"; + const expected = "1AB \n\n2EF"; try testing.expectEqualStrings(expected, contents); } } @@ -8389,7 +8399,7 @@ test "Screen: selectionString soft wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); @@ -8414,7 +8424,7 @@ test "Screen: selectionString wide char" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1A⚡"; try s.testWriteString(str); @@ -8469,7 +8479,7 @@ test "Screen: selectionString wide char with header" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABC⚡"; try s.testWriteString(str); @@ -8495,7 +8505,7 @@ test "Screen: selectionString empty with soft wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 2, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); // Let me describe the situation that caused this because this @@ -8528,7 +8538,7 @@ test "Screen: selectionString with zero width joiner" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 1, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 1, .max_scrollback = 0 }); defer s.deinit(); const str = "👨‍"; // this has a ZWJ try s.testWriteString(str); @@ -8564,7 +8574,7 @@ test "Screen: selectionString, rectangle, basic" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 30, 5, 0); + var s = try init(alloc, .{ .cols = 30, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = \\Lorem ipsum dolor @@ -8597,7 +8607,7 @@ test "Screen: selectionString, rectangle, w/EOL" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 30, 5, 0); + var s = try init(alloc, .{ .cols = 30, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = \\Lorem ipsum dolor @@ -8632,7 +8642,7 @@ test "Screen: selectionString, rectangle, more complex w/breaks" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 30, 8, 0); + var s = try init(alloc, .{ .cols = 30, .rows = 8, .max_scrollback = 0 }); defer s.deinit(); const str = \\Lorem ipsum dolor @@ -8671,7 +8681,7 @@ test "Screen: selectionString multi-page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 2048); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 2048 }); defer s.deinit(); const first_page_size = s.pages.pages.first.?.data.capacity.rows; @@ -8705,7 +8715,7 @@ test "Screen: lineIterator" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH"; try s.testWriteString(str); @@ -8736,7 +8746,7 @@ test "Screen: lineIterator soft wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3ABCD"; try s.testWriteString(str); @@ -8768,7 +8778,7 @@ test "Screen: hyperlink start/end" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try testing.expect(s.cursor.hyperlink_id == 0); { @@ -8795,7 +8805,7 @@ test "Screen: hyperlink reuse" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try testing.expect(s.cursor.hyperlink_id == 0); @@ -8833,7 +8843,7 @@ test "Screen: hyperlink cursor state on resize" { // it may be invalid one day. It's here to document/verify the // current behavior. - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); // Start a hyperlink @@ -8864,7 +8874,7 @@ test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 0); + var s = try init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); defer s.deinit(); // Start a hyperlink with a URI that just barely fits in the string alloc. @@ -8898,7 +8908,7 @@ test "Screen: adjustCapacity cursor style ref count" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try s.setAttribute(.{ .bold = {} }); @@ -8932,7 +8942,7 @@ test "Screen: adjustCapacity cursor hyperlink exceeds string alloc size" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 0); + var s = try init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); defer s.deinit(); // Start a hyperlink with a URI that just barely fits in the string alloc. @@ -8974,7 +8984,7 @@ test "Screen: adjustCapacity cursor style exceeds style set capacity" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 1000); + var s = try init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); const page = &s.cursor.page_pin.node.data; @@ -9023,81 +9033,3 @@ test "Screen: adjustCapacity cursor style exceeds style set capacity" { try testing.expect(s.cursor.style.default()); try testing.expectEqual(style.default_id, s.cursor.style_id); } - -test "Screen UTF8 cell map with newlines" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 0); - defer s.deinit(); - try s.testWriteString("A\n\nB\n\nC"); - - var cell_map = Page.CellMap.init(alloc); - defer cell_map.deinit(); - var builder: std.Io.Writer.Allocating = .init(alloc); - defer builder.deinit(); - try s.dumpString(&builder.writer, .{ - .tl = s.pages.getTopLeft(.screen), - .br = s.pages.getBottomRight(.screen), - .cell_map = &cell_map, - }); - - try testing.expectEqual(7, builder.written().len); - try testing.expectEqualStrings("A\n\nB\n\nC", builder.written()); - try testing.expectEqual(builder.written().len, cell_map.map.items.len); - try testing.expectEqual(Page.CellMapEntry{ - .x = 0, - .y = 0, - }, cell_map.map.items[0]); - try testing.expectEqual(Page.CellMapEntry{ - .x = 1, - .y = 0, - }, cell_map.map.items[1]); - try testing.expectEqual(Page.CellMapEntry{ - .x = 0, - .y = 1, - }, cell_map.map.items[2]); - try testing.expectEqual(Page.CellMapEntry{ - .x = 0, - .y = 2, - }, cell_map.map.items[3]); -} - -test "Screen UTF8 cell map with blank prefix" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 0); - defer s.deinit(); - s.cursorAbsolute(2, 1); - try s.testWriteString("B"); - - var cell_map: Page.CellMap = .init(alloc); - defer cell_map.deinit(); - var builder: std.Io.Writer.Allocating = .init(alloc); - defer builder.deinit(); - try s.dumpString(&builder.writer, .{ - .tl = s.pages.getTopLeft(.screen), - .br = s.pages.getBottomRight(.screen), - .cell_map = &cell_map, - }); - - try testing.expectEqualStrings("\n B", builder.written()); - try testing.expectEqual(builder.written().len, cell_map.map.items.len); - try testing.expectEqual(Page.CellMapEntry{ - .x = 0, - .y = 0, - }, cell_map.map.items[0]); - try testing.expectEqual(Page.CellMapEntry{ - .x = 0, - .y = 1, - }, cell_map.map.items[1]); - try testing.expectEqual(Page.CellMapEntry{ - .x = 1, - .y = 1, - }, cell_map.map.items[2]); - try testing.expectEqual(Page.CellMapEntry{ - .x = 2, - .y = 1, - }, cell_map.map.items[3]); -} diff --git a/src/terminal/ScreenSet.zig b/src/terminal/ScreenSet.zig new file mode 100644 index 000000000..418888694 --- /dev/null +++ b/src/terminal/ScreenSet.zig @@ -0,0 +1,106 @@ +/// A ScreenSet holds multiple terminal screens. This is initially created +/// to handle simple primary vs alternate screens, but could be extended +/// in the future to handle N screens. +/// +/// One of the goals of this is to allow lazy initialization of screens +/// as needed. The primary screen is always initialized, but the alternate +/// screen may not be until first used. +const ScreenSet = @This(); + +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const testing = std.testing; +const Allocator = std.mem.Allocator; +const Screen = @import("Screen.zig"); + +/// The possible keys for screens in the screen set. +pub const Key = enum(u1) { + primary, + alternate, +}; + +/// The key value of the currently active screen. Useful for simple +/// comparisons, e.g. "is this screen the primary screen". +active_key: Key, + +/// The active screen pointer. +active: *Screen, + +/// All screens that are initialized. +all: std.EnumMap(Key, *Screen), + +pub fn init( + alloc: Allocator, + opts: Screen.Options, +) !ScreenSet { + // We need to initialize our initial primary screen + const screen = try alloc.create(Screen); + errdefer alloc.destroy(screen); + screen.* = try .init(alloc, opts); + return .{ + .active_key = .primary, + .active = screen, + .all = .init(.{ .primary = screen }), + }; +} + +pub fn deinit(self: *ScreenSet, alloc: Allocator) void { + // Destroy all initialized screens + var it = self.all.iterator(); + while (it.next()) |entry| { + entry.value.*.deinit(); + alloc.destroy(entry.value.*); + } +} + +/// Get the screen for the given key, if it is initialized. +pub fn get(self: *const ScreenSet, key: Key) ?*Screen { + return self.all.get(key); +} + +/// Get the screen for the given key, initializing it if necessary. +pub fn getInit( + self: *ScreenSet, + alloc: Allocator, + key: Key, + opts: Screen.Options, +) !*Screen { + if (self.get(key)) |screen| return screen; + const screen = try alloc.create(Screen); + errdefer alloc.destroy(screen); + screen.* = try .init(alloc, opts); + self.all.put(key, screen); + return screen; +} + +/// Remove a key from the set. The primary screen cannot be removed (asserted). +pub fn remove( + self: *ScreenSet, + alloc: Allocator, + key: Key, +) void { + assert(key != .primary); + if (self.all.fetchRemove(key)) |screen| { + screen.deinit(); + alloc.destroy(screen); + } +} + +/// Switch the active screen to the given key. Requires that the +/// screen is initialized. +pub fn switchTo(self: *ScreenSet, key: Key) void { + self.active_key = key; + self.active = self.all.get(key).?; +} + +test ScreenSet { + const alloc = testing.allocator; + var set: ScreenSet = try .init(alloc, .default); + defer set.deinit(alloc); + try testing.expectEqual(.primary, set.active_key); + + // Initialize a secondary screen + _ = try set.getInit(alloc, .alternate, .default); + set.switchTo(.alternate); + try testing.expectEqual(.alternate, set.active_key); +} diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 267f223d5..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, ); } @@ -451,7 +504,7 @@ pub fn adjust( test "Selection: adjust right" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A1234\nB5678\nC1234\nD5678"); @@ -518,7 +571,7 @@ test "Selection: adjust right" { test "Selection: adjust left" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A1234\nB5678\nC1234\nD5678"); @@ -567,7 +620,7 @@ test "Selection: adjust left" { test "Selection: adjust left skips blanks" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A1234\nB5678\nC12\nD56"); @@ -616,7 +669,7 @@ test "Selection: adjust left skips blanks" { test "Selection: adjust up" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A\nB\nC\nD\nE"); @@ -663,7 +716,7 @@ test "Selection: adjust up" { test "Selection: adjust down" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A\nB\nC\nD\nE"); @@ -710,7 +763,7 @@ test "Selection: adjust down" { test "Selection: adjust down with not full screen" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A\nB\nC"); @@ -738,7 +791,7 @@ test "Selection: adjust down with not full screen" { test "Selection: adjust home" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A\nB\nC"); @@ -766,7 +819,7 @@ test "Selection: adjust home" { test "Selection: adjust end with not full screen" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A\nB\nC"); @@ -794,7 +847,7 @@ test "Selection: adjust end with not full screen" { test "Selection: adjust beginning of line" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 8, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 8, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A12 B34\nC12 D34"); @@ -864,7 +917,7 @@ test "Selection: adjust beginning of line" { test "Selection: adjust end of line" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 8, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 8, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A12 B34\nC12 D34"); @@ -934,7 +987,7 @@ test "Selection: order, standard" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 100, 100, 1); + var s = try Screen.init(alloc, .{ .cols = 100, .rows = 100, .max_scrollback = 1 }); defer s.deinit(); { @@ -998,7 +1051,7 @@ test "Selection: order, rectangle" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 100, 100, 1); + var s = try Screen.init(alloc, .{ .cols = 100, .rows = 100, .max_scrollback = 1 }); defer s.deinit(); // Conventions: @@ -1110,7 +1163,7 @@ test "Selection: order, rectangle" { test "topLeft" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); { // forward @@ -1173,7 +1226,7 @@ test "topLeft" { test "bottomRight" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); { // forward @@ -1236,7 +1289,7 @@ test "bottomRight" { test "ordered" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); { // forward @@ -1317,7 +1370,7 @@ test "ordered" { test "Selection: contains" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); { const sel = Selection.init( @@ -1363,7 +1416,7 @@ test "Selection: contains" { test "Selection: contains, rectangle" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 15, 15, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 15, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); { const sel = Selection.init( @@ -1425,7 +1478,7 @@ test "Selection: contains, rectangle" { test "Selection: containedRow" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 5, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); { diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig index ae34f5fc8..4ac47eeab 100644 --- a/src/terminal/StringMap.zig +++ b/src/terminal/StringMap.zig @@ -109,7 +109,7 @@ test "StringMap searchIterator" { defer re.deinit(); // Initialize our screen - var s = try Screen.init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); 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 69bcbcb84..3d00abf74 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"); @@ -22,11 +21,14 @@ const sgr = @import("sgr.zig"); const Tabstops = @import("Tabstops.zig"); const color = @import("color.zig"); const mouse_shape_pkg = @import("mouse_shape.zig"); +const ReadonlyHandler = @import("stream_readonly.zig").Handler; +const ReadonlyStream = @import("stream_readonly.zig").Stream; const size = @import("size.zig"); const pagepkg = @import("page.zig"); const style = @import("style.zig"); const Screen = @import("Screen.zig"); +const ScreenSet = @import("ScreenSet.zig"); const Page = pagepkg.Page; const Cell = pagepkg.Cell; const Row = pagepkg.Row; @@ -36,18 +38,8 @@ const log = std.log.scoped(.terminal); /// Default tabstop interval const TABSTOP_INTERVAL = 8; -/// Screen type is an enum that tracks whether a screen is primary or alternate. -pub const ScreenType = enum { - primary, - alternate, -}; - -/// Screen is the current screen state. The "active_screen" field says what -/// the current screen is. The backup screen is the opposite of the active -/// screen. -active_screen: ScreenType, -screen: Screen, -secondary_screen: Screen, +/// The set of screens behind this terminal (e.g. primary vs alternate). +screens: ScreenSet, /// Whether we're currently writing to the status line (DECSASD and DECSSDT). /// We don't support a status line currently so we just black hole this @@ -71,17 +63,8 @@ scrolling_region: ScrollingRegion, /// The last reported pwd, if any. pwd: std.ArrayList(u8), -/// The default color palette. This is only modified by changing the config file -/// and is used to reset the palette when receiving an OSC 104 command. -default_palette: color.Palette = color.default, - -/// The color palette to use. The mask indicates which palette indices have been -/// modified with OSC 4 -color_palette: struct { - const Mask = std.StaticBitSet(@typeInfo(color.Palette).array.len); - colors: color.Palette = color.default, - mask: Mask = .initEmpty(), -} = .{}, +/// The color state for this terminal. +colors: Colors, /// The previous printed character. This is used for the repeat previous /// char CSI (ESC [ b). @@ -128,10 +111,37 @@ 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 = .{}, } = .{}, +/// The various color configurations a terminal maintains and that can +/// be set dynamically via OSC, with defaults usually coming from a +/// configuration. +pub const Colors = struct { + background: color.DynamicRGB, + foreground: color.DynamicRGB, + cursor: color.DynamicRGB, + palette: color.DynamicPalette, + + pub const default: Colors = .{ + .background = .unset, + .foreground = .unset, + .cursor = .unset, + .palette = .default, + }; +}; + /// This is a set of dirty flags the renderer can use to determine /// what parts of the screen need to be redrawn. It is up to the renderer /// to clear these flags. @@ -197,6 +207,7 @@ pub const Options = struct { cols: size.CellCountInt, rows: size.CellCountInt, max_scrollback: usize = 10_000, + colors: Colors = .default, /// The default mode state. When the terminal gets a reset, it /// will revert back to this state. @@ -210,12 +221,18 @@ pub fn init( ) !Terminal { const cols = opts.cols; const rows = opts.rows; - return Terminal{ + + var screen_set: ScreenSet = try .init(alloc, .{ .cols = cols, .rows = rows, - .active_screen = .primary, - .screen = try .init(alloc, cols, rows, opts.max_scrollback), - .secondary_screen = try .init(alloc, cols, rows, 0), + .max_scrollback = opts.max_scrollback, + }); + errdefer screen_set.deinit(alloc); + + return .{ + .cols = cols, + .rows = rows, + .screens = screen_set, .tabstops = try .init(alloc, cols, TABSTOP_INTERVAL), .scrolling_region = .{ .top = 0, @@ -224,6 +241,7 @@ pub fn init( .right = cols - 1, }, .pwd = .empty, + .colors = opts.colors, .modes = .{ .values = opts.default_modes, .default = opts.default_modes, @@ -233,15 +251,27 @@ pub fn init( pub fn deinit(self: *Terminal, alloc: Allocator) void { self.tabstops.deinit(alloc); - self.screen.deinit(); - self.secondary_screen.deinit(); + self.screens.deinit(alloc); self.pwd.deinit(alloc); self.* = undefined; } +/// Return a terminal.Stream that can process VT streams and update this +/// terminal state. The streams will only process read-only data that +/// modifies terminal state. Sequences that query or otherwise require +/// output will be ignored. +pub fn vtStream(self: *Terminal) ReadonlyStream { + return .initAlloc(self.gpa(), self.vtHandler()); +} + +/// This is the handler-side only for vtStream. +pub fn vtHandler(self: *Terminal) ReadonlyHandler { + return .init(self); +} + /// The general allocator we should use for this terminal. fn gpa(self: *Terminal) Allocator { - return self.screen.alloc; + return self.screens.active.alloc; } /// Print UTF-8 encoded string to the terminal. @@ -269,17 +299,20 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void { } pub fn print(self: *Terminal, c: u21) !void { - // log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x }); + // 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. - defer self.screen.assertIntegrity(); + defer self.screens.active.assertIntegrity(); // Our right margin depends where our cursor is now. - const right_limit = if (self.screen.cursor.x > self.scrolling_region.right) + const right_limit = if (self.screens.active.cursor.x > self.scrolling_region.right) self.cols else self.scrolling_region.right + 1; @@ -290,8 +323,9 @@ pub fn print(self: *Terminal, c: u21) !void { // as quickly as possible. if (c > 255 and self.modes.get(.grapheme_cluster) and - self.screen.cursor.x > 0) + 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. @@ -305,18 +339,18 @@ pub fn print(self: *Terminal, c: u21) !void { // we're not on the last column, then we just use the previous // column. Otherwise, we need to check if there is text to // figure out if we're attaching to the prev or current. - if (self.screen.cursor.x != right_limit - 1) break :left 1; - break :left @intFromBool(self.screen.cursor.page_cell.codepoint() == 0); + if (self.screens.active.cursor.x != right_limit - 1) break :left 1; + break :left @intFromBool(self.screens.active.cursor.page_cell.codepoint() == 0); }; // If the previous cell is a wide spacer tail, then we actually // want to use the cell before that because that has the actual // content. - const immediate = self.screen.cursorCellLeft(left); + const immediate = self.screens.active.cursorCellLeft(left); break :prev switch (immediate.wide) { else => .{ .cell = immediate, .left = left }, .spacer_tail => .{ - .cell = self.screen.cursorCellLeft(left + 1), + .cell = self.screens.active.cursorCellLeft(left + 1), .left = left + 1, }, }; @@ -330,7 +364,7 @@ pub fn print(self: *Terminal, c: u21) !void { var state: unicode.GraphemeBreakState = .{}; var cp1: u21 = prev.cell.content.codepoint; if (prev.cell.hasGrapheme()) { - const cps = self.screen.cursor.page_pin.node.data.lookupGrapheme(prev.cell).?; + const cps = self.screens.active.cursor.page_pin.node.data.lookupGrapheme(prev.cell).?; for (cps) |cp2| { // log.debug("cp1={x} cp2={x}", .{ cp1, cp2 }); assert(!unicode.graphemeBreak(cp1, cp2, &state)); @@ -349,10 +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 only applies to emoji const prev_props = unicode.table.get(prev.cell.content.codepoint); - const emoji = prev_props.grapheme_boundary_class.isExtendedPictographic(); - if (!emoji) 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: { @@ -360,12 +394,12 @@ pub fn print(self: *Terminal, c: u21) !void { // Move our cursor back to the previous. We'll move // the cursor within this block to the proper location. - self.screen.cursorLeft(prev.left); + self.screens.active.cursorLeft(prev.left); // If we don't have space for the wide char, we need // to insert spacers and wrap. Then we just print the wide // char as normal. - if (self.screen.cursor.x == right_limit - 1) { + if (self.screens.active.cursor.x == right_limit - 1) { if (!self.modes.get(.wraparound)) return; self.printCell( 0, @@ -377,14 +411,14 @@ pub fn print(self: *Terminal, c: u21) !void { self.printCell(prev.cell.content.codepoint, .wide); // Write our spacer - self.screen.cursorRight(1); + self.screens.active.cursorRight(1); self.printCell(0, .spacer_tail); // Move the cursor again so we're beyond our spacer - if (self.screen.cursor.x == right_limit - 1) { - self.screen.cursor.pending_wrap = true; + if (self.screens.active.cursor.x == right_limit - 1) { + self.screens.active.cursor.pending_wrap = true; } else { - self.screen.cursorRight(1); + self.screens.active.cursorRight(1); } }, @@ -394,7 +428,7 @@ pub fn print(self: *Terminal, c: u21) !void { prev.cell.wide = .narrow; // Remove the wide spacer tail - const cell = self.screen.cursorCellLeft(prev.left - 1); + const cell = self.screens.active.cursorCellLeft(prev.left - 1); cell.wide = .narrow; // Back track the cursor so that we don't end up with @@ -404,15 +438,15 @@ pub fn print(self: *Terminal, c: u21) !void { // least surprise, and also matches the behavior that // can be observed in Kitty, which is one of the only // other VS aware terminals. - if (self.screen.cursor.x == right_limit - 1) { + if (self.screens.active.cursor.x == right_limit - 1) { // If we're already at the right edge, we stay // here and set the pending wrap to false since // when we pend a wrap, we only move our cursor once // even for wide chars (tests verify). - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; } else { // Otherwise, move back. - self.screen.cursorLeft(1); + self.screens.active.cursorLeft(1); } break :narrow; @@ -427,8 +461,8 @@ pub fn print(self: *Terminal, c: u21) !void { prev.left, prev.cell.codepoint(), }); - self.screen.cursorMarkDirty(); - try self.screen.appendGrapheme(prev.cell, c); + self.screens.active.cursorMarkDirty(); + try self.screens.active.appendGrapheme(prev.cell, c); return; } } @@ -447,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. @@ -456,16 +491,16 @@ pub fn print(self: *Terminal, c: u21) !void { // print anything or even store this. Zero-width characters are ALWAYS // attached to some other non-zero-width character at the time of // writing. - if (self.screen.cursor.x == 0) { + if (self.screens.active.cursor.x == 0) { log.warn("zero-width character with no prior character, ignoring", .{}); return; } // Find our previous cell const prev = prev: { - const immediate = self.screen.cursorCellLeft(1); + const immediate = self.screens.active.cursorCellLeft(1); if (immediate.wide != .spacer_tail) break :prev immediate; - break :prev self.screen.cursorCellLeft(2); + break :prev self.screens.active.cursorCellLeft(2); }; // If our previous cell has no text, just ignore the zero-width character @@ -481,7 +516,7 @@ pub fn print(self: *Terminal, c: u21) !void { if (!emoji) return; } - try self.screen.appendGrapheme(prev, c); + try self.screens.active.appendGrapheme(prev, c); return; } @@ -489,14 +524,14 @@ pub fn print(self: *Terminal, c: u21) !void { self.previous_char = c; // If we're soft-wrapping, then handle that first. - if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) { + if (self.screens.active.cursor.pending_wrap and self.modes.get(.wraparound)) { try self.printWrap(); } // If we have insert mode enabled then we need to handle that. We // only do insert mode if we're not at the end of the line. if (self.modes.get(.insert) and - self.screen.cursor.x + width < self.cols) + self.screens.active.cursor.x + width < self.cols) { self.insertBlanks(width); } @@ -504,7 +539,8 @@ pub fn print(self: *Terminal, c: u21) !void { switch (width) { // Single cell is very easy: just write in the cell 1 => { - self.screen.cursorMarkDirty(); + @branchHint(.likely); + self.screens.active.cursorMarkDirty(); @call(.always_inline, printCell, .{ self, c, .narrow }); }, @@ -516,7 +552,7 @@ pub fn print(self: *Terminal, c: u21) !void { // If we don't have space for the wide char, we need // to insert spacers and wrap. Then we just print the wide // char as normal. - if (self.screen.cursor.x == right_limit - 1) { + if (self.screens.active.cursor.x == right_limit - 1) { // If we don't have wraparound enabled then we don't print // this character at all and don't move the cursor. This is // how xterm behaves. @@ -529,14 +565,14 @@ pub fn print(self: *Terminal, c: u21) !void { try self.printWrap(); } - self.screen.cursorMarkDirty(); + self.screens.active.cursorMarkDirty(); self.printCell(c, .wide); - self.screen.cursorRight(1); + self.screens.active.cursorRight(1); self.printCell(0, .spacer_tail); } else { // This is pretty broken, terminals should never be only 1-wide. // We should prevent this downstream. - self.screen.cursorMarkDirty(); + self.screens.active.cursorMarkDirty(); self.printCell(0, .narrow); }, @@ -545,13 +581,13 @@ pub fn print(self: *Terminal, c: u21) !void { // If we're at the column limit, then we need to wrap the next time. // In this case, we don't move the cursor. - if (self.screen.cursor.x == right_limit - 1) { - self.screen.cursor.pending_wrap = true; + if (self.screens.active.cursor.x == right_limit - 1) { + self.screens.active.cursor.pending_wrap = true; return; } // Move the cursor - self.screen.cursorRight(1); + self.screens.active.cursorRight(1); } fn printCell( @@ -559,7 +595,7 @@ fn printCell( unmapped_c: u21, wide: Cell.Wide, ) void { - defer self.screen.assertIntegrity(); + defer self.screens.active.assertIntegrity(); // TODO: spacers should use a bgcolor only cell @@ -567,25 +603,29 @@ fn printCell( // TODO: non-utf8 handling, gr // If we're single shifting, then we use the key exactly once. - const key = if (self.screen.charset.single_shift) |key_once| blk: { - self.screen.charset.single_shift = null; + const key = if (self.screens.active.charset.single_shift) |key_once| blk: { + self.screens.active.charset.single_shift = null; break :blk key_once; - } else self.screen.charset.gl; - const set = self.screen.charset.charsets.get(key); + } 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. if (unmapped_c > std.math.maxInt(u8)) break :c ' '; // Get our lookup table and map it - const table = set.table(); + const table = charsets.table(set); break :c @intCast(table[@intCast(unmapped_c)]); }; - const cell = self.screen.cursor.page_cell; + const cell = self.screens.active.cursor.page_cell; // If the wide property of this cell is the same, then we don't // need to do the special handling here because the structure will @@ -598,22 +638,22 @@ fn printCell( // Previous cell was wide. We need to clear the tail and head. .wide => wide: { - if (self.screen.cursor.x >= self.cols - 1) break :wide; + if (self.screens.active.cursor.x >= self.cols - 1) break :wide; - const spacer_cell = self.screen.cursorCellRight(1); - self.screen.clearCells( - &self.screen.cursor.page_pin.node.data, - self.screen.cursor.page_row, + const spacer_cell = self.screens.active.cursorCellRight(1); + self.screens.active.clearCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, spacer_cell[0..1], ); - if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { - const head_cell = self.screen.cursorCellEndOfPrev(); + if (self.screens.active.cursor.y > 0 and self.screens.active.cursor.x <= 1) { + const head_cell = self.screens.active.cursorCellEndOfPrev(); head_cell.wide = .narrow; } }, .spacer_tail => { - assert(self.screen.cursor.x > 0); + assert(self.screens.active.cursor.x > 0); // So integrity checks pass. We fix this up later so we don't // need to do this without safety checks. @@ -621,14 +661,14 @@ fn printCell( cell.wide = .narrow; } - const wide_cell = self.screen.cursorCellLeft(1); - self.screen.clearCells( - &self.screen.cursor.page_pin.node.data, - self.screen.cursor.page_row, + const wide_cell = self.screens.active.cursorCellLeft(1); + self.screens.active.clearCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, wide_cell[0..1], ); - if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { - const head_cell = self.screen.cursorCellEndOfPrev(); + if (self.screens.active.cursor.y > 0 and self.screens.active.cursor.x <= 1) { + const head_cell = self.screens.active.cursorCellEndOfPrev(); head_cell.wide = .narrow; } }, @@ -642,21 +682,20 @@ fn printCell( // If the prior value had graphemes, clear those if (cell.hasGrapheme()) { - self.screen.cursor.page_pin.node.data.clearGrapheme( - self.screen.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 // cell's new style will be different after writing. - const style_changed = cell.style_id != self.screen.cursor.style_id; + const style_changed = cell.style_id != self.screens.active.cursor.style_id; if (style_changed) { - var page = &self.screen.cursor.page_pin.node.data; + var page = &self.screens.active.cursor.page_pin.node.data; // Release the old style. if (cell.style_id != style.default_id) { - assert(self.screen.cursor.page_row.styled); + assert(self.screens.active.cursor.page_row.styled); page.styles.release(page.memory, cell.style_id); } } @@ -668,18 +707,18 @@ fn printCell( cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = c }, - .style_id = self.screen.cursor.style_id, + .style_id = self.screens.active.cursor.style_id, .wide = wide, - .protected = self.screen.cursor.protected, + .protected = self.screens.active.cursor.protected, }; if (style_changed) { - var page = &self.screen.cursor.page_pin.node.data; + var page = &self.screens.active.cursor.page_pin.node.data; // Use the new style. if (cell.style_id != style.default_id) { page.styles.use(page.memory, cell.style_id); - self.screen.cursor.page_row.styled = true; + self.screens.active.cursor.page_row.styled = true; } } @@ -687,22 +726,25 @@ 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) { - self.screen.cursor.page_row.kitty_virtual_placeholder = true; + @branchHint(.unlikely); + self.screens.active.cursor.page_row.kitty_virtual_placeholder = true; } } // We check for an active hyperlink first because setHyperlink // handles clearing the old hyperlink and an optimization if we're // overwriting the same hyperlink. - if (self.screen.cursor.hyperlink_id > 0) { - self.screen.cursorSetHyperlink() catch |err| { + 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.screen.cursor.page_pin.node.data; - page.clearHyperlink(self.screen.cursor.page_row, cell); + var page = &self.screens.active.cursor.page_pin.node.data; + page.clearHyperlink(cell); + page.updateRowHyperlinkFlag(self.screens.active.cursor.page_row); } } @@ -710,31 +752,31 @@ fn printWrap(self: *Terminal) !void { // We only mark that we soft-wrapped if we're at the edge of our // full screen. We don't mark the row as wrapped if we're in the // middle due to a right margin. - const mark_wrap = self.screen.cursor.x == self.cols - 1; - if (mark_wrap) self.screen.cursor.page_row.wrap = true; + const mark_wrap = self.screens.active.cursor.x == self.cols - 1; + if (mark_wrap) self.screens.active.cursor.page_row.wrap = true; // Get the old semantic prompt so we can extend it to the next // line. We need to do this before we index() because we may // modify memory. - const old_prompt = self.screen.cursor.page_row.semantic_prompt; + const old_prompt = self.screens.active.cursor.page_row.semantic_prompt; // Move to the next line try self.index(); - self.screen.cursorHorizontalAbsolute(self.scrolling_region.left); + self.screens.active.cursorHorizontalAbsolute(self.scrolling_region.left); if (mark_wrap) { // New line must inherit semantic prompt of the old line - self.screen.cursor.page_row.semantic_prompt = old_prompt; - self.screen.cursor.page_row.wrap_continuation = true; + self.screens.active.cursor.page_row.semantic_prompt = old_prompt; + self.screens.active.cursor.page_row.wrap_continuation = true; } // Assure that our screen is consistent - self.screen.assertIntegrity(); + self.screens.active.assertIntegrity(); } /// Set the charset into the given slot. pub fn configureCharset(self: *Terminal, slot: charsets.Slots, set: charsets.Charset) void { - self.screen.charset.charsets.set(slot, set); + self.screens.active.charset.charsets.set(slot, set); } /// Invoke the charset in slot into the active slot. If single is true, @@ -747,25 +789,25 @@ pub fn invokeCharset( ) void { if (single) { assert(active == .GL); - self.screen.charset.single_shift = slot; + self.screens.active.charset.single_shift = slot; return; } switch (active) { - .GL => self.screen.charset.gl = slot, - .GR => self.screen.charset.gr = slot, + .GL => self.screens.active.charset.gl = slot, + .GR => self.screens.active.charset.gr = slot, } } /// Carriage return moves the cursor to the first column. pub fn carriageReturn(self: *Terminal) void { // Always reset pending wrap state - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // In origin mode we always move to the left margin - self.screen.cursorHorizontalAbsolute(if (self.modes.get(.origin)) + self.screens.active.cursorHorizontalAbsolute(if (self.modes.get(.origin)) self.scrolling_region.left - else if (self.screen.cursor.x >= self.scrolling_region.left) + else if (self.screens.active.cursor.x >= self.scrolling_region.left) self.scrolling_region.left else 0); @@ -787,17 +829,17 @@ pub fn backspace(self: *Terminal) void { /// 0, adjust it to 1. pub fn cursorUp(self: *Terminal, count_req: usize) void { // Always resets pending wrap - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // The maximum amount the cursor can move up depends on scrolling regions - const max = if (self.screen.cursor.y >= self.scrolling_region.top) - self.screen.cursor.y - self.scrolling_region.top + const max = if (self.screens.active.cursor.y >= self.scrolling_region.top) + self.screens.active.cursor.y - self.scrolling_region.top else - self.screen.cursor.y; + self.screens.active.cursor.y; const count = @min(max, @max(count_req, 1)); // We can safely intCast below because of the min/max clamping we did above. - self.screen.cursorUp(@intCast(count)); + self.screens.active.cursorUp(@intCast(count)); } /// Move the cursor down amount lines. If amount is greater than the maximum @@ -805,15 +847,15 @@ pub fn cursorUp(self: *Terminal, count_req: usize) void { /// will not scroll the screen or scroll region. If amount is 0, adjust it to 1. pub fn cursorDown(self: *Terminal, count_req: usize) void { // Always resets pending wrap - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // The max the cursor can move to depends where the cursor currently is - const max = if (self.screen.cursor.y <= self.scrolling_region.bottom) - self.scrolling_region.bottom - self.screen.cursor.y + const max = if (self.screens.active.cursor.y <= self.scrolling_region.bottom) + self.scrolling_region.bottom - self.screens.active.cursor.y else - self.rows - self.screen.cursor.y - 1; + self.rows - self.screens.active.cursor.y - 1; const count = @min(max, @max(count_req, 1)); - self.screen.cursorDown(@intCast(count)); + self.screens.active.cursorDown(@intCast(count)); } /// Move the cursor right amount columns. If amount is greater than the @@ -822,15 +864,15 @@ pub fn cursorDown(self: *Terminal, count_req: usize) void { /// 0, adjust it to 1. pub fn cursorRight(self: *Terminal, count_req: usize) void { // Always resets pending wrap - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // The max the cursor can move to depends where the cursor currently is - const max = if (self.screen.cursor.x <= self.scrolling_region.right) - self.scrolling_region.right - self.screen.cursor.x + const max = if (self.screens.active.cursor.x <= self.scrolling_region.right) + self.scrolling_region.right - self.screens.active.cursor.x else - self.cols - self.screen.cursor.x - 1; + self.cols - self.screens.active.cursor.x - 1; const count = @min(max, @max(count_req, 1)); - self.screen.cursorRight(@intCast(count)); + self.screens.active.cursorRight(@intCast(count)); } /// Move the cursor to the left amount cells. If amount is 0, adjust it to 1. @@ -849,34 +891,34 @@ pub fn cursorLeft(self: *Terminal, count_req: usize) void { // If we are in no wrap mode, then we move the cursor left and exit // since this is the fastest and most typical path. if (wrap_mode == .none) { - self.screen.cursorLeft(@min(count, self.screen.cursor.x)); - self.screen.cursor.pending_wrap = false; + self.screens.active.cursorLeft(@min(count, self.screens.active.cursor.x)); + self.screens.active.cursor.pending_wrap = false; return; } // If we have a pending wrap state and we are in either reverse wrap // modes then we decrement the amount we move by one to match xterm. - if (self.screen.cursor.pending_wrap) { + if (self.screens.active.cursor.pending_wrap) { count -= 1; - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; } // The margins we can move to. const top = self.scrolling_region.top; const bottom = self.scrolling_region.bottom; const right_margin = self.scrolling_region.right; - const left_margin = if (self.screen.cursor.x < self.scrolling_region.left) + const left_margin = if (self.screens.active.cursor.x < self.scrolling_region.left) 0 else self.scrolling_region.left; // Handle some edge cases when our cursor is already on the left margin. - if (self.screen.cursor.x == left_margin) { + if (self.screens.active.cursor.x == left_margin) { switch (wrap_mode) { // In reverse mode, if we're already before the top margin // then we just set our cursor to the top-left and we're done. - .reverse => if (self.screen.cursor.y <= top) { - self.screen.cursorAbsolute(left_margin, top); + .reverse => if (self.screens.active.cursor.y <= top) { + self.screens.active.cursorAbsolute(left_margin, top); return; }, @@ -890,22 +932,22 @@ pub fn cursorLeft(self: *Terminal, count_req: usize) void { while (true) { // We can move at most to the left margin. - const max = self.screen.cursor.x - left_margin; + const max = self.screens.active.cursor.x - left_margin; // We want to move at most the number of columns we have left // or our remaining count. Do the move. const amount = @min(max, count); count -= amount; - self.screen.cursorLeft(amount); + self.screens.active.cursorLeft(amount); // If we have no more to move, then we're done. if (count == 0) break; // If we are at the top, then we are done. - if (self.screen.cursor.y == top) { + if (self.screens.active.cursor.y == top) { if (wrap_mode != .reverse_extended) break; - self.screen.cursorAbsolute(right_margin, bottom); + self.screens.active.cursorAbsolute(right_margin, bottom); count -= 1; continue; } @@ -917,18 +959,18 @@ pub fn cursorLeft(self: *Terminal, count_req: usize) void { // up to the (0, 0) and stopping there. My reasoning is that for an // appropriately sized value of "count" this is the behavior that xterm // would have. This is unit tested. - if (self.screen.cursor.y == 0) { - assert(self.screen.cursor.x == left_margin); + if (self.screens.active.cursor.y == 0) { + assert(self.screens.active.cursor.x == left_margin); break; } // If our previous line is not wrapped then we are done. if (wrap_mode != .reverse_extended) { - const prev_row = self.screen.cursorRowUp(1); + const prev_row = self.screens.active.cursorRowUp(1); if (!prev_row.wrap) break; } - self.screen.cursorAbsolute(right_margin, self.screen.cursor.y - 1); + self.screens.active.cursorAbsolute(right_margin, self.screens.active.cursor.y - 1); count -= 1; } } @@ -939,14 +981,14 @@ pub fn cursorLeft(self: *Terminal, count_req: usize) void { /// is kept per screen (main / alternative). If for the current screen state /// was already saved it is overwritten. pub fn saveCursor(self: *Terminal) void { - self.screen.saved_cursor = .{ - .x = self.screen.cursor.x, - .y = self.screen.cursor.y, - .style = self.screen.cursor.style, - .protected = self.screen.cursor.protected, - .pending_wrap = self.screen.cursor.pending_wrap, + self.screens.active.saved_cursor = .{ + .x = self.screens.active.cursor.x, + .y = self.screens.active.cursor.y, + .style = self.screens.active.cursor.style, + .protected = self.screens.active.cursor.protected, + .pending_wrap = self.screens.active.cursor.pending_wrap, .origin = self.modes.get(.origin), - .charset = self.screen.charset, + .charset = self.screens.active.charset, }; } @@ -955,7 +997,7 @@ pub fn saveCursor(self: *Terminal) void { /// The primary and alternate screen have distinct save state. /// If no save was done before values are reset to their initial values. pub fn restoreCursor(self: *Terminal) !void { - const saved: Screen.SavedCursor = self.screen.saved_cursor orelse .{ + const saved: Screen.SavedCursor = self.screens.active.saved_cursor orelse .{ .x = 0, .y = 0, .style = .{}, @@ -966,29 +1008,29 @@ pub fn restoreCursor(self: *Terminal) !void { }; // Set the style first because it can fail - const old_style = self.screen.cursor.style; - self.screen.cursor.style = saved.style; - errdefer self.screen.cursor.style = old_style; - try self.screen.manualStyleUpdate(); + const old_style = self.screens.active.cursor.style; + self.screens.active.cursor.style = saved.style; + errdefer self.screens.active.cursor.style = old_style; + try self.screens.active.manualStyleUpdate(); - self.screen.charset = saved.charset; + self.screens.active.charset = saved.charset; self.modes.set(.origin, saved.origin); - self.screen.cursor.pending_wrap = saved.pending_wrap; - self.screen.cursor.protected = saved.protected; - self.screen.cursorAbsolute( + self.screens.active.cursor.pending_wrap = saved.pending_wrap; + self.screens.active.cursor.protected = saved.protected; + self.screens.active.cursorAbsolute( @min(saved.x, self.cols - 1), @min(saved.y, self.rows - 1), ); // Ensure our screen is consistent - self.screen.assertIntegrity(); + self.screens.active.assertIntegrity(); } /// Set the character protection mode for the terminal. pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { switch (mode) { .off => { - self.screen.cursor.protected = false; + self.screens.active.cursor.protected = false; // screen.protected_mode is NEVER reset to ".off" because // logic such as eraseChars depends on knowing what the @@ -996,13 +1038,13 @@ pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { }, .iso => { - self.screen.cursor.protected = true; - self.screen.protected_mode = .iso; + self.screens.active.cursor.protected = true; + self.screens.active.protected_mode = .iso; }, .dec => { - self.screen.cursor.protected = true; - self.screen.protected_mode = .dec; + self.screens.active.cursor.protected = true; + self.screens.active.protected_mode = .dec; }, } } @@ -1023,8 +1065,8 @@ pub const SemanticPrompt = enum { /// (OSC 133) only allow setting this for wherever the current active cursor /// is located. pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { - //log.debug("semantic_prompt y={} p={}", .{ self.screen.cursor.y, p }); - self.screen.cursor.page_row.semantic_prompt = switch (p) { + //log.debug("semantic_prompt y={} p={}", .{ self.screens.active.cursor.y, p }); + self.screens.active.cursor.page_row.semantic_prompt = switch (p) { .prompt => .prompt, .prompt_continuation => .prompt_continuation, .input => .input, @@ -1039,15 +1081,15 @@ pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { /// If the shell integration doesn't exist, this will always return false. pub fn cursorIsAtPrompt(self: *Terminal) bool { // If we're on the secondary screen, we're never at a prompt. - if (self.active_screen == .alternate) return false; + if (self.screens.active_key == .alternate) return false; // Reverse through the active - const start_x, const start_y = .{ self.screen.cursor.x, self.screen.cursor.y }; - defer self.screen.cursorAbsolute(start_x, start_y); + const start_x, const start_y = .{ self.screens.active.cursor.x, self.screens.active.cursor.y }; + defer self.screens.active.cursorAbsolute(start_x, start_y); for (0..start_y + 1) |i| { - if (i > 0) self.screen.cursorUp(1); - switch (self.screen.cursor.page_row.semantic_prompt) { + if (i > 0) self.screens.active.cursorUp(1); + switch (self.screens.active.cursor.page_row.semantic_prompt) { // If we're at a prompt or input area, then we are at a prompt. .prompt, .prompt_continuation, @@ -1069,14 +1111,14 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool { /// Horizontal tab moves the cursor to the next tabstop, clearing /// the screen to the left the tabstop. pub fn horizontalTab(self: *Terminal) !void { - while (self.screen.cursor.x < self.scrolling_region.right) { + while (self.screens.active.cursor.x < self.scrolling_region.right) { // Move the cursor right - self.screen.cursorRight(1); + self.screens.active.cursorRight(1); // If the last cursor position was a tabstop we return. We do // "last cursor position" because we want a space to be written // at the tabstop unless we're at the end (the while condition). - if (self.tabstops.get(self.screen.cursor.x)) return; + if (self.tabstops.get(self.screens.active.cursor.x)) return; } } @@ -1087,18 +1129,18 @@ pub fn horizontalTabBack(self: *Terminal) !void { while (true) { // If we're already at the edge of the screen, then we're done. - if (self.screen.cursor.x <= left_limit) return; + if (self.screens.active.cursor.x <= left_limit) return; // Move the cursor left - self.screen.cursorLeft(1); - if (self.tabstops.get(self.screen.cursor.x)) return; + self.screens.active.cursorLeft(1); + if (self.tabstops.get(self.screens.active.cursor.x)) return; } } /// Clear tab stops. pub fn tabClear(self: *Terminal, cmd: csi.TabClear) void { switch (cmd) { - .current => self.tabstops.unset(self.screen.cursor.x), + .current => self.tabstops.unset(self.screens.active.cursor.x), .all => self.tabstops.reset(0), else => log.warn("invalid or unknown tab clear setting: {}", .{cmd}), } @@ -1107,7 +1149,7 @@ pub fn tabClear(self: *Terminal, cmd: csi.TabClear) void { /// Set a tab stop on the current cursor. /// TODO: test pub fn tabSet(self: *Terminal) void { - self.tabstops.set(self.screen.cursor.x); + self.tabstops.set(self.screens.active.cursor.x); } /// TODO: test @@ -1129,16 +1171,16 @@ pub fn tabReset(self: *Terminal) void { /// This unsets the pending wrap state without wrapping. pub fn index(self: *Terminal) !void { // Unset pending wrap state - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // Outside of the scroll region we move the cursor one line down. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom) + if (self.screens.active.cursor.y < self.scrolling_region.top or + self.screens.active.cursor.y > self.scrolling_region.bottom) { // We only move down if we're not already at the bottom of // the screen. - if (self.screen.cursor.y < self.rows - 1) { - self.screen.cursorDown(1); + if (self.screens.active.cursor.y < self.rows - 1) { + self.screens.active.cursorDown(1); } return; @@ -1147,13 +1189,13 @@ pub fn index(self: *Terminal) !void { // If the cursor is inside the scrolling region and on the bottom-most // line, then we scroll up. If our scrolling region is the full screen // we create scrollback. - if (self.screen.cursor.y == self.scrolling_region.bottom and - self.screen.cursor.x >= self.scrolling_region.left and - self.screen.cursor.x <= self.scrolling_region.right) + if (self.screens.active.cursor.y == self.scrolling_region.bottom and + self.screens.active.cursor.x >= self.scrolling_region.left and + self.screens.active.cursor.x <= self.scrolling_region.right) { if (comptime build_options.kitty_graphics) { // Scrolling dirties the images because it updates their placements pins. - self.screen.kitty_images.dirty = true; + self.screens.active.kitty_images.dirty = true; } // If our scrolling region is at the top, we create scrollback. @@ -1161,7 +1203,7 @@ pub fn index(self: *Terminal) !void { self.scrolling_region.left == 0 and self.scrolling_region.right == self.cols - 1) { - try self.screen.cursorScrollAbove(); + try self.screens.active.cursorScrollAbove(); return; } @@ -1175,9 +1217,9 @@ pub fn index(self: *Terminal) !void { // However, scrollUp is WAY slower. We should optimize this // case to work in the eraseRowBounded codepath and remove // this check. - !self.screen.blankCell().isZero()) + !self.screens.active.blankCell().isZero()) { - self.scrollUp(1); + try self.scrollUp(1); return; } @@ -1185,9 +1227,9 @@ pub fn index(self: *Terminal) !void { // scroll the contents of the scrolling region. // Preserve old cursor just for assertions - const old_cursor = self.screen.cursor; + const old_cursor = self.screens.active.cursor; - try self.screen.pages.eraseRowBounded( + try self.screens.active.pages.eraseRowBounded( .{ .active = .{ .y = self.scrolling_region.top } }, self.scrolling_region.bottom - self.scrolling_region.top, ); @@ -1196,26 +1238,26 @@ pub fn index(self: *Terminal) !void { // up by 1, so we need to move it back down. A `cursorReload` // would be better option but this is more efficient and this is // a super hot path so we do this instead. - assert(self.screen.cursor.x == old_cursor.x); - assert(self.screen.cursor.y == old_cursor.y); - self.screen.cursor.y -= 1; - self.screen.cursorDown(1); + assert(self.screens.active.cursor.x == old_cursor.x); + assert(self.screens.active.cursor.y == old_cursor.y); + self.screens.active.cursor.y -= 1; + self.screens.active.cursorDown(1); // The operations above can prune our cursor style so we need to // update. This should never fail because the above can only FREE // memory. - self.screen.manualStyleUpdate() catch |err| { + self.screens.active.manualStyleUpdate() catch |err| { std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); - self.screen.cursor.style = .{}; - self.screen.manualStyleUpdate() catch unreachable; + self.screens.active.cursor.style = .{}; + self.screens.active.manualStyleUpdate() catch unreachable; }; return; } // Increase cursor by 1, maximum to bottom of scroll region - if (self.screen.cursor.y < self.scrolling_region.bottom) { - self.screen.cursorDown(1); + if (self.screens.active.cursor.y < self.scrolling_region.bottom) { + self.screens.active.cursorDown(1); } } @@ -1232,9 +1274,9 @@ pub fn index(self: *Terminal) !void { /// * If the cursor is not on the top-most line of the scrolling region: /// move the cursor one line up pub fn reverseIndex(self: *Terminal) void { - if (self.screen.cursor.y != self.scrolling_region.top or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) + if (self.screens.active.cursor.y != self.scrolling_region.top or + self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) { self.cursorUp(1); return; @@ -1273,7 +1315,7 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { }; // Unset pending wrap state - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // Calculate our new x/y const row = if (row_req == 0) 1 else row_req; @@ -1282,19 +1324,19 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { const y = @min(params.y_max, row + params.y_offset) -| 1; // If the y is unchanged then this is fast pointer math - if (y == self.screen.cursor.y) { - if (x > self.screen.cursor.x) { - self.screen.cursorRight(x - self.screen.cursor.x); + if (y == self.screens.active.cursor.y) { + if (x > self.screens.active.cursor.x) { + self.screens.active.cursorRight(x - self.screens.active.cursor.x); } else { - self.screen.cursorLeft(self.screen.cursor.x - x); + self.screens.active.cursorLeft(self.screens.active.cursor.x - x); } return; } // If everything changed we do an absolute change which is slightly slower - self.screen.cursorAbsolute(x, y); - // log.info("set cursor position: col={} row={}", .{ self.screen.cursor.x, self.screen.cursor.y }); + self.screens.active.cursorAbsolute(x, y); + // log.info("set cursor position: col={} row={}", .{ self.screens.active.cursor.x, self.screens.active.cursor.y }); } /// Set Top and Bottom Margins If bottom is not specified, 0 or bigger than @@ -1336,16 +1378,16 @@ pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) /// Scroll the text down by one row. pub fn scrollDown(self: *Terminal, count: usize) void { // Preserve our x/y to restore. - const old_x = self.screen.cursor.x; - const old_y = self.screen.cursor.y; - const old_wrap = self.screen.cursor.pending_wrap; + const old_x = self.screens.active.cursor.x; + const old_y = self.screens.active.cursor.y; + const old_wrap = self.screens.active.cursor.pending_wrap; defer { - self.screen.cursorAbsolute(old_x, old_y); - self.screen.cursor.pending_wrap = old_wrap; + self.screens.active.cursorAbsolute(old_x, old_y); + self.screens.active.cursor.pending_wrap = old_wrap; } // Move to the top of the scroll region - self.screen.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); + self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); self.insertLines(count); } @@ -1356,28 +1398,54 @@ pub fn scrollDown(self: *Terminal, count: usize) void { /// The new lines are created according to the current SGR state. /// /// Does not change the (absolute) cursor position. -pub fn scrollUp(self: *Terminal, count: usize) void { +pub fn scrollUp(self: *Terminal, count: usize) !void { // Preserve our x/y to restore. - const old_x = self.screen.cursor.x; - const old_y = self.screen.cursor.y; - const old_wrap = self.screen.cursor.pending_wrap; + const old_x = self.screens.active.cursor.x; + const old_y = self.screens.active.cursor.y; + const old_wrap = self.screens.active.cursor.pending_wrap; defer { - self.screen.cursorAbsolute(old_x, old_y); - self.screen.cursor.pending_wrap = old_wrap; + self.screens.active.cursorAbsolute(old_x, old_y); + self.screens.active.cursor.pending_wrap = old_wrap; + } + + // If our scroll region is at the top and we have no left/right + // margins then we move the scrolled out text into the scrollback. + if (self.scrolling_region.top == 0 and + self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1) + { + // Scrolling dirties the images because it updates their placements pins. + if (comptime build_options.kitty_graphics) { + self.screens.active.kitty_images.dirty = true; + } + + // Clamp count to the scroll region height. + const region_height = self.scrolling_region.bottom + 1; + const adjusted_count = @min(count, region_height); + + // TODO: Create an optimized version that can scroll N times + // This isn't critical because in most cases, scrollUp is used + // with count=1, but it's still a big optimization opportunity. + + // Move our cursor to the bottom of the scroll region so we can + // use the cursorScrollAbove function to create scrollback + self.screens.active.cursorAbsolute(0, self.scrolling_region.bottom); + for (0..adjusted_count) |_| try self.screens.active.cursorScrollAbove(); + return; } // Move to the top of the scroll region - self.screen.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); + self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); self.deleteLines(count); } /// 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, @@ -1385,7 +1453,7 @@ pub const ScrollViewport = union(enum) { /// Scroll the viewport of the terminal grid. pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void { - self.screen.scroll(switch (behavior) { + self.screens.active.scroll(switch (behavior) { .top => .{ .top = {} }, .bottom => .{ .active = {} }, .delta => |delta| .{ .delta_row = delta }, @@ -1431,7 +1499,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; @@ -1441,7 +1510,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; @@ -1477,23 +1547,23 @@ pub fn insertLines(self: *Terminal, count: usize) void { if (count == 0) return; // If the cursor is outside the scroll region we do nothing. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; + if (self.screens.active.cursor.y < self.scrolling_region.top or + self.screens.active.cursor.y > self.scrolling_region.bottom or + self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) return; if (comptime build_options.kitty_graphics) { // Scrolling dirties the images because it updates their placements pins. - self.screen.kitty_images.dirty = true; + self.screens.active.kitty_images.dirty = true; } // At the end we need to return the cursor to the row it started on. - const start_y = self.screen.cursor.y; + const start_y = self.screens.active.cursor.y; defer { - self.screen.cursorAbsolute(self.scrolling_region.left, start_y); + self.screens.active.cursorAbsolute(self.scrolling_region.left, start_y); // Always unset pending wrap - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; } // We have a slower path if we have left or right scroll margins. @@ -1501,7 +1571,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { self.scrolling_region.right < self.cols - 1; // Remaining rows from our cursor to the bottom of the scroll region. - const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; + const rem = self.scrolling_region.bottom - self.screens.active.cursor.y + 1; // We can only insert lines up to our remaining lines in the scroll // region. So we take whichever is smaller. @@ -1509,8 +1579,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { // Create a new tracked pin which we'll use to navigate the page list // so that if we need to adjust capacity it will be properly tracked. - var cur_p = self.screen.pages.trackPin( - self.screen.cursor.page_pin.down(rem - 1).?, + var cur_p = self.screens.active.pages.trackPin( + self.screens.active.cursor.page_pin.down(rem - 1).?, ) catch |err| { comptime assert(@TypeOf(err) == error{OutOfMemory}); @@ -1522,7 +1592,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { log.err("insertLines trackPin error err={}", .{err}); @panic("insertLines trackPin OOM"); }; - defer self.screen.pages.untrackPin(cur_p); + defer self.screens.active.pages.untrackPin(cur_p); // Our current y position relative to the cursor var y: usize = rem; @@ -1570,7 +1640,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { const cap = dst_p.node.data.capacity; // Adjust our page capacity to make // room for we didn't have space for - _ = self.screen.adjustCapacity( + _ = self.screens.active.adjustCapacity( dst_p.node, switch (err) { // Rehash the sets @@ -1629,6 +1699,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 { @@ -1648,7 +1721,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { // Clear the cells for this row, it has been shifted. const page = &cur_p.node.data; const cells = page.getCells(cur_row); - self.screen.clearCells( + self.screens.active.clearCells( page, cur_row, cells[self.scrolling_region.left .. self.scrolling_region.right + 1], @@ -1683,22 +1756,22 @@ pub fn deleteLines(self: *Terminal, count: usize) void { if (count == 0) return; // If the cursor is outside the scroll region we do nothing. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; + if (self.screens.active.cursor.y < self.scrolling_region.top or + self.screens.active.cursor.y > self.scrolling_region.bottom or + self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) return; if (comptime build_options.kitty_graphics) { // Scrolling dirties the images because it updates their placements pins. - self.screen.kitty_images.dirty = true; + self.screens.active.kitty_images.dirty = true; } // At the end we need to return the cursor to the row it started on. - const start_y = self.screen.cursor.y; + const start_y = self.screens.active.cursor.y; defer { - self.screen.cursorAbsolute(self.scrolling_region.left, start_y); + self.screens.active.cursorAbsolute(self.scrolling_region.left, start_y); // Always unset pending wrap - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; } // We have a slower path if we have left or right scroll margins. @@ -1706,7 +1779,7 @@ pub fn deleteLines(self: *Terminal, count: usize) void { self.scrolling_region.right < self.cols - 1; // Remaining rows from our cursor to the bottom of the scroll region. - const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; + const rem = self.scrolling_region.bottom - self.screens.active.cursor.y + 1; // We can only insert lines up to our remaining lines in the scroll // region. So we take whichever is smaller. @@ -1714,15 +1787,15 @@ pub fn deleteLines(self: *Terminal, count: usize) void { // Create a new tracked pin which we'll use to navigate the page list // so that if we need to adjust capacity it will be properly tracked. - var cur_p = self.screen.pages.trackPin( - self.screen.cursor.page_pin.*, + var cur_p = self.screens.active.pages.trackPin( + self.screens.active.cursor.page_pin.*, ) catch |err| { // See insertLines comptime assert(@TypeOf(err) == error{OutOfMemory}); log.err("deleteLines trackPin error err={}", .{err}); @panic("deleteLines trackPin OOM"); }; - defer self.screen.pages.untrackPin(cur_p); + defer self.screens.active.pages.untrackPin(cur_p); // Our current y position relative to the cursor var y: usize = 0; @@ -1770,7 +1843,7 @@ pub fn deleteLines(self: *Terminal, count: usize) void { const cap = dst_p.node.data.capacity; // Adjust our page capacity to make // room for we didn't have space for - _ = self.screen.adjustCapacity( + _ = self.screens.active.adjustCapacity( dst_p.node, switch (err) { // Rehash the sets @@ -1824,6 +1897,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 { @@ -1843,7 +1919,7 @@ pub fn deleteLines(self: *Terminal, count: usize) void { // Clear the cells for this row, it's from out of bounds. const page = &cur_p.node.data; const cells = page.getCells(cur_row); - self.screen.clearCells( + self.screens.active.clearCells( page, cur_row, cells[self.scrolling_region.left .. self.scrolling_region.right + 1], @@ -1868,35 +1944,35 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // Unset pending wrap state without wrapping. Note: this purposely // happens BEFORE the scroll region check below, because that's what // xterm does. - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // If our cursor is outside the margins then do nothing. We DO reset // wrap state still so this must remain below the above logic. - if (self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; + if (self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) return; // If our count is larger than the remaining amount, we just erase right. // We only do this if we can erase the entire line (no right margin). // if (right_limit == self.cols and - // count > right_limit - self.screen.cursor.x) + // count > right_limit - self.screens.active.cursor.x) // { // self.eraseLine(.right, false); // return; // } // left is just the cursor position but as a multi-pointer - const left: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - var page = &self.screen.cursor.page_pin.node.data; + const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); + var page = &self.screens.active.cursor.page_pin.node.data; // If our X is a wide spacer tail then we need to erase the // previous cell too so we don't split a multi-cell character. - if (self.screen.cursor.page_cell.wide == .spacer_tail) { - assert(self.screen.cursor.x > 0); - self.screen.clearCells(page, self.screen.cursor.page_row, (left - 1)[0..2]); + if (self.screens.active.cursor.page_cell.wide == .spacer_tail) { + assert(self.screens.active.cursor.x > 0); + self.screens.active.clearCells(page, self.screens.active.cursor.page_row, (left - 1)[0..2]); } // Remaining cols from our cursor to the right margin. - const rem = self.scrolling_region.right - self.screen.cursor.x + 1; + const rem = self.scrolling_region.right - self.screens.active.cursor.x + 1; // We can only insert blanks up to our remaining cols const adjusted_count = @min(count, rem); @@ -1917,9 +1993,9 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { if (end.wide == .wide) { const end_multi: [*]Cell = @ptrCast(end); assert(end_multi[1].wide == .spacer_tail); - self.screen.clearCells( + self.screens.active.clearCells( page, - self.screen.cursor.page_row, + self.screens.active.cursor.page_row, end_multi[0..2], ); } @@ -1933,10 +2009,10 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { } // Insert blanks. The blanks preserve the background color. - self.screen.clearCells(page, self.screen.cursor.page_row, left[0..adjusted_count]); + self.screens.active.clearCells(page, self.screens.active.cursor.page_row, left[0..adjusted_count]); // Our row is always dirty - self.screen.cursorMarkDirty(); + self.screens.active.cursorMarkDirty(); } /// Removes amount characters from the current cursor position to the right. @@ -1952,22 +2028,22 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void { // If our cursor is outside the margins then do nothing. We DO reset // wrap state still so this must remain below the above logic. - if (self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; + if (self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) return; // left is just the cursor position but as a multi-pointer - const left: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - var page = &self.screen.cursor.page_pin.node.data; + const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); + var page = &self.screens.active.cursor.page_pin.node.data; // Remaining cols from our cursor to the right margin. - const rem = self.scrolling_region.right - self.screen.cursor.x + 1; + const rem = self.scrolling_region.right - self.screens.active.cursor.x + 1; // We can only insert blanks up to our remaining cols const count = @min(count_req, rem); - self.screen.splitCellBoundary(self.screen.cursor.x); - self.screen.splitCellBoundary(self.screen.cursor.x + count); - self.screen.splitCellBoundary(self.scrolling_region.right + 1); + self.screens.active.splitCellBoundary(self.screens.active.cursor.x); + self.screens.active.splitCellBoundary(self.screens.active.cursor.x + count); + self.screens.active.splitCellBoundary(self.scrolling_region.right + 1); // This is the amount of space at the right of the scroll region // that will NOT be blank, so we need to shift the correct cols right. @@ -1988,24 +2064,24 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void { } // Insert blanks. The blanks preserve the background color. - self.screen.clearCells(page, self.screen.cursor.page_row, x[0 .. rem - scroll_amount]); + self.screens.active.clearCells(page, self.screens.active.cursor.page_row, x[0 .. rem - scroll_amount]); // Our row's soft-wrap is always reset. - self.screen.cursorResetWrap(); + self.screens.active.cursorResetWrap(); // Our row is always dirty - self.screen.cursorMarkDirty(); + self.screens.active.cursorMarkDirty(); } pub fn eraseChars(self: *Terminal, count_req: usize) void { const count = end: { - const remaining = self.cols - self.screen.cursor.x; + const remaining = self.cols - self.screens.active.cursor.x; var end = @min(remaining, @max(count_req, 1)); // If our last cell is a wide char then we need to also clear the // cell beyond it since we can't just split a wide char. if (end != remaining) { - const last = self.screen.cursorCellRight(end - 1); + const last = self.screens.active.cursorCellRight(end - 1); if (last.wide == .wide) end += 1; } @@ -2017,33 +2093,33 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { // TODO(qwerasd): This isn't actually correct if you take in to account // protected modes. We need to figure out how to make `clearCells` or at // least `clearUnprotectedCells` handle boundary conditions... - self.screen.splitCellBoundary(self.screen.cursor.x); - self.screen.splitCellBoundary(self.screen.cursor.x + count); + self.screens.active.splitCellBoundary(self.screens.active.cursor.x); + self.screens.active.splitCellBoundary(self.screens.active.cursor.x + count); // Reset our row's soft-wrap. - self.screen.cursorResetWrap(); + self.screens.active.cursorResetWrap(); // Mark our cursor row as dirty - self.screen.cursorMarkDirty(); + self.screens.active.cursorMarkDirty(); // Clear the cells - const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); + const cells: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); // If we never had a protection mode, then we can assume no cells // are protected and go with the fast path. If the last protection // mode was not ISO we also always ignore protection attributes. - if (self.screen.protected_mode != .iso) { - self.screen.clearCells( - &self.screen.cursor.page_pin.node.data, - self.screen.cursor.page_row, + if (self.screens.active.protected_mode != .iso) { + self.screens.active.clearCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, cells[0..count], ); return; } - self.screen.clearUnprotectedCells( - &self.screen.cursor.page_pin.node.data, - self.screen.cursor.page_row, + self.screens.active.clearUnprotectedCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, cells[0..count], ); } @@ -2057,25 +2133,25 @@ pub fn eraseLine( // Get our start/end positions depending on mode. const start, const end = switch (mode) { .right => right: { - var x = self.screen.cursor.x; + var x = self.screens.active.cursor.x; // If our X is a wide spacer tail then we need to erase the // previous cell too so we don't split a multi-cell character. - if (x > 0 and self.screen.cursor.page_cell.wide == .spacer_tail) { + if (x > 0 and self.screens.active.cursor.page_cell.wide == .spacer_tail) { x -= 1; } // Reset our row's soft-wrap. - self.screen.cursorResetWrap(); + self.screens.active.cursorResetWrap(); break :right .{ x, self.cols }; }, .left => left: { - var x = self.screen.cursor.x; + var x = self.screens.active.cursor.x; // If our x is a wide char we need to delete the tail too. - if (self.screen.cursor.page_cell.wide == .wide) { + if (self.screens.active.cursor.page_cell.wide == .wide) { x += 1; } @@ -2094,36 +2170,36 @@ pub fn eraseLine( // All modes will clear the pending wrap state and we know we have // a valid mode at this point. - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // We always mark our row as dirty - self.screen.cursorMarkDirty(); + self.screens.active.cursorMarkDirty(); // Start of our cells const cells: [*]Cell = cells: { - const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - break :cells cells - self.screen.cursor.x; + const cells: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); + break :cells cells - self.screens.active.cursor.x; }; // We respect protected attributes if explicitly requested (probably // a DECSEL sequence) or if our last protected mode was ISO even if its // not currently set. - const protected = self.screen.protected_mode == .iso or protected_req; + const protected = self.screens.active.protected_mode == .iso or protected_req; // If we're not respecting protected attributes, we can use a fast-path // to fill the entire line. if (!protected) { - self.screen.clearCells( - &self.screen.cursor.page_pin.node.data, - self.screen.cursor.page_row, + self.screens.active.clearCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, cells[start..end], ); return; } - self.screen.clearUnprotectedCells( - &self.screen.cursor.page_pin.node.data, - self.screen.cursor.page_row, + self.screens.active.clearUnprotectedCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, cells[start..end], ); } @@ -2137,23 +2213,23 @@ pub fn eraseDisplay( // We respect protected attributes if explicitly requested (probably // a DECSEL sequence) or if our last protected mode was ISO even if its // not currently set. - const protected = self.screen.protected_mode == .iso or protected_req; + const protected = self.screens.active.protected_mode == .iso or protected_req; switch (mode) { .scroll_complete => { - self.screen.scrollClear() catch |err| { + self.screens.active.scrollClear() catch |err| { log.warn("scroll clear failed, doing a normal clear err={}", .{err}); self.eraseDisplay(.complete, protected_req); return; }; // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; if (comptime build_options.kitty_graphics) { // Clear all Kitty graphics state for this screen - self.screen.kitty_images.delete( - self.screen.alloc, + self.screens.active.kitty_images.delete( + self.screens.active.alloc, self, .{ .all = true }, ); @@ -2167,15 +2243,15 @@ pub fn eraseDisplay( // at a prompt scrolls the screen contents prior to clearing. // Most shells send `ESC [ H ESC [ 2 J` so we can't just check // our current cursor position. See #905 - if (self.active_screen == .primary) at_prompt: { + if (self.screens.active_key == .primary) at_prompt: { // Go from the bottom of the active up and see if we're // at a prompt. - const active_br = self.screen.pages.getBottomRight( + const active_br = self.screens.active.pages.getBottomRight( .active, ) orelse break :at_prompt; var it = active_br.rowIterator( .left_up, - self.screen.pages.getTopLeft(.active), + self.screens.active.pages.getTopLeft(.active), ); while (it.next()) |p| { const row = p.rowAndCell().row; @@ -2195,26 +2271,26 @@ pub fn eraseDisplay( } } else break :at_prompt; - self.screen.scrollClear() catch { + self.screens.active.scrollClear() catch { // If we fail, we just fall back to doing a normal clear // so we don't worry about the error. }; } // All active area - self.screen.clearRows( + self.screens.active.clearRows( .{ .active = .{} }, null, protected, ); // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; if (comptime build_options.kitty_graphics) { // Clear all Kitty graphics state for this screen - self.screen.kitty_images.delete( - self.screen.alloc, + self.screens.active.kitty_images.delete( + self.screens.active.alloc, self, .{ .all = true }, ); @@ -2229,16 +2305,16 @@ pub fn eraseDisplay( self.eraseLine(.right, protected_req); // All lines below - if (self.screen.cursor.y + 1 < self.rows) { - self.screen.clearRows( - .{ .active = .{ .y = self.screen.cursor.y + 1 } }, + if (self.screens.active.cursor.y + 1 < self.rows) { + self.screens.active.clearRows( + .{ .active = .{ .y = self.screens.active.cursor.y + 1 } }, null, protected, ); } // Unsets pending wrap state. Should be done by eraseLine. - assert(!self.screen.cursor.pending_wrap); + assert(!self.screens.active.cursor.pending_wrap); }, .above => { @@ -2246,19 +2322,19 @@ pub fn eraseDisplay( self.eraseLine(.left, protected_req); // All lines above - if (self.screen.cursor.y > 0) { - self.screen.clearRows( + if (self.screens.active.cursor.y > 0) { + self.screens.active.clearRows( .{ .active = .{ .y = 0 } }, - .{ .active = .{ .y = self.screen.cursor.y - 1 } }, + .{ .active = .{ .y = self.screens.active.cursor.y - 1 } }, protected, ); } // Unsets pending wrap state - assert(!self.screen.cursor.pending_wrap); + assert(!self.screens.active.cursor.pending_wrap); }, - .scrollback => self.screen.eraseRows(.{ .history = .{} }, null), + .scrollback => self.screens.active.eraseRows(.{ .history = .{} }, null), } } @@ -2268,13 +2344,13 @@ pub fn eraseDisplay( pub fn decaln(self: *Terminal) !void { // Clear our stylistic attributes. This is the only thing that can // fail so we do it first so we can undo it. - const old_style = self.screen.cursor.style; - self.screen.cursor.style = .{ - .bg_color = self.screen.cursor.style.bg_color, - .fg_color = self.screen.cursor.style.fg_color, + const old_style = self.screens.active.cursor.style; + self.screens.active.cursor.style = .{ + .bg_color = self.screens.active.cursor.style.bg_color, + .fg_color = self.screens.active.cursor.style.fg_color, }; - errdefer self.screen.cursor.style = old_style; - try self.screen.manualStyleUpdate(); + errdefer self.screens.active.cursor.style = old_style; + try self.screens.active.manualStyleUpdate(); // Reset margins, also sets cursor to top-left self.scrolling_region = .{ @@ -2292,7 +2368,7 @@ pub fn decaln(self: *Terminal) !void { // Use clearRows instead of eraseDisplay because we must NOT respect // protected attributes here. - self.screen.clearRows( + self.screens.active.clearRows( .{ .active = .{} }, null, false, @@ -2300,24 +2376,24 @@ pub fn decaln(self: *Terminal) !void { // Fill with Es by moving the cursor but reset it after. while (true) { - const page = &self.screen.cursor.page_pin.node.data; - const row = self.screen.cursor.page_row; + const page = &self.screens.active.cursor.page_pin.node.data; + const row = self.screens.active.cursor.page_row; const cells_multi: [*]Cell = row.cells.ptr(page.memory); const cells = cells_multi[0..page.size.cols]; @memset(cells, .{ .content_tag = .codepoint, .content = .{ .codepoint = 'E' }, - .style_id = self.screen.cursor.style_id, + .style_id = self.screens.active.cursor.style_id, // DECALN does not respect protected state. Verified with xterm. .protected = false, }); // If we have a ref-counted style, increase - if (self.screen.cursor.style_id != style.default_id) { + if (self.screens.active.cursor.style_id != style.default_id) { page.styles.useMultiple( page.memory, - self.screen.cursor.style_id, + self.screens.active.cursor.style_id, @intCast(cells.len), ); row.styled = true; @@ -2326,9 +2402,9 @@ pub fn decaln(self: *Terminal) !void { // We messed with the page so assert its integrity here. page.assertIntegrity(); - self.screen.cursorMarkDirty(); - if (self.screen.cursor.y == self.rows - 1) break; - self.screen.cursorDown(1); + self.screens.active.cursorMarkDirty(); + if (self.screens.active.cursor.y == self.rows - 1) break; + self.screens.active.cursorDown(1); } // Reset the cursor to the top-left @@ -2352,7 +2428,7 @@ pub fn kittyGraphics( /// Set a style attribute. pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { - try self.screen.setAttribute(attr); + try self.screens.active.setAttribute(attr); } /// Print the active attributes as a string. This is used to respond to DECRQSS @@ -2367,7 +2443,7 @@ pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { // The SGR response always starts with a 0. See https://vt100.net/docs/vt510-rm/DECRPSS try writer.writeByte('0'); - const pen = self.screen.cursor.style; + const pen = self.screens.active.cursor.style; var attrs: [8]u8 = @splat(0); var i: usize = 0; @@ -2496,25 +2572,22 @@ pub fn resize( self.tabstops = try .init(alloc, cols, 8); } - // If we're making the screen smaller, dealloc the unused items. - if (self.active_screen == .primary) { - if (self.flags.shell_redraws_prompt) { - self.screen.clearPrompt(); - } - - if (self.modes.get(.wraparound)) { - try self.screen.resize(cols, rows); - } else { - try self.screen.resizeWithoutReflow(cols, rows); - } - try self.secondary_screen.resizeWithoutReflow(cols, rows); + // Resize primary screen, which supports reflow + const primary = self.screens.get(.primary).?; + if (self.screens.active_key == .primary and + self.flags.shell_redraws_prompt) + { + primary.clearPrompt(); + } + if (self.modes.get(.wraparound)) { + try primary.resize(cols, rows); } else { - try self.screen.resizeWithoutReflow(cols, rows); - if (self.modes.get(.wraparound)) { - try self.secondary_screen.resize(cols, rows); - } else { - try self.secondary_screen.resizeWithoutReflow(cols, rows); - } + try primary.resizeWithoutReflow(cols, rows); + } + + // Alternate screen, if it exists, doesn't reflow + if (self.screens.get(.alternate)) |alt| { + try alt.resizeWithoutReflow(cols, rows); } // Whenever we resize we just mark it as a screen clear @@ -2546,14 +2619,6 @@ pub fn getPwd(self: *const Terminal) ?[]const u8 { return self.pwd.items; } -/// Get the screen pointer for the given type. -pub fn getScreen(self: *Terminal, t: ScreenType) *Screen { - return if (self.active_screen == t) - &self.screen - else - &self.secondary_screen; -} - /// Switch to the given screen type (alternate or primary). /// /// This does NOT handle behaviors such as clearing the screen, @@ -2569,40 +2634,62 @@ pub fn getScreen(self: *Terminal, t: ScreenType) *Screen { /// more than two screens in the future if needed. There isn't /// currently a spec for this, but it is something I think might /// be useful in the future. -pub fn switchScreen(self: *Terminal, t: ScreenType) ?*Screen { +pub fn switchScreen(self: *Terminal, key: ScreenSet.Key) !?*Screen { // If we're already on the requested screen we do nothing. - if (self.active_screen == t) return null; + if (self.screens.active_key == key) return null; + const old = self.screens.active; // We always end hyperlink state when switching screens. // We need to do this on the original screen. - self.screen.endHyperlink(); + old.endHyperlink(); - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = t; + // Switch the screens/ + const new = self.screens.get(key) orelse new: { + const primary = self.screens.get(.primary).?; + break :new try self.screens.getInit( + old.alloc, + key, + .{ + .cols = self.cols, + .rows = self.rows, + .max_scrollback = switch (key) { + .primary => primary.pages.explicit_max_size, + .alternate => 0, + }, + + // Inherit our Kitty image storage limit from the primary + // screen if we have to initialize. + .kitty_image_storage_limit = if (comptime build_options.kitty_graphics) + primary.kitty_images.total_limit + else + 0, + }, + ); + }; // The new screen should not have any hyperlinks set - assert(self.screen.cursor.hyperlink_id == 0); + assert(new.cursor.hyperlink_id == 0); // Bring our charset state with us - self.screen.charset = old.charset; + new.charset = old.charset; // Clear our selection - self.screen.clearSelection(); + new.clearSelection(); if (comptime build_options.kitty_graphics) { // Mark kitty images as dirty so they redraw. Without this set // the images will remain where they were (the dirty bit on // the screen only tracks the terminal grid, not the images). - self.screen.kitty_images.dirty = true; + new.kitty_images.dirty = true; } // Mark our terminal as dirty to redraw the grid. self.flags.dirty.clear = true; - return &self.secondary_screen; + // Finalize the switch + self.screens.switchTo(key); + + return old; } /// Switch screen via a mode switch (e.g. mode 47, 1047, 1049). @@ -2618,7 +2705,7 @@ pub fn switchScreenMode( self: *Terminal, mode: SwitchScreenMode, enabled: bool, -) void { +) !void { // The behavior in this function is completely based on reading // the xterm source, specifically "charproc.c" for // `srm_ALTBUF`, `srm_OPT_ALTBUF`, and `srm_OPT_ALTBUF_CURSOR`. @@ -2630,7 +2717,7 @@ pub fn switchScreenMode( // If we're disabling 1047 and we're on alt screen then // we clear the screen. - .@"1047" => if (!enabled and self.active_screen == .alternate) { + .@"1047" => if (!enabled and self.screens.active_key == .alternate) { self.eraseDisplay(.complete, false); }, @@ -2640,8 +2727,8 @@ pub fn switchScreenMode( } // Switch screens first to whatever we're going to. - const to: ScreenType = if (enabled) .alternate else .primary; - const old_ = self.switchScreen(to); + const to: ScreenSet.Key = if (enabled) .alternate else .primary; + const old_ = try self.switchScreen(to); switch (mode) { // For these modes, we need to copy the cursor. We only copy @@ -2649,7 +2736,7 @@ pub fn switchScreenMode( // cursor is already copied. The cursor is copied regardless // of destination screen. .@"47", .@"1047" => if (old_) |old| { - self.screen.cursorCopy(old.cursor, .{ + self.screens.active.cursorCopy(old.cursor, .{ .hyperlink = false, }) catch |err| { log.warn( @@ -2662,14 +2749,14 @@ pub fn switchScreenMode( // Mode 1049 restores cursor on the primary screen when // we disable it. .@"1049" => if (enabled) { - assert(self.active_screen == .alternate); + assert(self.screens.active_key == .alternate); self.eraseDisplay(.complete, false); // When we enter alt screen with 1049, we always copy the // cursor from the primary screen (if we weren't already // on it). if (old_) |old| { - self.screen.cursorCopy(old.cursor, .{ + self.screens.active.cursorCopy(old.cursor, .{ .hyperlink = false, }) catch |err| { log.warn( @@ -2679,7 +2766,7 @@ pub fn switchScreenMode( }; } } else { - assert(self.active_screen == .primary); + assert(self.screens.active_key == .primary); self.restoreCursor() catch |err| { log.warn( "restore cursor on switch screen failed to={} err={}", @@ -2716,12 +2803,12 @@ pub const SwitchScreenMode = enum { /// /// The caller must free the string. pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 { - return try self.screen.dumpStringAlloc(alloc, .{ .viewport = .{} }); + return try self.screens.active.dumpStringAlloc(alloc, .{ .viewport = .{} }); } /// Same as plainString, but respects row wrap state when building the string. pub fn plainStringUnwrapped(self: *Terminal, alloc: Allocator) ![]const u8 { - return try self.screen.dumpStringAllocUnwrapped(alloc, .{ .viewport = .{} }); + return try self.screens.active.dumpStringAllocUnwrapped(alloc, .{ .viewport = .{} }); } /// Full reset. @@ -2730,17 +2817,15 @@ pub fn plainStringUnwrapped(self: *Terminal, alloc: Allocator) ![]const u8 { /// this will reuse the existing memory. In the latter case, memory may /// be wasted (since its unused) but it isn't leaked. pub fn fullReset(self: *Terminal) void { - // Reset our screens - self.screen.reset(); - self.secondary_screen.reset(); - // Ensure we're back on primary screen - if (self.active_screen != .primary) { - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .primary; - } + self.screens.switchTo(.primary); + self.screens.remove( + self.screens.active.alloc, + .alternate, + ); + + // Reset our screens + self.screens.active.reset(); // Rest our basic state self.modes.reset(); @@ -2762,12 +2847,12 @@ pub fn fullReset(self: *Terminal) void { /// Returns true if the point is dirty, used for testing. fn isDirty(t: *const Terminal, pt: point.Point) bool { - return t.screen.pages.getCell(pt).?.isDirty(); + return t.screens.active.pages.getCell(pt).?.isDirty(); } /// Clear all dirty bits. Testing only. fn clearDirty(t: *Terminal) void { - t.screen.pages.clearDirty(); + t.screens.active.pages.clearDirty(); } test "Terminal: input with no control characters" { @@ -2777,8 +2862,8 @@ test "Terminal: input with no control characters" { // Basic grid writing for ("hello") |c| try t.print(c); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); { const str = try t.plainString(alloc); defer alloc.free(str); @@ -2797,9 +2882,9 @@ test "Terminal: input with basic wraparound" { // Basic grid writing for ("helloworldabc12") |c| try t.print(c); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); { const str = try t.plainString(alloc); defer alloc.free(str); @@ -2829,8 +2914,8 @@ test "Terminal: input that forces scroll" { // Basic grid writing for ("abcdef") |c| try t.print(c); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); { const str = try t.plainString(alloc); defer alloc.free(str); @@ -2864,19 +2949,19 @@ test "Terminal: input glitch text" { // Get our initial grapheme capacity. const grapheme_cap = cap: { - const page = t.screen.pages.pages.first.?; + const page = t.screens.active.pages.pages.first.?; break :cap page.data.capacity.grapheme_bytes; }; // Print glitch text until our capacity changes while (true) { - const page = t.screen.pages.pages.first.?; + const page = t.screens.active.pages.pages.first.?; if (page.data.capacity.grapheme_bytes != grapheme_cap) break; try t.printString(glitch); } // We're testing to make sure that grapheme capacity gets increased. - const page = t.screen.pages.pages.first.?; + const page = t.screens.active.pages.pages.first.?; try testing.expect(page.data.capacity.grapheme_bytes > grapheme_cap); } @@ -2888,8 +2973,8 @@ test "Terminal: zero-width character at start" { // just ignore it. try t.print(0x200D); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); // Should not be dirty since we changed nothing. try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); @@ -2910,17 +2995,17 @@ test "Terminal: print wide char" { defer t.deinit(testing.allocator); try t.print(0x1F600); // Smiley face - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } @@ -2934,22 +3019,22 @@ test "Terminal: print wide char at edge creates spacer head" { t.setCursorPos(1, 10); try t.print(0x1F600); // Smiley face - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 9, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 9, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } @@ -2978,12 +3063,12 @@ test "Terminal: print wide char in single-width terminal" { defer t.deinit(testing.allocator); try t.print(0x1F600); // Smiley face - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -3000,17 +3085,17 @@ test "Terminal: print over wide char at 0,0" { t.setCursorPos(0, 0); try t.print('A'); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + 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); @@ -3029,13 +3114,13 @@ test "Terminal: print over wide spacer tail" { try t.print('X'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'X'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -3058,7 +3143,7 @@ test "Terminal: print over wide char with bold" { try t.print(0x1F600); // Smiley face // verify we have styles in our style map { - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -3069,7 +3154,7 @@ test "Terminal: print over wide char with bold" { // verify our style is gone { - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -3088,7 +3173,7 @@ test "Terminal: print over wide char with bg color" { try t.print(0x1F600); // Smiley face // verify we have styles in our style map { - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -3099,7 +3184,7 @@ test "Terminal: print over wide char with bg color" { // verify our style is gone { - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -3119,13 +3204,13 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try t.print(0x1F467); // We should have 6 cells taken up - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 6), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 6), t.screens.active.cursor.x); // Assert various properties about our screen to verify // we have all expected cells. { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3134,7 +3219,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(usize, 1), cps.len); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + 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.expect(!cell.hasGrapheme()); @@ -3142,7 +3227,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F469), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3151,7 +3236,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(usize, 1), cps.len); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3159,7 +3244,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F467), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3167,7 +3252,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3195,7 +3280,7 @@ test "Terminal: VS16 doesn't make character with 2027 disabled" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3228,21 +3313,21 @@ 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". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + // 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); // Assert various properties about our screen to verify // we have all expected cells. { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + 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); } @@ -3279,8 +3364,8 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { try t.print(0x1F467); // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // Row should be dirty try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); @@ -3288,7 +3373,7 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { // Assert various properties about our screen to verify // we have all expected cells. { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3297,7 +3382,7 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { try testing.expectEqual(@as(usize, 4), cps.len); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + 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.expect(!cell.hasGrapheme()); @@ -3305,6 +3390,93 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { } } +test "Terminal: keypad sequence VS15" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // This is: "#︎" (number sign with text presentation selector) + try t.print(0x23); // # Number sign (valid base) + try t.print(0xFE0E); // VS15 (text presentation selector) + + // VS15 should combine with the base character into a single grapheme cluster, + // taking 1 cell (narrow character). + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + // Row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // The base emoji should be in cell 0 with the skin tone as a grapheme + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x23), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: keypad sequence VS16" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // This is: "#️" (number sign with emoji presentation selector) + try t.print(0x23); // # Number sign (valid base) + try t.print(0xFE0F); // VS16 (emoji presentation selector) + + // VS16 should combine with the base character into a single grapheme cluster, + // taking 2 cells (wide character). + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // The base emoji should be in cell 0 with the skin tone as a grapheme + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x23), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } +} + +test "Terminal: Fitzpatrick skin tone next valid base" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // This is: "👋🏿" (waving hand with dark skin tone) + try t.print(0x1F44B); // 👋 Waving hand (valid base) + try t.print(0x1F3FF); // 🏿 Dark skin tone modifier + + // The skin tone should combine with the base emoji into a single grapheme cluster, + // taking 2 cells (wide character). + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // The base emoji should be in cell 0 with the skin tone as a grapheme + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F44B), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } +} + test "Terminal: Fitzpatrick skin tone next to non-base" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); @@ -3319,8 +3491,8 @@ test "Terminal: Fitzpatrick skin tone next to non-base" { // We should have 4 cells taken up. Importantly, the skin tone // should not join with the quotes. - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); // Row should be dirty try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); @@ -3328,21 +3500,21 @@ test "Terminal: Fitzpatrick skin tone next to non-base" { // Assert various properties about our screen to verify // we have all expected cells. { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x22), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F3FF), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x22), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3375,8 +3547,8 @@ test "Terminal: multicodepoint grapheme marks dirty on every codepoint" { try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); } test "Terminal: VS15 to make narrow character" { @@ -3391,16 +3563,16 @@ test "Terminal: VS15 to make narrow character" { t.clearDirty(); // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); try t.print(0xFE0E); // VS15 to make narrow try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); t.clearDirty(); // VS15 should send us back a cell since our char is no longer wide. - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); @@ -3409,7 +3581,7 @@ test "Terminal: VS15 to make narrow character" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3434,8 +3606,8 @@ test "Terminal: VS15 on already narrow emoji" { t.clearDirty(); // Character takes up one cell - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); @@ -3444,7 +3616,7 @@ test "Terminal: VS15 on already narrow emoji" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x26C8), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3454,6 +3626,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); @@ -3466,18 +3703,18 @@ test "Terminal: VS15 to make narrow character with pending wrap" { t.clearDirty(); // We only move one because we're in a pending wrap state. - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); try t.print(0xFE0E); // VS15 to make narrow try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); t.clearDirty(); // VS15 should clear the pending wrap state - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); { const str = try t.plainString(testing.allocator); @@ -3486,7 +3723,7 @@ test "Terminal: VS15 to make narrow character with pending wrap" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3517,7 +3754,7 @@ test "Terminal: VS16 to make wide character with mode 2027" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3548,7 +3785,7 @@ test "Terminal: VS16 repeated with mode 2027" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3557,7 +3794,7 @@ test "Terminal: VS16 repeated with mode 2027" { try testing.expectEqual(@as(usize, 1), cps.len); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3576,23 +3813,23 @@ 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". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + // 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.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + 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); @@ -3611,14 +3848,14 @@ 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". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + // 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); // Assert various properties about our screen to verify // we have all expected cells. { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3626,7 +3863,7 @@ test "Terminal: print invalid VS16 with second char" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'y'), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3634,6 +3871,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); @@ -3657,7 +3928,7 @@ test "Terminal: overwrite grapheme should clear grapheme data" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3681,11 +3952,11 @@ test "Terminal: overwrite multicodepoint grapheme clears grapheme data" { try t.print(0x1F467); // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // We should have one cell with graphemes - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.graphemeCount()); // Move back and overwrite wide @@ -3694,8 +3965,8 @@ test "Terminal: overwrite multicodepoint grapheme clears grapheme data" { try t.print('X'); try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 0), page.graphemeCount()); { @@ -3721,11 +3992,11 @@ test "Terminal: overwrite multicodepoint grapheme tail clears grapheme data" { try t.print(0x1F467); // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // We should have one cell with graphemes - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.graphemeCount()); // Move back and overwrite wide @@ -3738,8 +4009,8 @@ test "Terminal: overwrite multicodepoint grapheme tail clears grapheme data" { try testing.expectEqualStrings(" X", str); } - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 0), page.graphemeCount()); } @@ -3763,7 +4034,7 @@ test "Terminal: print writes to bottom if scrolled" { } // Scroll to the top - t.screen.scroll(.{ .top = {} }); + t.screens.active.scroll(.{ .top = {} }); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -3772,7 +4043,7 @@ test "Terminal: print writes to bottom if scrolled" { // Type try t.print('A'); - t.screen.scroll(.{ .active = {} }); + t.screens.active.scroll(.{ .active = {} }); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -3780,8 +4051,8 @@ test "Terminal: print writes to bottom if scrolled" { } try testing.expect(t.isDirty(.{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } })); } @@ -3888,11 +4159,11 @@ test "Terminal: print kitty unicode placeholder" { defer t.deinit(testing.allocator); try t.print(kitty.graphics.unicode.placeholder); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, kitty.graphics.unicode.placeholder), cell.content.codepoint); try testing.expect(list_cell.row.kitty_virtual_placeholder); @@ -3907,8 +4178,8 @@ test "Terminal: soft wrap" { // Basic grid writing for ("hello") |c| try t.print(c); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -3927,11 +4198,11 @@ test "Terminal: soft wrap with semantic prompt" { for ("hello") |c| try t.print(c); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); } } @@ -3947,8 +4218,8 @@ test "Terminal: disabled wraparound with wide char and one space" { try t.printString("AAAA"); t.clearDirty(); try t.print(0x1F6A8); // Police car light - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); @@ -3958,7 +4229,7 @@ test "Terminal: disabled wraparound with wide char and one space" { // Make sure we printed nothing { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -3979,8 +4250,8 @@ test "Terminal: disabled wraparound with wide char and no space" { try t.printString("AAAAA"); t.clearDirty(); try t.print(0x1F6A8); // Police car light - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); @@ -3989,7 +4260,7 @@ test "Terminal: disabled wraparound with wide char and no space" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -4012,8 +4283,8 @@ test "Terminal: disabled wraparound with wide grapheme and half space" { try t.print(0x2764); // Heart t.clearDirty(); try t.print(0xFE0F); // VS16 to make wide - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); @@ -4022,7 +4293,7 @@ test "Terminal: disabled wraparound with wide grapheme and half space" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, '❤'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -4049,7 +4320,7 @@ test "Terminal: print right margin wrap" { } { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; const row = list_cell.row; try testing.expect(!row.wrap); } @@ -4129,15 +4400,15 @@ test "Terminal: print wide char at right margin does not create spacer head" { t.setLeftAndRightMargin(3, 5); t.setCursorPos(1, 5); try t.print(0x1F600); // Smiley face - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); // Both rows dirty because the cursor moved try testing.expect(t.isDirty(.{ .screen = .{ .x = 4, .y = 0 } })); try testing.expect(t.isDirty(.{ .screen = .{ .x = 4, .y = 1 } })); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -4146,13 +4417,13 @@ test "Terminal: print wide char at right margin does not create spacer head" { try testing.expect(!row.wrap); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } @@ -4163,12 +4434,12 @@ test "Terminal: print with hyperlink" { defer t.deinit(testing.allocator); // Setup our hyperlink and print - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("123456"); // Verify all our cells have a hyperlink for (0..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4188,14 +4459,14 @@ test "Terminal: print over cell with same hyperlink" { defer t.deinit(testing.allocator); // Setup our hyperlink and print - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("123456"); t.setCursorPos(1, 1); try t.printString("123456"); // Verify all our cells have a hyperlink for (0..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4215,14 +4486,14 @@ test "Terminal: print and end hyperlink" { defer t.deinit(testing.allocator); // Setup our hyperlink and print - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("123"); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); try t.printString("456"); // Verify all our cells have a hyperlink for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4234,7 +4505,7 @@ test "Terminal: print and end hyperlink" { try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (3..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4252,14 +4523,14 @@ test "Terminal: print and change hyperlink" { defer t.deinit(testing.allocator); // Setup our hyperlink and print - try t.screen.startHyperlink("http://one.example.com", null); + try t.screens.active.startHyperlink("http://one.example.com", null); try t.printString("123"); - try t.screen.startHyperlink("http://two.example.com", null); + try t.screens.active.startHyperlink("http://two.example.com", null); try t.printString("456"); // Verify all our cells have a hyperlink for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4269,7 +4540,7 @@ test "Terminal: print and change hyperlink" { try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (3..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4287,15 +4558,15 @@ test "Terminal: overwrite hyperlink" { defer t.deinit(testing.allocator); // Setup our hyperlink and print - try t.screen.startHyperlink("http://one.example.com", null); + try t.screens.active.startHyperlink("http://one.example.com", null); try t.printString("123"); t.setCursorPos(1, 1); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); try t.printString("456"); // Verify all our cells have a hyperlink for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4330,8 +4601,8 @@ test "Terminal: linefeed and carriage return" { try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); for ("world") |c| try t.print(c); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4345,12 +4616,12 @@ test "Terminal: linefeed unsets pending wrap" { // Basic grid writing for ("hello") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap == true); + try testing.expect(t.screens.active.cursor.pending_wrap == true); t.clearDirty(); try t.linefeed(); try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); - try testing.expect(t.screen.cursor.pending_wrap == false); + try testing.expect(t.screens.active.cursor.pending_wrap == false); } test "Terminal: linefeed mode automatic carriage return" { @@ -4375,9 +4646,9 @@ test "Terminal: carriage return unsets pending wrap" { // Basic grid writing for ("hello") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap == true); + try testing.expect(t.screens.active.cursor.pending_wrap == true); t.carriageReturn(); - try testing.expect(t.screen.cursor.pending_wrap == false); + try testing.expect(t.screens.active.cursor.pending_wrap == false); } test "Terminal: carriage return origin mode moves to left margin" { @@ -4385,30 +4656,30 @@ test "Terminal: carriage return origin mode moves to left margin" { defer t.deinit(testing.allocator); t.modes.set(.origin, true); - t.screen.cursor.x = 0; + t.screens.active.cursor.x = 0; t.scrolling_region.left = 2; t.carriageReturn(); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); } test "Terminal: carriage return left of left margin moves to zero" { var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); - t.screen.cursor.x = 1; + t.screens.active.cursor.x = 1; t.scrolling_region.left = 2; t.carriageReturn(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); } test "Terminal: carriage return right of left margin moves to left margin" { var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); - t.screen.cursor.x = 3; + t.screens.active.cursor.x = 3; t.scrolling_region.left = 2; t.carriageReturn(); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); } test "Terminal: backspace" { @@ -4419,8 +4690,8 @@ test "Terminal: backspace" { for ("hello") |c| try t.print(c); t.backspace(); try t.print('y'); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4436,17 +4707,17 @@ test "Terminal: horizontal tabs" { // HT try t.print('1'); try t.horizontalTab(); - try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 8), t.screens.active.cursor.x); // HT try t.horizontalTab(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x); // HT at the end try t.horizontalTab(); - try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 19), t.screens.active.cursor.x); try t.horizontalTab(); - try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 19), t.screens.active.cursor.x); } test "Terminal: horizontal tabs starting on tabstop" { @@ -4454,9 +4725,9 @@ test "Terminal: horizontal tabs starting on tabstop" { var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); - t.setCursorPos(t.screen.cursor.y, 9); + t.setCursorPos(t.screens.active.cursor.y, 9); try t.print('X'); - t.setCursorPos(t.screen.cursor.y, 9); + t.setCursorPos(t.screens.active.cursor.y, 9); try t.horizontalTab(); try t.print('A'); @@ -4474,7 +4745,7 @@ test "Terminal: horizontal tabs with right margin" { t.scrolling_region.left = 2; t.scrolling_region.right = 5; - t.setCursorPos(t.screen.cursor.y, 1); + t.setCursorPos(t.screens.active.cursor.y, 1); try t.print('X'); try t.horizontalTab(); try t.print('A'); @@ -4492,21 +4763,21 @@ test "Terminal: horizontal tabs back" { defer t.deinit(alloc); // Edge of screen - t.setCursorPos(t.screen.cursor.y, 20); + t.setCursorPos(t.screens.active.cursor.y, 20); // HT try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x); // HT try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 8), t.screens.active.cursor.x); // HT try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); } test "Terminal: horizontal tabs back starting on tabstop" { @@ -4514,9 +4785,9 @@ test "Terminal: horizontal tabs back starting on tabstop" { var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); - t.setCursorPos(t.screen.cursor.y, 9); + t.setCursorPos(t.screens.active.cursor.y, 9); try t.print('X'); - t.setCursorPos(t.screen.cursor.y, 9); + t.setCursorPos(t.screens.active.cursor.y, 9); try t.horizontalTabBack(); try t.print('A'); @@ -4573,9 +4844,9 @@ test "Terminal: cursorPos resets wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.setCursorPos(1, 1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -4663,52 +4934,52 @@ test "Terminal: setCursorPos (original test)" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); // Setting it to 0 should keep it zero (1 based) t.setCursorPos(0, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); // Should clamp to size t.setCursorPos(81, 81); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.y); // Should reset pending wrap t.setCursorPos(0, 80); try t.print('c'); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.setCursorPos(0, 80); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); // Origin mode t.modes.set(.origin, true); // No change without a scroll region t.setCursorPos(81, 81); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.y); // Set the scroll region t.setTopAndBottomMargin(10, t.rows); t.setCursorPos(0, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); t.setCursorPos(1, 1); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); t.setCursorPos(100, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.y); t.setTopAndBottomMargin(10, 11); t.setCursorPos(2, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 10), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 10), t.screens.active.cursor.y); } test "Terminal: setTopAndBottomMargin simple" { @@ -5039,7 +5310,7 @@ test "Terminal: insertLines colors with bg color" { } for (0..t.cols) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5070,7 +5341,7 @@ test "Terminal: insertLines handles style refs" { try t.setAttribute(.{ .unset = {} }); // verify we have styles in our style map - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); t.setCursorPos(2, 2); @@ -5274,9 +5545,9 @@ test "Terminal: insertLines resets pending wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.insertLines(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('B'); { @@ -5306,7 +5577,7 @@ test "Terminal: insertLines resets wrap" { } { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; const row = list_cell.row; try testing.expect(!row.wrap); } @@ -5389,15 +5660,17 @@ test "Terminal: scrollUp simple" { try t.printString("GHI"); t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - t.clearDirty(); - t.scrollUp(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); + const cursor = t.screens.active.cursor; + const viewport_before = t.screens.active.pages.getTopLeft(.viewport); + try t.scrollUp(1); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + // Viewport should have moved. Our entire page should've scrolled! + // The viewport moving will cause our render state to make the full + // frame as dirty. + const viewport_after = t.screens.active.pages.getTopLeft(.viewport); + try testing.expect(!viewport_before.eql(viewport_after)); { const str = try t.plainString(testing.allocator); @@ -5414,14 +5687,14 @@ test "Terminal: scrollUp moves hyperlink" { try t.printString("ABC"); t.carriageReturn(); try t.linefeed(); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("DEF"); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); t.carriageReturn(); try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5430,7 +5703,7 @@ test "Terminal: scrollUp moves hyperlink" { } for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5444,7 +5717,7 @@ test "Terminal: scrollUp moves hyperlink" { try testing.expectEqual(1, page.hyperlink_set.count()); } for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5462,9 +5735,9 @@ test "Terminal: scrollUp clears hyperlink" { var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("ABC"); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); t.carriageReturn(); try t.linefeed(); try t.printString("DEF"); @@ -5472,7 +5745,7 @@ test "Terminal: scrollUp clears hyperlink" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5481,7 +5754,7 @@ test "Terminal: scrollUp clears hyperlink" { } for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5510,7 +5783,7 @@ test "Terminal: scrollUp top/bottom scroll region" { t.setCursorPos(1, 1); t.clearDirty(); - t.scrollUp(1); + try t.scrollUp(1); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5540,11 +5813,11 @@ test "Terminal: scrollUp left/right scroll region" { t.scrolling_region.right = 3; t.setCursorPos(2, 2); - const cursor = t.screen.cursor; + const cursor = t.screens.active.cursor; t.clearDirty(); - t.scrollUp(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); + try t.scrollUp(1); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); @@ -5565,16 +5838,16 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try t.printString("ABC123"); t.carriageReturn(); try t.linefeed(); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("DEF456"); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); t.carriageReturn(); try t.linefeed(); try t.printString("GHI789"); t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5585,7 +5858,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { // First row gets some hyperlinks { for (0..1) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5595,7 +5868,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expect(id == null); } for (1..4) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5609,7 +5882,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expectEqual(1, page.hyperlink_set.count()); } for (4..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5623,7 +5896,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { // Second row preserves hyperlink where we didn't scroll { for (0..1) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5637,7 +5910,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expectEqual(1, page.hyperlink_set.count()); } for (1..4) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5647,7 +5920,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expect(id == null); } for (4..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5674,7 +5947,7 @@ test "Terminal: scrollUp preserves pending wrap" { try t.print('B'); t.setCursorPos(3, 5); try t.print('C'); - t.scrollUp(1); + try t.scrollUp(1); try t.print('X'); { @@ -5695,7 +5968,7 @@ test "Terminal: scrollUp full top/bottom region" { t.setTopAndBottomMargin(2, 5); t.clearDirty(); - t.scrollUp(4); + try t.scrollUp(4); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5721,7 +5994,7 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" { t.setLeftAndRightMargin(2, 4); t.clearDirty(); - t.scrollUp(4); + try t.scrollUp(4); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5737,6 +6010,143 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" { } } +test "Terminal: scrollUp creates scrollback in primary screen" { + // When in primary screen with full-width scroll region at top, + // scrollUp (CSI S) should push lines into scrollback like xterm. + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 10 }); + defer t.deinit(alloc); + + // Fill the screen with content + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DDDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("EEEEE"); + + t.clearDirty(); + + // Scroll up by 1, which should push "AAAAA" into scrollback + try t.scrollUp(1); + + // The cursor row (new empty row) should be dirty + try testing.expect(t.screens.active.cursor.page_row.dirty); + + // The active screen should now show BBBBB through EEEEE plus one blank line + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC\nDDDDD\nEEEEE", str); + } + + // Now scroll to the top to see scrollback - AAAAA should be there + t.screens.active.scroll(.{ .top = {} }); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // Should see AAAAA in scrollback + try testing.expectEqualStrings("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero" { + // When max_scrollback is 0, scrollUp should still work but not retain history + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + + try t.scrollUp(1); + + // Active screen should show scrolled content + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC", str); + } + + // Scroll to top - should be same as active since no scrollback + t.screens.active.scroll(.{ .top = {} }); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero and top margin" { + // When max_scrollback is 0 and top margin is set, should use deleteLines path + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DDDDD"); + + // Set top margin (not at row 0) + t.setTopAndBottomMargin(2, 5); + + try t.scrollUp(1); + + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // First row preserved, rest scrolled + try testing.expectEqualStrings("AAAAA\nCCCCC\nDDDDD", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero and left/right margin" { + // When max_scrollback is 0 with left/right margins, uses deleteLines path + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 10, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAABBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCCDDDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("EEEEEFFFFF"); + + // Set left/right margins (columns 2-6, 1-indexed = indices 1-5) + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 6); + + try t.scrollUp(1); + + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // cols 1-5 scroll, col 0 and cols 6+ preserved + try testing.expectEqualStrings("ACCCCDBBBB\nCEEEEFDDDD\nE FFFF", str); + } +} + test "Terminal: scrollDown simple" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); @@ -5751,11 +6161,11 @@ test "Terminal: scrollDown simple" { try t.printString("GHI"); t.setCursorPos(2, 2); - const cursor = t.screen.cursor; + const cursor = t.screens.active.cursor; t.clearDirty(); t.scrollDown(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); for (0..5) |y| try testing.expect(t.isDirty(.{ .active = .{ .x = 0, @@ -5774,9 +6184,9 @@ test "Terminal: scrollDown hyperlink moves" { var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("ABC"); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); t.carriageReturn(); try t.linefeed(); try t.printString("DEF"); @@ -5793,7 +6203,7 @@ test "Terminal: scrollDown hyperlink moves" { } for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5807,7 +6217,7 @@ test "Terminal: scrollDown hyperlink moves" { try testing.expectEqual(1, page.hyperlink_set.count()); } for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5835,11 +6245,11 @@ test "Terminal: scrollDown outside of scroll region" { t.setTopAndBottomMargin(3, 4); t.setCursorPos(2, 2); - const cursor = t.screen.cursor; + const cursor = t.screens.active.cursor; t.clearDirty(); t.scrollDown(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5871,11 +6281,11 @@ test "Terminal: scrollDown left/right scroll region" { t.scrolling_region.right = 3; t.setCursorPos(2, 2); - const cursor = t.screen.cursor; + const cursor = t.screens.active.cursor; t.clearDirty(); t.scrollDown(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); for (0..4) |y| try testing.expect(t.isDirty(.{ .active = .{ .x = 0, @@ -5894,9 +6304,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("ABC123"); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); t.carriageReturn(); try t.linefeed(); try t.printString("DEF456"); @@ -5917,7 +6327,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { // First row preserves hyperlink where we didn't scroll { for (0..1) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5931,7 +6341,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expectEqual(1, page.hyperlink_set.count()); } for (1..4) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5941,7 +6351,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expect(id == null); } for (4..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5959,7 +6369,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { // Second row gets some hyperlinks { for (0..1) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5969,7 +6379,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expect(id == null); } for (1..4) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5983,7 +6393,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expectEqual(1, page.hyperlink_set.count()); } for (4..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -6011,11 +6421,11 @@ test "Terminal: scrollDown outside of left/right scroll region" { t.scrolling_region.right = 3; t.setCursorPos(1, 1); - const cursor = t.screen.cursor; + const cursor = t.screens.active.cursor; t.clearDirty(); t.scrollDown(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); for (0..4) |y| try testing.expect(t.isDirty(.{ .active = .{ .x = 0, @@ -6130,9 +6540,9 @@ test "Terminal: eraseChars resets pending wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.eraseChars(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -6149,7 +6559,7 @@ test "Terminal: eraseChars resets wrap" { for ("ABCDE123") |c| try t.print(c); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; const row = list_cell.row; try testing.expect(row.wrap); } @@ -6158,7 +6568,7 @@ test "Terminal: eraseChars resets wrap" { t.eraseChars(1); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; const row = list_cell.row; try testing.expect(!row.wrap); } @@ -6191,7 +6601,7 @@ test "Terminal: eraseChars preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings(" C", str); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 0xFF, @@ -6200,7 +6610,7 @@ test "Terminal: eraseChars preserves background sgr" { }, list_cell.cell.content.color_rgb); } { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 1, .y = 0 } }).?; try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 0xFF, @@ -6223,7 +6633,7 @@ test "Terminal: eraseChars handles refcounted styles" { try t.print('C'); // verify we have styles in our style map - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); t.setCursorPos(1, 1); @@ -6300,7 +6710,7 @@ test "Terminal: eraseChars wide char boundary conditions" { t.setCursorPos(1, 2); t.eraseChars(3); - t.screen.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); { const str = try t.plainString(alloc); @@ -6330,7 +6740,7 @@ test "Terminal: eraseChars wide char splits proper cell boundaries" { t.setCursorPos(1, 6); // At: て t.eraseChars(4); // Delete: て下 - t.screen.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); { const str = try t.plainString(alloc); @@ -6357,7 +6767,7 @@ test "Terminal: eraseChars wide char wrap boundary conditions" { t.setCursorPos(2, 2); t.eraseChars(3); - t.screen.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); { const str = try t.plainString(alloc); @@ -6638,9 +7048,9 @@ test "Terminal: index scrolling with hyperlink" { defer t.deinit(alloc); t.setCursorPos(5, 1); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.print('A'); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); t.cursorLeft(1); // undo moving right from 'A' try t.index(); try t.print('B'); @@ -6652,7 +7062,7 @@ test "Terminal: index scrolling with hyperlink" { } { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = 0, .y = 3, } }).?; @@ -6664,7 +7074,7 @@ test "Terminal: index scrolling with hyperlink" { try testing.expectEqual(@as(hyperlink.Id, 1), id); } { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = 0, .y = 4, } }).?; @@ -6682,10 +7092,10 @@ test "Terminal: index outside of scrolling region" { var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); t.setTopAndBottomMargin(2, 5); try t.index(); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); } test "Terminal: index from the bottom outside of scroll region" { @@ -6768,7 +7178,7 @@ test "Terminal: index bottom of primary screen background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings("\n\n\nA", str); for (0..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 4, } }).?; @@ -6812,9 +7222,9 @@ test "Terminal: index bottom of scroll region with hyperlinks" { try t.print('A'); try t.index(); t.carriageReturn(); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.print('B'); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); try t.index(); t.carriageReturn(); try t.print('C'); @@ -6826,7 +7236,7 @@ test "Terminal: index bottom of scroll region with hyperlinks" { } { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = 0, .y = 0, } }).?; @@ -6838,7 +7248,7 @@ test "Terminal: index bottom of scroll region with hyperlinks" { try testing.expectEqual(@as(hyperlink.Id, 1), id); } { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = 0, .y = 1, } }).?; @@ -6858,9 +7268,9 @@ test "Terminal: index bottom of scroll region clear hyperlinks" { t.setTopAndBottomMargin(2, 3); t.setCursorPos(2, 1); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.print('A'); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); try t.index(); t.carriageReturn(); try t.print('B'); @@ -6875,7 +7285,7 @@ test "Terminal: index bottom of scroll region clear hyperlinks" { } for (1..3) |y| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = 0, .y = @intCast(y), } }).?; @@ -6914,7 +7324,7 @@ test "Terminal: index bottom of scroll region with background SGR" { } for (0..t.cols) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 2, } }).?; @@ -7003,8 +7413,8 @@ test "Terminal: index inside left/right margin" { try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); @@ -7027,12 +7437,12 @@ test "Terminal: index bottom of scroll region creates scrollback" { try t.print('Y'); { - const str = try t.screen.dumpStringAlloc(alloc, .{ .viewport = .{} }); + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer testing.allocator.free(str); try testing.expectEqualStrings("2\n3\nY\nX", str); } { - const str = try t.screen.dumpStringAlloc(alloc, .{ .screen = .{} }); + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); defer testing.allocator.free(str); try testing.expectEqualStrings("1\n2\n3\nY\nX", str); } @@ -7077,17 +7487,17 @@ test "Terminal: index bottom of scroll region blank line preserves SGR" { try t.index(); { - const str = try t.screen.dumpStringAlloc(alloc, .{ .viewport = .{} }); + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer testing.allocator.free(str); try testing.expectEqualStrings("2\n3\n\nX", str); } { - const str = try t.screen.dumpStringAlloc(alloc, .{ .screen = .{} }); + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); defer testing.allocator.free(str); try testing.expectEqualStrings("1\n2\n3\n\nX", str); } for (0..t.cols) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 2, } }).?; @@ -7160,9 +7570,9 @@ test "Terminal: cursorUp resets wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorUp(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7196,9 +7606,9 @@ test "Terminal: cursorLeft unsets pending wrap state" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7214,9 +7624,9 @@ test "Terminal: cursorLeft unsets pending wrap state with longer jump" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorLeft(3); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7235,9 +7645,9 @@ test "Terminal: cursorLeft reverse wrap with pending wrap state" { t.modes.set(.reverse_wrap, true); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7256,9 +7666,9 @@ test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { t.modes.set(.reverse_wrap_extended, true); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7279,7 +7689,7 @@ test "Terminal: cursorLeft reverse wrap" { for ("ABCDE1") |c| try t.print(c); t.cursorLeft(2); try t.print('X'); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); { const str = try t.plainString(testing.allocator); @@ -7407,8 +7817,8 @@ test "Terminal: cursorLeft extended reverse wrap above top scroll region" { t.setCursorPos(2, 1); t.cursorLeft(1000); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); } test "Terminal: cursorLeft reverse wrap on first row" { @@ -7423,8 +7833,8 @@ test "Terminal: cursorLeft reverse wrap on first row" { t.setCursorPos(1, 2); t.cursorLeft(1000); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); } test "Terminal: cursorDown basic" { @@ -7484,9 +7894,9 @@ test "Terminal: cursorDown resets wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorDown(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7502,9 +7912,9 @@ test "Terminal: cursorRight resets wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorRight(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7619,7 +8029,7 @@ test "Terminal: deleteLines colors with bg color" { } for (0..t.cols) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 4, } }).?; @@ -7657,8 +8067,8 @@ test "Terminal: deleteLines (legacy)" { try t.linefeed(); // We should be - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); { const str = try t.plainString(testing.allocator); @@ -7700,8 +8110,8 @@ test "Terminal: deleteLines with scroll region" { try t.linefeed(); // We should be - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + // try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); { const str = try t.plainString(testing.allocator); @@ -7743,8 +8153,8 @@ test "Terminal: deleteLines with scroll region, large count" { try t.linefeed(); // We should be - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + // try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); { const str = try t.plainString(testing.allocator); @@ -7794,9 +8204,9 @@ test "Terminal: deleteLines resets pending wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.deleteLines(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('B'); { @@ -7828,7 +8238,7 @@ test "Terminal: deleteLines resets wrap" { } for (0..t.rows) |y| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = @intCast(y), } }).?; @@ -8187,7 +8597,7 @@ test "Terminal: default style is empty" { try t.print('A'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expectEqual(@as(style.Id, 0), cell.style_id); @@ -8203,12 +8613,12 @@ test "Terminal: bold style" { try t.print('A'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expect(cell.style_id != 0); - const page = &t.screen.cursor.page_pin.node.data; - try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 1); + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 1); } } @@ -8224,14 +8634,14 @@ test "Terminal: garbage collect overwritten" { try t.print('B'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint); try testing.expect(cell.style_id == 0); } // verify we have no styles in our style map - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -8246,14 +8656,14 @@ test "Terminal: do not garbage collect old styles in use" { try t.print('B'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint); try testing.expect(cell.style_id == 0); } // verify we have no styles in our style map - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -8268,7 +8678,7 @@ test "Terminal: print with style marks the row as styled" { try t.print('B'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; try testing.expect(list_cell.row.styled); } } @@ -8285,8 +8695,8 @@ test "Terminal: DECALN" { try t.print('B'); try t.decaln(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); for (0..t.rows) |y| try testing.expect(t.isDirty(.{ .active = .{ .x = 0, @@ -8337,7 +8747,7 @@ test "Terminal: decaln preserves color" { } { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 0xFF, @@ -8366,10 +8776,10 @@ test "Terminal: DECALN resets graphemes with protected mode" { try t.decaln(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expect(t.screen.cursor.protected); - try testing.expect(t.screen.protected_mode == .iso); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.protected); + try testing.expect(t.screens.active.protected_mode == .iso); for (0..t.rows) |y| try testing.expect(t.isDirty(.{ .active = .{ .x = 0, @@ -8492,7 +8902,7 @@ test "Terminal: insertBlanks preserves background sgr" { try testing.expectEqualStrings(" ABC", str); } { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 0xFF, @@ -8572,11 +8982,11 @@ test "Terminal: insertBlanks outside left/right scroll region" { for ("ABC") |c| try t.print(c); t.scrolling_region.left = 2; t.scrolling_region.right = 4; - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.clearDirty(); t.insertBlanks(2); try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -8625,7 +9035,7 @@ test "Terminal: insertBlanks deleting graphemes" { try t.print(0x1F467); // We should have one cell with graphemes - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); @@ -8661,7 +9071,7 @@ test "Terminal: insertBlanks shift graphemes" { try t.print(0x1F467); // We should have one cell with graphemes - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); @@ -8708,7 +9118,7 @@ test "Terminal: insertBlanks shifts hyperlinks" { var t = try init(alloc, .{ .cols = 10, .rows = 2 }); defer t.deinit(alloc); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("ABC"); t.setCursorPos(1, 1); t.insertBlanks(2); @@ -8721,7 +9131,7 @@ test "Terminal: insertBlanks shifts hyperlinks" { // Verify all our cells have a hyperlink for (2..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -8733,7 +9143,7 @@ test "Terminal: insertBlanks shifts hyperlinks" { try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (0..2) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -8749,7 +9159,7 @@ test "Terminal: insertBlanks pushes hyperlink off end completely" { var t = try init(alloc, .{ .cols = 3, .rows = 2 }); defer t.deinit(alloc); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("ABC"); t.setCursorPos(1, 1); t.insertBlanks(3); @@ -8761,7 +9171,7 @@ test "Terminal: insertBlanks pushes hyperlink off end completely" { } for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -8976,9 +9386,9 @@ test "Terminal: deleteChars resets pending wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.deleteChars(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -8995,7 +9405,7 @@ test "Terminal: deleteChars resets wrap" { for ("ABCDE123") |c| try t.print(c); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; const row = list_cell.row; try testing.expect(row.wrap); } @@ -9003,7 +9413,7 @@ test "Terminal: deleteChars resets wrap" { t.deleteChars(1); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; const row = list_cell.row; try testing.expect(!row.wrap); } @@ -9056,7 +9466,7 @@ test "Terminal: deleteChars preserves background sgr" { try testing.expectEqualStrings("AB23", str); } for (t.cols - 2..t.cols) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 0, } }).?; @@ -9077,11 +9487,11 @@ test "Terminal: deleteChars outside scroll region" { try t.printString("ABC123"); t.scrolling_region.left = 2; t.scrolling_region.right = 4; - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.clearDirty(); t.deleteChars(2); try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); { const str = try t.plainString(testing.allocator); @@ -9137,13 +9547,13 @@ test "Terminal: deleteChars split wide character from wide" { t.deleteChars(1); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, '1'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -9160,13 +9570,13 @@ test "Terminal: deleteChars split wide character from end" { t.deleteChars(1); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x6A4B), cell.content.codepoint); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + 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); @@ -9180,7 +9590,7 @@ test "Terminal: deleteChars with a spacer head at the end" { try t.printString("0123橋123"); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const row = list_cell.row; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); @@ -9191,7 +9601,7 @@ test "Terminal: deleteChars with a spacer head at the end" { t.deleteChars(1); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -9271,7 +9681,7 @@ test "Terminal: deleteChars wide char boundary conditions" { t.setCursorPos(1, 2); t.deleteChars(3); - t.screen.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); { const str = try t.plainString(alloc); @@ -9323,7 +9733,7 @@ test "Terminal: deleteChars wide char wrap boundary conditions" { t.setCursorPos(2, 2); t.deleteChars(3); - t.screen.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); { const str = try t.plainString(alloc); @@ -9362,7 +9772,7 @@ test "Terminal: deleteChars wide char across right margin" { t.setCursorPos(1, 2); t.deleteChars(1); - t.screen.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); // NOTE: This behavior is slightly inconsistent with xterm. xterm // _visually_ splits the wide character (half the wide character shows @@ -9385,15 +9795,15 @@ test "Terminal: saveCursor" { defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); - t.screen.charset.gr = .G3; + t.screens.active.charset.gr = .G3; t.modes.set(.origin, true); t.saveCursor(); - t.screen.charset.gr = .G0; + t.screens.active.charset.gr = .G0; try t.setAttribute(.{ .unset = {} }); t.modes.set(.origin, false); try t.restoreCursor(); - try testing.expect(t.screen.cursor.style.flags.bold); - try testing.expect(t.screen.charset.gr == .G3); + try testing.expect(t.screens.active.cursor.style.flags.bold); + try testing.expect(t.screens.active.charset.gr == .G3); try testing.expect(t.modes.get(.origin)); } @@ -9481,13 +9891,13 @@ test "Terminal: saveCursor protected pen" { defer t.deinit(alloc); t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.protected); + try testing.expect(t.screens.active.cursor.protected); t.setCursorPos(1, 10); t.saveCursor(); t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.protected); + try testing.expect(!t.screens.active.cursor.protected); try t.restoreCursor(); - try testing.expect(t.screen.cursor.protected); + try testing.expect(t.screens.active.cursor.protected); } test "Terminal: saveCursor doesn't modify hyperlink state" { @@ -9495,12 +9905,12 @@ test "Terminal: saveCursor doesn't modify hyperlink state" { var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); - try t.screen.startHyperlink("http://example.com", null); - const id = t.screen.cursor.hyperlink_id; + try t.screens.active.startHyperlink("http://example.com", null); + const id = t.screens.active.cursor.hyperlink_id; t.saveCursor(); - try testing.expectEqual(id, t.screen.cursor.hyperlink_id); + try testing.expectEqual(id, t.screens.active.cursor.hyperlink_id); try t.restoreCursor(); - try testing.expectEqual(id, t.screen.cursor.hyperlink_id); + try testing.expectEqual(id, t.screens.active.cursor.hyperlink_id); } test "Terminal: setProtectedMode" { @@ -9508,15 +9918,15 @@ test "Terminal: setProtectedMode" { var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); - try testing.expect(!t.screen.cursor.protected); + try testing.expect(!t.screens.active.cursor.protected); t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.protected); + try testing.expect(!t.screens.active.cursor.protected); t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.protected); + try testing.expect(t.screens.active.cursor.protected); t.setProtectedMode(.dec); - try testing.expect(t.screen.cursor.protected); + try testing.expect(t.screens.active.cursor.protected); t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.protected); + try testing.expect(!t.screens.active.cursor.protected); } test "Terminal: eraseLine simple erase right" { @@ -9543,9 +9953,9 @@ test "Terminal: eraseLine resets pending wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.eraseLine(.right, false); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('B'); { @@ -9562,7 +9972,7 @@ test "Terminal: eraseLine resets wrap" { for ("ABCDE123") |c| try t.print(c); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; try testing.expect(list_cell.row.wrap); } @@ -9570,7 +9980,7 @@ test "Terminal: eraseLine resets wrap" { t.eraseLine(.right, false); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; try testing.expect(!list_cell.row.wrap); } try t.print('X'); @@ -9601,7 +10011,7 @@ test "Terminal: eraseLine right preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings("A", str); for (1..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 0, } }).?; @@ -9700,10 +10110,10 @@ test "Terminal: eraseLine right protected requested" { defer t.deinit(alloc); for ("12345678") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); t.setProtectedMode(.dec); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); + t.setCursorPos(t.screens.active.cursor.y + 1, 4); t.clearDirty(); t.eraseLine(.right, true); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -9739,11 +10149,11 @@ test "Terminal: eraseLine left resets wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.clearDirty(); t.eraseLine(.left, false); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('B'); { @@ -9772,7 +10182,7 @@ test "Terminal: eraseLine left preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings(" CDE", str); for (0..2) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 0, } }).?; @@ -9871,10 +10281,10 @@ test "Terminal: eraseLine left protected requested" { defer t.deinit(alloc); for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); t.setProtectedMode(.dec); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 8); + t.setCursorPos(t.screens.active.cursor.y + 1, 8); t.clearDirty(); t.eraseLine(.left, true); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -9905,7 +10315,7 @@ test "Terminal: eraseLine complete preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings("", str); for (0..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 0, } }).?; @@ -9984,10 +10394,10 @@ test "Terminal: eraseLine complete protected requested" { defer t.deinit(alloc); for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); t.setProtectedMode(.dec); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 8); + t.setCursorPos(t.screens.active.cursor.y + 1, 8); t.clearDirty(); t.eraseLine(.complete, true); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -10009,7 +10419,7 @@ test "Terminal: tabClear single" { try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); t.setCursorPos(1, 1); try t.horizontalTab(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x); } test "Terminal: tabClear all" { @@ -10021,7 +10431,7 @@ test "Terminal: tabClear all" { try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); t.setCursorPos(1, 1); try t.horizontalTab(); - try testing.expectEqual(@as(usize, 29), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 29), t.screens.active.cursor.x); } test "Terminal: printRepeat simple" { @@ -10176,7 +10586,7 @@ test "Terminal: eraseDisplay erase below preserves SGR bg" { defer testing.allocator.free(str); try testing.expectEqualStrings("ABC\nD", str); for (1..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 1, } }).?; @@ -10359,7 +10769,7 @@ test "Terminal: eraseDisplay erase above preserves SGR bg" { defer testing.allocator.free(str); try testing.expectEqualStrings("\n F\nGHI", str); for (0..2) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 1, } }).?; @@ -10498,10 +10908,10 @@ test "Terminal: eraseDisplay protected complete" { t.carriageReturn(); try t.linefeed(); for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); t.setProtectedMode(.dec); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); + t.setCursorPos(t.screens.active.cursor.y + 1, 4); t.clearDirty(); t.eraseDisplay(.complete, true); @@ -10526,10 +10936,10 @@ test "Terminal: eraseDisplay protected below" { t.carriageReturn(); try t.linefeed(); for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); t.setProtectedMode(.dec); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); + t.setCursorPos(t.screens.active.cursor.y + 1, 4); t.eraseDisplay(.below, true); { @@ -10565,10 +10975,10 @@ test "Terminal: eraseDisplay protected above" { t.carriageReturn(); try t.linefeed(); for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); t.setProtectedMode(.dec); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 8); + t.setCursorPos(t.screens.active.cursor.y + 1, 8); t.eraseDisplay(.above, true); { @@ -10586,13 +10996,13 @@ test "Terminal: eraseDisplay complete preserves cursor" { // Set our cursur try t.setAttribute(.{ .bold = {} }); try t.printString("AAAA"); - try testing.expect(t.screen.cursor.style_id != style.default_id); + try testing.expect(t.screens.active.cursor.style_id != style.default_id); // Erasing the display may detect that our style is no longer in use // and prune our style, which we don't want because its still our // active cursor. t.eraseDisplay(.complete, false); - try testing.expect(t.screen.cursor.style_id != style.default_id); + try testing.expect(t.screens.active.cursor.style_id != style.default_id); } test "Terminal: cursorIsAtPrompt" { @@ -10635,7 +11045,7 @@ test "Terminal: cursorIsAtPrompt alternate screen" { try testing.expect(t.cursorIsAtPrompt()); // Secondary screen is never a prompt - t.switchScreenMode(.@"1049", true); + try t.switchScreenMode(.@"1049", true); try testing.expect(!t.cursorIsAtPrompt()); t.markSemanticPrompt(.prompt); try testing.expect(!t.cursorIsAtPrompt()); @@ -10650,24 +11060,24 @@ test "Terminal: fullReset with a non-empty pen" { t.fullReset(); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } }).?; const cell = list_cell.cell; try testing.expect(cell.style_id == 0); } - try testing.expectEqual(@as(style.Id, 0), t.screen.cursor.style_id); + try testing.expectEqual(@as(style.Id, 0), t.screens.active.cursor.style_id); } test "Terminal: fullReset hyperlink" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); t.fullReset(); - try testing.expectEqual(0, t.screen.cursor.hyperlink_id); + try testing.expectEqual(0, t.screens.active.cursor.hyperlink_id); } test "Terminal: fullReset with a non-empty saved cursor" { @@ -10680,15 +11090,15 @@ test "Terminal: fullReset with a non-empty saved cursor" { t.fullReset(); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } }).?; const cell = list_cell.cell; try testing.expect(cell.style_id == 0); } - try testing.expectEqual(@as(style.Id, 0), t.screen.cursor.style_id); + try testing.expectEqual(@as(style.Id, 0), t.screens.active.cursor.style_id); } test "Terminal: fullReset origin mode" { @@ -10700,8 +11110,8 @@ test "Terminal: fullReset origin mode" { t.fullReset(); // Origin mode should be reset and the cursor should be moved - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); try testing.expect(!t.modes.get(.origin)); } @@ -10719,18 +11129,18 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" { var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - t.switchScreenMode(.@"1049", true); - t.screen.kitty_keyboard.push(.{ + try t.switchScreenMode(.@"1049", true); + t.screens.active.kitty_keyboard.push(.{ .disambiguate = true, .report_events = false, .report_alternates = true, .report_all = true, .report_associated = true, }); - t.switchScreenMode(.@"1049", false); + try t.switchScreenMode(.@"1049", false); t.fullReset(); - try testing.expectEqual(0, t.secondary_screen.kitty_keyboard.current().int()); + try testing.expect(t.screens.get(.alternate) == null); } test "Terminal: fullReset default modes" { @@ -10750,9 +11160,9 @@ test "Terminal: fullReset tracked pins" { defer t.deinit(testing.allocator); // Create a tracked pin - const p = try t.screen.pages.trackPin(t.screen.cursor.page_pin.*); + const p = try t.screens.active.pages.trackPin(t.screens.active.cursor.page_pin.*); t.fullReset(); - try testing.expect(t.screen.pages.pinIsValid(p.*)); + try testing.expect(t.screens.active.pages.pinIsValid(p.*)); } // https://github.com/mitchellh/ghostty/issues/272 @@ -10878,9 +11288,9 @@ test "Terminal: resize with reflow and saved cursor" { try t.printString("1A2B"); t.setCursorPos(2, 2); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint); @@ -10904,9 +11314,9 @@ test "Terminal: resize with reflow and saved cursor" { // Verify our cursor is still in the same place { - const list_cell = t.screen.pages.getCell(.{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint); @@ -10919,9 +11329,9 @@ test "Terminal: resize with reflow and saved cursor pending wrap" { defer t.deinit(alloc); try t.printString("1A2B"); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint); @@ -10981,13 +11391,13 @@ test "Terminal: DECCOLM resets pending wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.modes.set(.enable_mode_3, true); try t.deccolm(alloc, .@"80_cols"); try testing.expectEqual(@as(usize, 80), t.cols); try testing.expectEqual(@as(usize, 5), t.rows); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); } test "Terminal: DECCOLM preserves SGR bg" { @@ -11004,7 +11414,7 @@ test "Terminal: DECCOLM preserves SGR bg" { try t.deccolm(alloc, .@"80_cols"); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 0xFF, @@ -11042,8 +11452,8 @@ test "Terminal: mode 47 alt screen plain" { try t.printString("1A"); // Go to alt screen with mode 47 - t.switchScreenMode(.@"47", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"47", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Screen should be empty { @@ -11062,8 +11472,8 @@ test "Terminal: mode 47 alt screen plain" { } // Go back to primary - t.switchScreenMode(.@"47", false); - try testing.expectEqual(ScreenType.primary, t.active_screen); + try t.switchScreenMode(.@"47", false); + try testing.expectEqual(.primary, t.screens.active_key); // Primary screen should still have the original content { @@ -11073,8 +11483,8 @@ test "Terminal: mode 47 alt screen plain" { } // Go back to alt screen with mode 47 - t.switchScreenMode(.@"47", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"47", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Screen should retain content { @@ -11093,30 +11503,30 @@ test "Terminal: mode 47 copies cursor both directions" { try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); // Go to alt screen with mode 47 - t.switchScreenMode(.@"47", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"47", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Verify that our style is set { - try testing.expect(t.screen.cursor.style_id != style.default_id); - const page = &t.screen.cursor.page_pin.node.data; + try testing.expect(t.screens.active.cursor.style_id != style.default_id); + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); - try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } // Set a new style try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } }); // Go back to primary - t.switchScreenMode(.@"47", false); - try testing.expectEqual(ScreenType.primary, t.active_screen); + try t.switchScreenMode(.@"47", false); + try testing.expectEqual(.primary, t.screens.active_key); // Verify that our style is still set { - try testing.expect(t.screen.cursor.style_id != style.default_id); - const page = &t.screen.cursor.page_pin.node.data; + try testing.expect(t.screens.active.cursor.style_id != style.default_id); + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); - try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } } @@ -11129,8 +11539,8 @@ test "Terminal: mode 1047 alt screen plain" { try t.printString("1A"); // Go to alt screen with mode 47 - t.switchScreenMode(.@"1047", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"1047", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Screen should be empty { @@ -11149,8 +11559,8 @@ test "Terminal: mode 1047 alt screen plain" { } // Go back to primary - t.switchScreenMode(.@"1047", false); - try testing.expectEqual(ScreenType.primary, t.active_screen); + try t.switchScreenMode(.@"1047", false); + try testing.expectEqual(.primary, t.screens.active_key); // Primary screen should still have the original content { @@ -11160,8 +11570,8 @@ test "Terminal: mode 1047 alt screen plain" { } // Go back to alt screen with mode 1047 - t.switchScreenMode(.@"1047", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"1047", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Screen should be empty { @@ -11180,30 +11590,30 @@ test "Terminal: mode 1047 copies cursor both directions" { try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); // Go to alt screen with mode 47 - t.switchScreenMode(.@"1047", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"1047", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Verify that our style is set { - try testing.expect(t.screen.cursor.style_id != style.default_id); - const page = &t.screen.cursor.page_pin.node.data; + try testing.expect(t.screens.active.cursor.style_id != style.default_id); + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); - try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } // Set a new style try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } }); // Go back to primary - t.switchScreenMode(.@"1047", false); - try testing.expectEqual(ScreenType.primary, t.active_screen); + try t.switchScreenMode(.@"1047", false); + try testing.expectEqual(.primary, t.screens.active_key); // Verify that our style is still set { - try testing.expect(t.screen.cursor.style_id != style.default_id); - const page = &t.screen.cursor.page_pin.node.data; + try testing.expect(t.screens.active.cursor.style_id != style.default_id); + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); - try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } } @@ -11216,8 +11626,8 @@ test "Terminal: mode 1049 alt screen plain" { try t.printString("1A"); // Go to alt screen with mode 47 - t.switchScreenMode(.@"1049", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"1049", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Screen should be empty { @@ -11236,8 +11646,8 @@ test "Terminal: mode 1049 alt screen plain" { } // Go back to primary - t.switchScreenMode(.@"1049", false); - try testing.expectEqual(ScreenType.primary, t.active_screen); + try t.switchScreenMode(.@"1049", false); + try testing.expectEqual(.primary, t.screens.active_key); // Primary screen should still have the original content { @@ -11255,8 +11665,8 @@ test "Terminal: mode 1049 alt screen plain" { } // Go back to alt screen with mode 1049 - t.switchScreenMode(.@"1049", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"1049", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Screen should be empty { diff --git a/src/terminal/ansi.zig b/src/terminal/ansi.zig index 590e9885a..c9cd53666 100644 --- a/src/terminal/ansi.zig +++ b/src/terminal/ansi.zig @@ -1,3 +1,7 @@ +const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; + /// C0 (7-bit) control characters from ANSI. /// /// This is not complete, control characters are only added to this @@ -49,33 +53,28 @@ pub const RenditionAspect = enum(u16) { }; /// The device attribute request type (ESC [ c). -pub const DeviceAttributeReq = enum { - primary, // Blank - secondary, // > - tertiary, // = -}; +pub const DeviceAttributeReq = lib.Enum( + lib_target, + &.{ + "primary", // Blank + "secondary", // > + "tertiary", // = + }, +); /// Possible cursor styles (ESC [ q) -pub const CursorStyle = enum(u16) { - default = 0, - blinking_block = 1, - steady_block = 2, - blinking_underline = 3, - steady_underline = 4, - blinking_bar = 5, - steady_bar = 6, - - // Non-exhaustive so that @intToEnum never fails for unsupported modes. - _, - - /// True if the cursor should blink. - pub fn blinking(self: CursorStyle) bool { - return switch (self) { - .blinking_block, .blinking_underline, .blinking_bar => true, - else => false, - }; - } -}; +pub const CursorStyle = lib.Enum( + lib_target, + &.{ + "default", + "blinking_block", + "steady_block", + "blinking_underline", + "steady_underline", + "blinking_bar", + "steady_bar", + }, +); /// The status line type for DECSSDT. pub const StatusLineType = enum(u16) { @@ -88,19 +87,27 @@ pub const StatusLineType = enum(u16) { }; /// The display to target for status updates (DECSASD). -pub const StatusDisplay = enum(u16) { - main = 0, - status_line = 1, -}; +pub const StatusDisplay = lib.Enum( + lib_target, + &.{ + "main", + "status_line", + }, +); /// The possible modify key formats to ESC[>{a};{b}m /// Note: this is not complete, we should add more as we support more -pub const ModifyKeyFormat = union(enum) { - legacy: void, - cursor_keys: void, - function_keys: void, - other_keys: enum { none, numeric_except, numeric }, -}; +pub const ModifyKeyFormat = lib.Enum( + lib_target, + &.{ + "legacy", + "cursor_keys", + "function_keys", + "other_keys_none", + "other_keys_numeric_except", + "other_keys_numeric", + }, +); /// The protection modes that can be set for the terminal. See DECSCA and /// ESC V, W. 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/color.zig b/src/terminal/c/color.zig new file mode 100644 index 000000000..199339706 --- /dev/null +++ b/src/terminal/c/color.zig @@ -0,0 +1,12 @@ +const color = @import("../color.zig"); + +pub fn rgb_get( + c: color.RGB.C, + r: *u8, + g: *u8, + b: *u8, +) callconv(.c) void { + r.* = c.r; + g.* = c.g; + b.* = c.b; +} diff --git a/src/terminal/c/key_encode.zig b/src/terminal/c/key_encode.zig new file mode 100644 index 000000000..063cd8df7 --- /dev/null +++ b/src/terminal/c/key_encode.zig @@ -0,0 +1,287 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const lib_alloc = @import("../../lib/allocator.zig"); +const CAllocator = lib_alloc.Allocator; +const key_encode = @import("../../input/key_encode.zig"); +const key_event = @import("key_event.zig"); +const KittyFlags = @import("../../terminal/kitty/key.zig").Flags; +const OptionAsAlt = @import("../../input/config.zig").OptionAsAlt; +const Result = @import("result.zig").Result; +const KeyEvent = @import("key_event.zig").Event; + +const log = std.log.scoped(.key_encode); + +/// Wrapper around key encoding options that tracks the allocator for C API usage. +const KeyEncoderWrapper = struct { + opts: key_encode.Options, + alloc: Allocator, +}; + +/// C: GhosttyKeyEncoder +pub const Encoder = ?*KeyEncoderWrapper; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Encoder, +) callconv(.c) Result { + const alloc = lib_alloc.default(alloc_); + const ptr = alloc.create(KeyEncoderWrapper) catch + return .out_of_memory; + ptr.* = .{ + .opts = .{}, + .alloc = alloc, + }; + result.* = ptr; + return .success; +} + +pub fn free(encoder_: Encoder) callconv(.c) void { + const wrapper = encoder_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +/// C: GhosttyKeyEncoderOption +pub const Option = enum(c_int) { + cursor_key_application = 0, + keypad_key_application = 1, + ignore_keypad_with_numlock = 2, + alt_esc_prefix = 3, + modify_other_keys_state_2 = 4, + kitty_flags = 5, + macos_option_as_alt = 6, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { + return switch (self) { + .cursor_key_application, + .keypad_key_application, + .ignore_keypad_with_numlock, + .alt_esc_prefix, + .modify_other_keys_state_2, + => bool, + .kitty_flags => u8, + .macos_option_as_alt => OptionAsAlt, + }; + } +}; + +pub fn setopt( + encoder_: Encoder, + option: Option, + value: ?*const anyopaque, +) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + log.warn("setopt invalid option value={d}", .{@intFromEnum(option)}); + return; + }; + } + + return switch (option) { + inline else => |comptime_option| setoptTyped( + encoder_, + comptime_option, + @ptrCast(@alignCast(value orelse return)), + ), + }; +} + +fn setoptTyped( + encoder_: Encoder, + comptime option: Option, + value: *const option.InType(), +) void { + const opts = &encoder_.?.opts; + switch (option) { + .cursor_key_application => opts.cursor_key_application = value.*, + .keypad_key_application => opts.keypad_key_application = value.*, + .ignore_keypad_with_numlock => opts.ignore_keypad_with_numlock = value.*, + .alt_esc_prefix => opts.alt_esc_prefix = value.*, + .modify_other_keys_state_2 => opts.modify_other_keys_state_2 = value.*, + .kitty_flags => opts.kitty_flags = flags: { + const bits: u5 = @truncate(value.*); + break :flags @bitCast(bits); + }, + .macos_option_as_alt => { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(OptionAsAlt, @intFromEnum(value.*)) catch { + log.warn("setopt invalid OptionAsAlt value={d}", .{@intFromEnum(value.*)}); + return; + }; + } + opts.macos_option_as_alt = value.*; + }, + } +} + +pub fn encode( + encoder_: Encoder, + event_: KeyEvent, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(.c) Result { + // Attempt to write to this buffer + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + key_encode.encode( + &writer, + event_.?.event, + encoder_.?.opts, + ) catch |err| switch (err) { + error.WriteFailed => { + // If we don't have space, use a discarding writer to count + // how much space we would have needed. + var discarding: std.Io.Writer.Discarding = .init(&.{}); + key_encode.encode( + &discarding.writer, + event_.?.event, + encoder_.?.opts, + ) catch unreachable; + + // Discarding always uses a u64. If we're on 32-bit systems + // we cast down. We should make this safer in the future. + out_written.* = @intCast(discarding.count); + return .out_of_memory; + }, + }; + + out_written.* = writer.end; + return .success; +} + +test "alloc" { + const testing = std.testing; + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + free(e); +} + +test "setopt bool" { + const testing = std.testing; + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + // Test setting bool options + const val_true: bool = true; + setopt(e, .cursor_key_application, &val_true); + try testing.expect(e.?.opts.cursor_key_application); + + const val_false: bool = false; + setopt(e, .cursor_key_application, &val_false); + try testing.expect(!e.?.opts.cursor_key_application); + + setopt(e, .keypad_key_application, &val_true); + try testing.expect(e.?.opts.keypad_key_application); +} + +test "setopt kitty flags" { + const testing = std.testing; + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + // Test setting kitty flags + const flags: KittyFlags = .{ + .disambiguate = true, + .report_events = true, + }; + const flags_int: u8 = @intCast(flags.int()); + setopt(e, .kitty_flags, &flags_int); + try testing.expect(e.?.opts.kitty_flags.disambiguate); + try testing.expect(e.?.opts.kitty_flags.report_events); + try testing.expect(!e.?.opts.kitty_flags.report_alternates); +} + +test "setopt macos option as alt" { + const testing = std.testing; + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + // Test setting option as alt + const opt_left: OptionAsAlt = .left; + setopt(e, .macos_option_as_alt, &opt_left); + try testing.expectEqual(OptionAsAlt.left, e.?.opts.macos_option_as_alt); + + const opt_true: OptionAsAlt = .true; + setopt(e, .macos_option_as_alt, &opt_true); + try testing.expectEqual(OptionAsAlt.true, e.?.opts.macos_option_as_alt); +} + +test "encode: kitty ctrl release with ctrl mod set" { + const testing = std.testing; + + // Create encoder + var encoder: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &encoder, + )); + defer free(encoder); + + // Set kitty flags with all features enabled + { + const flags: KittyFlags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }; + const flags_int: u8 = @intCast(flags.int()); + setopt(encoder, .kitty_flags, &flags_int); + } + + // Create key event + var event: key_event.Event = undefined; + try testing.expectEqual(Result.success, key_event.new( + &lib_alloc.test_allocator, + &event, + )); + defer key_event.free(event); + + // Set event properties: release action, ctrl key, ctrl modifier + key_event.set_action(event, .release); + key_event.set_key(event, .control_left); + key_event.set_mods(event, .{ .ctrl = true }); + + // Encode null should give us the length required + var required: usize = 0; + try testing.expectEqual(Result.out_of_memory, encode( + encoder, + event, + null, + 0, + &required, + )); + + // Encode the key event + var buf: [128]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expectEqual(required, written); + + // Expected: ESC[57442;5:3u (ctrl key code with mods and release event) + const actual = buf[0..written]; + try testing.expectEqualStrings("\x1b[57442;5:3u", actual); +} diff --git a/src/terminal/c/key_event.zig b/src/terminal/c/key_event.zig new file mode 100644 index 000000000..748b8799c --- /dev/null +++ b/src/terminal/c/key_event.zig @@ -0,0 +1,268 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const lib_alloc = @import("../../lib/allocator.zig"); +const CAllocator = lib_alloc.Allocator; +const key = @import("../../input/key.zig"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.key_event); + +/// Wrapper around KeyEvent that tracks the allocator for C API usage. +/// The UTF-8 text is not owned by this wrapper - the caller is responsible +/// for ensuring the lifetime of any UTF-8 text set via set_utf8. +const KeyEventWrapper = struct { + event: key.KeyEvent = .{}, + alloc: Allocator, +}; + +/// C: GhosttyKeyEvent +pub const Event = ?*KeyEventWrapper; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Event, +) callconv(.c) Result { + const alloc = lib_alloc.default(alloc_); + const ptr = alloc.create(KeyEventWrapper) catch + return .out_of_memory; + ptr.* = .{ .alloc = alloc }; + result.* = ptr; + return .success; +} + +pub fn free(event_: Event) callconv(.c) void { + const wrapper = event_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +pub fn set_action(event_: Event, action: key.Action) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(key.Action, @intFromEnum(action)) catch { + log.warn("set_action invalid action value={d}", .{@intFromEnum(action)}); + return; + }; + } + + const event: *key.KeyEvent = &event_.?.event; + event.action = action; +} + +pub fn get_action(event_: Event) callconv(.c) key.Action { + const event: *key.KeyEvent = &event_.?.event; + return event.action; +} + +pub fn set_key(event_: Event, k: key.Key) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(key.Key, @intFromEnum(k)) catch { + log.warn("set_key invalid key value={d}", .{@intFromEnum(k)}); + return; + }; + } + + const event: *key.KeyEvent = &event_.?.event; + event.key = k; +} + +pub fn get_key(event_: Event) callconv(.c) key.Key { + const event: *key.KeyEvent = &event_.?.event; + return event.key; +} + +pub fn set_mods(event_: Event, mods: key.Mods) callconv(.c) void { + const event: *key.KeyEvent = &event_.?.event; + event.mods = mods; +} + +pub fn get_mods(event_: Event) callconv(.c) key.Mods { + const event: *key.KeyEvent = &event_.?.event; + return event.mods; +} + +pub fn set_consumed_mods(event_: Event, consumed_mods: key.Mods) callconv(.c) void { + const event: *key.KeyEvent = &event_.?.event; + event.consumed_mods = consumed_mods; +} + +pub fn get_consumed_mods(event_: Event) callconv(.c) key.Mods { + const event: *key.KeyEvent = &event_.?.event; + return event.consumed_mods; +} + +pub fn set_composing(event_: Event, composing: bool) callconv(.c) void { + const event: *key.KeyEvent = &event_.?.event; + event.composing = composing; +} + +pub fn get_composing(event_: Event) callconv(.c) bool { + const event: *key.KeyEvent = &event_.?.event; + return event.composing; +} + +pub fn set_utf8(event_: Event, utf8: ?[*]const u8, len: usize) callconv(.c) void { + const event: *key.KeyEvent = &event_.?.event; + event.utf8 = if (utf8) |ptr| ptr[0..len] else ""; +} + +pub fn get_utf8(event_: Event, len: ?*usize) callconv(.c) ?[*]const u8 { + const event: *key.KeyEvent = &event_.?.event; + if (len) |l| l.* = event.utf8.len; + return if (event.utf8.len == 0) null else event.utf8.ptr; +} + +pub fn set_unshifted_codepoint(event_: Event, codepoint: u32) callconv(.c) void { + const event: *key.KeyEvent = &event_.?.event; + event.unshifted_codepoint = @truncate(codepoint); +} + +pub fn get_unshifted_codepoint(event_: Event) callconv(.c) u32 { + const event: *key.KeyEvent = &event_.?.event; + return event.unshifted_codepoint; +} + +test "alloc" { + const testing = std.testing; + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + free(e); +} + +test "set" { + const testing = std.testing; + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + // Test action + set_action(e, .press); + try testing.expectEqual(key.Action.press, e.?.event.action); + + // Test key + set_key(e, .key_a); + try testing.expectEqual(key.Key.key_a, e.?.event.key); + + // Test mods + const mods: key.Mods = .{ .shift = true, .ctrl = true }; + set_mods(e, mods); + try testing.expect(e.?.event.mods.shift); + try testing.expect(e.?.event.mods.ctrl); + + // Test consumed mods + const consumed: key.Mods = .{ .shift = true }; + set_consumed_mods(e, consumed); + try testing.expect(e.?.event.consumed_mods.shift); + try testing.expect(!e.?.event.consumed_mods.ctrl); + + // Test composing + set_composing(e, true); + try testing.expect(e.?.event.composing); + + // Test UTF-8 + const text = "hello"; + set_utf8(e, text.ptr, text.len); + try testing.expectEqualStrings(text, e.?.event.utf8); + + // Test UTF-8 null + set_utf8(e, null, 0); + try testing.expectEqualStrings("", e.?.event.utf8); + + // Test unshifted codepoint + set_unshifted_codepoint(e, 'a'); + try testing.expectEqual(@as(u21, 'a'), e.?.event.unshifted_codepoint); +} + +test "get" { + const testing = std.testing; + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + // Set some values + set_action(e, .repeat); + set_key(e, .key_z); + + const mods: key.Mods = .{ .alt = true, .super = true }; + set_mods(e, mods); + + const consumed: key.Mods = .{ .alt = true }; + set_consumed_mods(e, consumed); + + set_composing(e, true); + + const text = "test"; + set_utf8(e, text.ptr, text.len); + + set_unshifted_codepoint(e, 'z'); + + // Get them back + try testing.expectEqual(key.Action.repeat, get_action(e)); + try testing.expectEqual(key.Key.key_z, get_key(e)); + + const got_mods = get_mods(e); + try testing.expect(got_mods.alt); + try testing.expect(got_mods.super); + + const got_consumed = get_consumed_mods(e); + try testing.expect(got_consumed.alt); + try testing.expect(!got_consumed.super); + + try testing.expect(get_composing(e)); + + var utf8_len: usize = undefined; + const got_utf8 = get_utf8(e, &utf8_len); + try testing.expect(got_utf8 != null); + try testing.expectEqual(@as(usize, 4), utf8_len); + try testing.expectEqualStrings("test", got_utf8.?[0..utf8_len]); + + try testing.expectEqual(@as(u32, 'z'), get_unshifted_codepoint(e)); +} + +test "complete key event" { + const testing = std.testing; + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + // Build a complete key event for shift+a + set_action(e, .press); + set_key(e, .key_a); + + const mods: key.Mods = .{ .shift = true }; + set_mods(e, mods); + + const consumed: key.Mods = .{ .shift = true }; + set_consumed_mods(e, consumed); + + const text = "A"; + set_utf8(e, text.ptr, text.len); + + set_unshifted_codepoint(e, 'a'); + + // Verify all fields + try testing.expectEqual(key.Action.press, e.?.event.action); + try testing.expectEqual(key.Key.key_a, e.?.event.key); + try testing.expect(e.?.event.mods.shift); + try testing.expect(e.?.event.consumed_mods.shift); + try testing.expectEqualStrings("A", e.?.event.utf8); + try testing.expectEqual(@as(u21, 'a'), e.?.event.unshifted_codepoint); + + // Also test the getter + var utf8_len: usize = undefined; + const got_utf8 = get_utf8(e, &utf8_len); + try testing.expect(got_utf8 != null); + try testing.expectEqual(@as(usize, 1), utf8_len); + try testing.expectEqualStrings("A", got_utf8.?[0..utf8_len]); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 68fd77edd..bc92597f5 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -1,4 +1,9 @@ +pub const color = @import("color.zig"); pub const osc = @import("osc.zig"); +pub const key_event = @import("key_event.zig"); +pub const key_encode = @import("key_encode.zig"); +pub const paste = @import("paste.zig"); +pub const sgr = @import("sgr.zig"); // The full C API, unexported. pub const osc_new = osc.new; @@ -9,8 +14,51 @@ pub const osc_end = osc.end; pub const osc_command_type = osc.commandType; pub const osc_command_data = osc.commandData; +pub const color_rgb_get = color.rgb_get; + +pub const sgr_new = sgr.new; +pub const sgr_free = sgr.free; +pub const sgr_reset = sgr.reset; +pub const sgr_set_params = sgr.setParams; +pub const sgr_next = sgr.next; +pub const sgr_unknown_full = sgr.unknown_full; +pub const sgr_unknown_partial = sgr.unknown_partial; +pub const sgr_attribute_tag = sgr.attribute_tag; +pub const sgr_attribute_value = sgr.attribute_value; +pub const wasm_alloc_sgr_attribute = sgr.wasm_alloc_attribute; +pub const wasm_free_sgr_attribute = sgr.wasm_free_attribute; + +pub const key_event_new = key_event.new; +pub const key_event_free = key_event.free; +pub const key_event_set_action = key_event.set_action; +pub const key_event_get_action = key_event.get_action; +pub const key_event_set_key = key_event.set_key; +pub const key_event_get_key = key_event.get_key; +pub const key_event_set_mods = key_event.set_mods; +pub const key_event_get_mods = key_event.get_mods; +pub const key_event_set_consumed_mods = key_event.set_consumed_mods; +pub const key_event_get_consumed_mods = key_event.get_consumed_mods; +pub const key_event_set_composing = key_event.set_composing; +pub const key_event_get_composing = key_event.get_composing; +pub const key_event_set_utf8 = key_event.set_utf8; +pub const key_event_get_utf8 = key_event.get_utf8; +pub const key_event_set_unshifted_codepoint = key_event.set_unshifted_codepoint; +pub const key_event_get_unshifted_codepoint = key_event.get_unshifted_codepoint; + +pub const key_encoder_new = key_encode.new; +pub const key_encoder_free = key_encode.free; +pub const key_encoder_setopt = key_encode.setopt; +pub const key_encoder_encode = key_encode.encode; + +pub const paste_is_safe = paste.is_safe; + test { + _ = color; _ = osc; + _ = key_event; + _ = key_encode; + _ = paste; + _ = sgr; // We want to make sure we run the tests for the C allocator interface. _ = @import("../../lib/allocator.zig"); diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index 8b6a8409c..c4cdaad3b 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -1,11 +1,11 @@ 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"); const Result = @import("result.zig").Result; +const log = std.log.scoped(.osc); + /// C: GhosttyOscParser pub const Parser = ?*osc.Parser; @@ -19,7 +19,7 @@ pub fn new( const alloc = lib_alloc.default(alloc_); const ptr = alloc.create(osc.Parser) catch return .out_of_memory; - ptr.* = .initAlloc(alloc); + ptr.* = .init(alloc); result.* = ptr; return .success; } @@ -68,6 +68,13 @@ pub fn commandData( data: CommandData, out: ?*anyopaque, ) callconv(.c) bool { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(CommandData, @intFromEnum(data)) catch { + log.warn("commandData invalid data value={d}", .{@intFromEnum(data)}); + return false; + }; + } + return switch (data) { inline else => |comptime_data| commandDataTyped( command_, diff --git a/src/terminal/c/paste.zig b/src/terminal/c/paste.zig new file mode 100644 index 000000000..eb4117a70 --- /dev/null +++ b/src/terminal/c/paste.zig @@ -0,0 +1,36 @@ +const std = @import("std"); +const paste = @import("../../input/paste.zig"); + +pub fn is_safe(data: ?[*]const u8, len: usize) callconv(.c) bool { + const slice: []const u8 = if (data) |v| v[0..len] else &.{}; + return paste.isSafe(slice); +} + +test "is_safe with safe data" { + const testing = std.testing; + const safe = "hello world"; + try testing.expect(is_safe(safe.ptr, safe.len)); +} + +test "is_safe with newline" { + const testing = std.testing; + const unsafe = "hello\nworld"; + try testing.expect(!is_safe(unsafe.ptr, unsafe.len)); +} + +test "is_safe with bracketed paste end" { + const testing = std.testing; + const unsafe = "hello\x1b[201~world"; + try testing.expect(!is_safe(unsafe.ptr, unsafe.len)); +} + +test "is_safe with empty data" { + const testing = std.testing; + const empty = ""; + try testing.expect(is_safe(empty.ptr, 0)); +} + +test "is_safe with null empty data" { + const testing = std.testing; + try testing.expect(is_safe(null, 0)); +} diff --git a/src/terminal/c/result.zig b/src/terminal/c/result.zig index a2ebc9b69..e9b5fc5e6 100644 --- a/src/terminal/c/result.zig +++ b/src/terminal/c/result.zig @@ -2,4 +2,5 @@ pub const Result = enum(c_int) { success = 0, out_of_memory = -1, + invalid_value = -2, }; diff --git a/src/terminal/c/sgr.zig b/src/terminal/c/sgr.zig new file mode 100644 index 000000000..53536417f --- /dev/null +++ b/src/terminal/c/sgr.zig @@ -0,0 +1,179 @@ +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const lib_alloc = @import("../../lib/allocator.zig"); +const CAllocator = lib_alloc.Allocator; +const sgr = @import("../sgr.zig"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.sgr); + +/// Wrapper around parser that tracks the allocator for C API usage. +const ParserWrapper = struct { + parser: sgr.Parser, + alloc: Allocator, +}; + +/// C: GhosttySgrParser +pub const Parser = ?*ParserWrapper; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Parser, +) callconv(.c) Result { + const alloc = lib_alloc.default(alloc_); + const ptr = alloc.create(ParserWrapper) catch + return .out_of_memory; + ptr.* = .{ + .parser = .empty, + .alloc = alloc, + }; + result.* = ptr; + return .success; +} + +pub fn free(parser_: Parser) callconv(.c) void { + const wrapper = parser_ orelse return; + const alloc = wrapper.alloc; + const parser: *sgr.Parser = &wrapper.parser; + if (parser.params.len > 0) alloc.free(parser.params); + alloc.destroy(wrapper); +} + +pub fn reset(parser_: Parser) callconv(.c) void { + const wrapper = parser_ orelse return; + const parser: *sgr.Parser = &wrapper.parser; + parser.idx = 0; +} + +pub fn setParams( + parser_: Parser, + params: [*]const u16, + seps_: ?[*]const u8, + len: usize, +) callconv(.c) Result { + const wrapper = parser_ orelse return .invalid_value; + const alloc = wrapper.alloc; + const parser: *sgr.Parser = &wrapper.parser; + + // Copy our new parameters + const params_slice = alloc.dupe(u16, params[0..len]) catch + return .out_of_memory; + if (parser.params.len > 0) alloc.free(parser.params); + parser.params = params_slice; + + // If we have separators, set that state too. + parser.params_sep = .initEmpty(); + if (seps_) |seps| { + if (len > @TypeOf(parser.params_sep).bit_length) { + log.warn("ghostty_sgr_set_params: separators length {} exceeds max supported length {}", .{ + len, + @TypeOf(parser.params_sep).bit_length, + }); + return .invalid_value; + } + + for (seps[0..len], 0..) |sep, i| { + if (sep == ':') parser.params_sep.set(i); + } + } + + // Reset our parsing state + parser.idx = 0; + + return .success; +} + +pub fn next( + parser_: Parser, + result: *sgr.Attribute.C, +) callconv(.c) bool { + const wrapper = parser_ orelse return false; + const parser: *sgr.Parser = &wrapper.parser; + if (parser.next()) |attr| { + result.* = attr.cval(); + return true; + } + + return false; +} + +pub fn unknown_full( + unknown: sgr.Attribute.Unknown.C, + ptr: ?*[*]const u16, +) callconv(.c) usize { + if (ptr) |p| p.* = unknown.full_ptr; + return unknown.full_len; +} + +pub fn unknown_partial( + unknown: sgr.Attribute.Unknown.C, + ptr: ?*[*]const u16, +) callconv(.c) usize { + if (ptr) |p| p.* = unknown.partial_ptr; + return unknown.partial_len; +} + +pub fn attribute_tag( + attr: sgr.Attribute.C, +) callconv(.c) sgr.Attribute.Tag { + return attr.tag; +} + +pub fn attribute_value( + attr: *sgr.Attribute.C, +) callconv(.c) *sgr.Attribute.CValue { + return &attr.value; +} + +pub fn wasm_alloc_attribute() callconv(.c) *sgr.Attribute.C { + const alloc = std.heap.wasm_allocator; + const ptr = alloc.create(sgr.Attribute.C) catch @panic("out of memory"); + return ptr; +} + +pub fn wasm_free_attribute(attr: *sgr.Attribute.C) callconv(.c) void { + const alloc = std.heap.wasm_allocator; + alloc.destroy(attr); +} + +test "alloc" { + var p: Parser = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &p, + )); + free(p); +} + +test "simple params, no seps" { + var p: Parser = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &p, + )); + defer free(p); + + try testing.expectEqual(Result.success, setParams( + p, + &.{1}, + null, + 1, + )); + + // Set it twice on purpose to make sure we don't leak. + try testing.expectEqual(Result.success, setParams( + p, + &.{1}, + null, + 1, + )); + + // Verify we get bold + var attr: sgr.Attribute.C = undefined; + try testing.expect(next(p, &attr)); + try testing.expectEqual(.bold, attr.tag); + + // Nothing else + try testing.expect(!next(p, &attr)); +} diff --git a/src/terminal/charsets.zig b/src/terminal/charsets.zig index 66d6566e3..00a2d8d1f 100644 --- a/src/terminal/charsets.zig +++ b/src/terminal/charsets.zig @@ -1,88 +1,89 @@ const std = @import("std"); -const assert = std.debug.assert; +const build_options = @import("terminal_options"); +const assert = @import("../quirks.zig").inlineAssert; +const LibEnum = @import("../lib/enum.zig").Enum; /// The available charset slots for a terminal. -pub const Slots = enum(u3) { - G0 = 0, - G1 = 1, - G2 = 2, - G3 = 3, -}; +pub const Slots = LibEnum( + if (build_options.c_abi) .c else .zig, + &.{ "G0", "G1", "G2", "G3" }, +); /// The name of the active slots. -pub const ActiveSlot = enum { GL, GR }; +pub const ActiveSlot = LibEnum( + if (build_options.c_abi) .c else .zig, + &.{ "GL", "GR" }, +); /// The list of supported character sets and their associated tables. -pub const Charset = enum { - utf8, - ascii, - british, - dec_special, +pub const Charset = LibEnum( + if (build_options.c_abi) .c else .zig, + &.{ "utf8", "ascii", "british", "dec_special" }, +); - /// 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 { - return switch (set) { - .british => &british, - .dec_special => &dec_special, +/// 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 inline fn table(set: Charset) []const u16 { + return switch (set) { + .british => &british, + .dec_special => &dec_special, - // utf8 is not a table, callers should double-check if the - // charset is utf8 and NOT use tables. - .utf8 => unreachable, + // utf8 is not a table, callers should double-check if the + // charset is utf8 and NOT use tables. + .utf8 => unreachable, - // recommended that callers just map ascii directly but we can - // support a table - .ascii => &ascii, - }; - } -}; + // recommended that callers just map ascii directly but we can + // support a table + .ascii => &ascii, + }; +} /// Just a basic c => c ascii table const ascii = initTable(); /// https://vt100.net/docs/vt220-rm/chapter2.html const british = british: { - var table = initTable(); - table[0x23] = 0x00a3; - break :british table; + var tbl = initTable(); + tbl[0x23] = 0x00a3; + break :british tbl; }; /// https://en.wikipedia.org/wiki/DEC_Special_Graphics const dec_special = tech: { - var table = initTable(); - table[0x60] = 0x25C6; - table[0x61] = 0x2592; - table[0x62] = 0x2409; - table[0x63] = 0x240C; - table[0x64] = 0x240D; - table[0x65] = 0x240A; - table[0x66] = 0x00B0; - table[0x67] = 0x00B1; - table[0x68] = 0x2424; - table[0x69] = 0x240B; - table[0x6a] = 0x2518; - table[0x6b] = 0x2510; - table[0x6c] = 0x250C; - table[0x6d] = 0x2514; - table[0x6e] = 0x253C; - table[0x6f] = 0x23BA; - table[0x70] = 0x23BB; - table[0x71] = 0x2500; - table[0x72] = 0x23BC; - table[0x73] = 0x23BD; - table[0x74] = 0x251C; - table[0x75] = 0x2524; - table[0x76] = 0x2534; - table[0x77] = 0x252C; - table[0x78] = 0x2502; - table[0x79] = 0x2264; - table[0x7a] = 0x2265; - table[0x7b] = 0x03C0; - table[0x7c] = 0x2260; - table[0x7d] = 0x00A3; - table[0x7e] = 0x00B7; - break :tech table; + var tbl = initTable(); + tbl[0x60] = 0x25C6; + tbl[0x61] = 0x2592; + tbl[0x62] = 0x2409; + tbl[0x63] = 0x240C; + tbl[0x64] = 0x240D; + tbl[0x65] = 0x240A; + tbl[0x66] = 0x00B0; + tbl[0x67] = 0x00B1; + tbl[0x68] = 0x2424; + tbl[0x69] = 0x240B; + tbl[0x6a] = 0x2518; + tbl[0x6b] = 0x2510; + tbl[0x6c] = 0x250C; + tbl[0x6d] = 0x2514; + tbl[0x6e] = 0x253C; + tbl[0x6f] = 0x23BA; + tbl[0x70] = 0x23BB; + tbl[0x71] = 0x2500; + tbl[0x72] = 0x23BC; + tbl[0x73] = 0x23BD; + tbl[0x74] = 0x251C; + tbl[0x75] = 0x2524; + tbl[0x76] = 0x2534; + tbl[0x77] = 0x252C; + tbl[0x78] = 0x2502; + tbl[0x79] = 0x2264; + tbl[0x7a] = 0x2265; + tbl[0x7b] = 0x03C0; + tbl[0x7c] = 0x2260; + tbl[0x7d] = 0x00A3; + tbl[0x7e] = 0x00B7; + break :tech tbl; }; /// Our table length is 256 so we can contain all ASCII chars. @@ -104,11 +105,11 @@ test { // utf8 has no table if (@field(Charset, field.name) == .utf8) continue; - const table = @field(Charset, field.name).table(); + const tbl = table(@field(Charset, field.name)); // Yes, I could use `table_len` here, but I want to explicitly use a // hardcoded constant so that if there are miscompilations or a comptime // issue, we catch it. - try testing.expectEqual(@as(usize, 256), table.len); + try testing.expectEqual(@as(usize, 256), tbl.len); } } diff --git a/src/terminal/color.zig b/src/terminal/color.zig index d108e205b..07c3e72f5 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -1,5 +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. @@ -45,6 +47,97 @@ pub const default: Palette = default: { /// Palette is the 256 color palette. pub const Palette = [256]RGB; +/// A palette that can have its colors changed and reset. Purposely built +/// for terminal color operations. +pub const DynamicPalette = struct { + /// The current palette including any user modifications. + current: Palette, + + /// The original/default palette values. + original: Palette, + + /// A bitset where each bit represents whether the corresponding + /// palette index has been modified from its default value. + mask: Mask, + + const Mask = std.StaticBitSet(@typeInfo(Palette).array.len); + + pub const default: DynamicPalette = .init(colorpkg.default); + + /// Initialize a dynamic palette with a default palette. + pub fn init(def: Palette) DynamicPalette { + return .{ + .current = def, + .original = def, + .mask = .initEmpty(), + }; + } + + /// Set a custom color at the given palette index. + pub fn set(self: *DynamicPalette, idx: u8, color: RGB) void { + self.current[idx] = color; + self.mask.set(idx); + } + + /// Reset the color at the given palette index to its original value. + pub fn reset(self: *DynamicPalette, idx: u8) void { + self.current[idx] = self.original[idx]; + self.mask.unset(idx); + } + + /// Reset all colors to their original values. + pub fn resetAll(self: *DynamicPalette) void { + self.* = .init(self.original); + } + + /// Change the default palette, but preserve the changed values. + pub fn changeDefault(self: *DynamicPalette, def: Palette) void { + self.original = def; + + // Fast path, the palette is usually not changed. + if (self.mask.count() == 0) { + self.current = self.original; + return; + } + + // There are usually less set than unset, so iterate over the changed + // values and override them. + var current = def; + var it = self.mask.iterator(.{}); + while (it.next()) |idx| current[idx] = self.current[idx]; + self.current = current; + } +}; + +/// RGB value that can be changed and reset. This can also be totally unset +/// in every way, in which case the caller can determine their own ultimate +/// default. +pub const DynamicRGB = struct { + override: ?RGB, + default: ?RGB, + + pub const unset: DynamicRGB = .{ .override = null, .default = null }; + + pub fn init(def: RGB) DynamicRGB { + return .{ + .override = null, + .default = def, + }; + } + + pub fn get(self: *const DynamicRGB) ?RGB { + return self.override orelse self.default; + } + + pub fn set(self: *DynamicRGB, color: RGB) void { + self.override = color; + } + + pub fn reset(self: *DynamicRGB) void { + self.override = self.default; + } +}; + /// Color names in the standard 8 or 16 color palette. pub const Name = enum(u8) { black = 0, @@ -68,6 +161,12 @@ pub const Name = enum(u8) { // Remainders are valid unnamed values in the 256 color palette. _, + pub const C = u8; + + pub fn cval(self: Name) C { + return @intFromEnum(self); + } + /// Default colors for tagged values. pub fn default(self: Name) !RGB { return switch (self) { @@ -179,6 +278,20 @@ pub const RGB = packed struct(u24) { g: u8 = 0, b: u8 = 0, + pub const C = extern struct { + r: u8, + g: u8, + b: u8, + }; + + pub fn cval(self: RGB) C { + return .{ + .r = self.r, + .g = self.g, + .b = self.b, + }; + } + pub fn eql(self: RGB, other: RGB) bool { return self.r == other.r and self.g == other.g and self.b == other.b; } @@ -243,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; } @@ -257,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), @@ -294,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; } @@ -320,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; + }, } } @@ -330,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; } @@ -341,6 +468,7 @@ pub const RGB = packed struct(u24) { } else false; if (value[i] != ':') { + @branchHint(.cold); return error.InvalidFormat; } @@ -349,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; @@ -363,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; @@ -436,3 +568,118 @@ test "RGB.parse" { try testing.expectError(error.InvalidFormat, RGB.parse("#fffff")); try testing.expectError(error.InvalidFormat, RGB.parse("#gggggg")); } + +test "DynamicPalette: init" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + try testing.expectEqual(default, p.current); + try testing.expectEqual(default, p.original); + try testing.expectEqual(@as(usize, 0), p.mask.count()); +} + +test "DynamicPalette: set" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + const new_color = RGB{ .r = 255, .g = 0, .b = 0 }; + + p.set(0, new_color); + try testing.expectEqual(new_color, p.current[0]); + try testing.expect(p.mask.isSet(0)); + try testing.expectEqual(@as(usize, 1), p.mask.count()); + + try testing.expectEqual(default[0], p.original[0]); +} + +test "DynamicPalette: reset" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + const new_color = RGB{ .r = 255, .g = 0, .b = 0 }; + + p.set(0, new_color); + try testing.expect(p.mask.isSet(0)); + + p.reset(0); + try testing.expectEqual(default[0], p.current[0]); + try testing.expect(!p.mask.isSet(0)); + try testing.expectEqual(@as(usize, 0), p.mask.count()); +} + +test "DynamicPalette: resetAll" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + const new_color = RGB{ .r = 255, .g = 0, .b = 0 }; + + p.set(0, new_color); + p.set(5, new_color); + p.set(10, new_color); + try testing.expectEqual(@as(usize, 3), p.mask.count()); + + p.resetAll(); + try testing.expectEqual(default, p.current); + try testing.expectEqual(default, p.original); + try testing.expectEqual(@as(usize, 0), p.mask.count()); +} + +test "DynamicPalette: changeDefault with no changes" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + var new_palette = default; + new_palette[0] = RGB{ .r = 100, .g = 100, .b = 100 }; + + p.changeDefault(new_palette); + try testing.expectEqual(new_palette, p.original); + try testing.expectEqual(new_palette, p.current); + try testing.expectEqual(@as(usize, 0), p.mask.count()); +} + +test "DynamicPalette: changeDefault preserves changes" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + const custom_color = RGB{ .r = 255, .g = 0, .b = 0 }; + + p.set(5, custom_color); + try testing.expect(p.mask.isSet(5)); + + var new_palette = default; + new_palette[0] = RGB{ .r = 100, .g = 100, .b = 100 }; + new_palette[5] = RGB{ .r = 50, .g = 50, .b = 50 }; + + p.changeDefault(new_palette); + + try testing.expectEqual(new_palette, p.original); + try testing.expectEqual(new_palette[0], p.current[0]); + try testing.expectEqual(custom_color, p.current[5]); + try testing.expect(p.mask.isSet(5)); + try testing.expectEqual(@as(usize, 1), p.mask.count()); +} + +test "DynamicPalette: changeDefault with multiple changes" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + const red = RGB{ .r = 255, .g = 0, .b = 0 }; + const green = RGB{ .r = 0, .g = 255, .b = 0 }; + const blue = RGB{ .r = 0, .g = 0, .b = 255 }; + + p.set(1, red); + p.set(2, green); + p.set(3, blue); + + var new_palette = default; + new_palette[0] = RGB{ .r = 50, .g = 50, .b = 50 }; + new_palette[1] = RGB{ .r = 60, .g = 60, .b = 60 }; + + p.changeDefault(new_palette); + + try testing.expectEqual(new_palette[0], p.current[0]); + try testing.expectEqual(red, p.current[1]); + try testing.expectEqual(green, p.current[2]); + try testing.expectEqual(blue, p.current[3]); + try testing.expectEqual(@as(usize, 3), p.mask.count()); +} diff --git a/src/terminal/csi.zig b/src/terminal/csi.zig index 0cab9ed52..d2f4bd6f8 100644 --- a/src/terminal/csi.zig +++ b/src/terminal/csi.zig @@ -1,3 +1,7 @@ +const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; + /// Modes for the ED CSI command. pub const EraseDisplay = enum(u8) { below = 0, @@ -33,13 +37,16 @@ pub const TabClear = enum(u8) { }; /// Style formats for terminal size reports. -pub const SizeReportStyle = enum { - // XTWINOPS - csi_14_t, - csi_16_t, - csi_18_t, - csi_21_t, -}; +pub const SizeReportStyle = lib.Enum( + lib_target, + &.{ + // XTWINOPS + "csi_14_t", + "csi_16_t", + "csi_18_t", + "csi_21_t", + }, +); /// XTWINOPS CSI 22/23 pub const TitlePushPop = struct { diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 971ea13a0..425325d4a 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; @@ -26,7 +26,7 @@ pub const Handler = struct { assert(self.state == .inactive); // Initialize our state to ignore in case of error - self.state = .{ .ignore = {} }; + self.state = .ignore; // Try to parse the hook. const hk_ = self.tryHook(alloc, dcs) catch |err| { @@ -70,7 +70,7 @@ pub const Handler = struct { ), }, }, - .command = .{ .tmux = .{ .enter = {} } }, + .command = .{ .tmux = .enter }, }; }, @@ -116,7 +116,7 @@ pub const Handler = struct { // On error we just discard our state and ignore the rest log.info("error putting byte into DCS handler err={}", .{err}); self.discard(); - self.state = .{ .ignore = {} }; + self.state = .ignore; return null; }; } @@ -158,7 +158,7 @@ pub const Handler = struct { // Note: we do NOT call deinit here on purpose because some commands // transfer memory ownership. If state needs cleanup, the switch // prong below should handle it. - defer self.state = .{ .inactive = {} }; + defer self.state = .inactive; return switch (self.state) { .inactive, @@ -167,7 +167,7 @@ pub const Handler = struct { .tmux => if (comptime build_options.tmux_control_mode) tmux: { self.state.deinit(); - break :tmux .{ .tmux = .{ .exit = {} } }; + break :tmux .{ .tmux = .exit }; } else unreachable, .xtgettcap => |*list| xtgettcap: { @@ -200,7 +200,7 @@ pub const Handler = struct { fn discard(self: *Handler) void { self.state.deinit(); - self.state = .{ .inactive = {} }; + self.state = .inactive; } }; @@ -213,7 +213,7 @@ pub const Command = union(enum) { /// Tmux control mode tmux: if (build_options.tmux_control_mode) - terminal.tmux.Notification + terminal.tmux.ControlNotification else void, @@ -255,21 +255,15 @@ pub const Command = union(enum) { decstbm, decslrm, }; - - /// Tmux control mode - pub const Tmux = union(enum) { - enter: void, - exit: void, - }; }; const State = union(enum) { /// We're not in a DCS state at the moment. - inactive: void, + inactive, /// We're hooked, but its an unknown DCS command or one that went /// invalid due to some bad input, so we're ignoring the rest. - ignore: void, + ignore, /// XTGETTCAP xtgettcap: std.Io.Writer.Allocating, @@ -282,7 +276,7 @@ const State = union(enum) { /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode tmux: if (build_options.tmux_control_mode) - terminal.tmux.Client + terminal.tmux.ControlParser else void, diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig new file mode 100644 index 000000000..74bbfe482 --- /dev/null +++ b/src/terminal/formatter.zig @@ -0,0 +1,5821 @@ +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const color = @import("color.zig"); +const size = @import("size.zig"); +const charsets = @import("charsets.zig"); +const kitty = @import("kitty.zig"); +const modespkg = @import("modes.zig"); +const Screen = @import("Screen.zig"); +const Terminal = @import("Terminal.zig"); +const Cell = @import("page.zig").Cell; +const Coordinate = @import("point.zig").Coordinate; +const Page = @import("page.zig").Page; +const PageList = @import("PageList.zig"); +const Pin = PageList.Pin; +const Row = @import("page.zig").Row; +const Selection = @import("Selection.zig"); +const Style = @import("style.zig").Style; + +/// Formats available. +pub const Format = enum { + /// Plain text. + plain, + + /// Include VT sequences to preserve colors, styles, URLs, etc. + /// This is predominantly SGR sequences but may contain others as needed. + /// + /// Note that for reference colors, like palette indices, this will + /// vary based on the formatter and you should see the docs. For example, + /// PageFormatter with VT will emit SGR sequences with palette indices, + /// not the color itself. + /// + /// For VT, newlines will be emitted as `\r\n` so that the cursor properly + /// moves back to the beginning prior emitting follow-up lines. + vt, + + /// HTML output. + /// + /// This will emit inline styles for as much styling as possible, + /// in the interest of simplicity and ease of editing. This isn't meant + /// to build the most beautiful or efficient HTML, but rather to be + /// stylistically correct. + /// + /// For colors, RGB values are emitted as inline CSS (#RRGGBB) while palette + /// indices use CSS variables (var(--vt-palette-N)). The palette colors are + /// emitted by TerminalFormatter.Extra.palette as a "); + }, + } + + // If we have a pin_map, add the bytes we wrote to map. + if (self.pin_map) |*m| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + var extra_formatter: TerminalFormatter = self; + extra_formatter.content = .none; + extra_formatter.pin_map = null; + extra_formatter.extra = .none; + extra_formatter.extra.palette = true; + try extra_formatter.format(&discarding.writer); + + // Map all those bytes to the same pin. Use the top left to ensure + // the node pointer is always properly initialized. + m.map.appendNTimes( + m.alloc, + self.terminal.screens.active.pages.getTopLeft(.screen), + discarding.count, + ) catch return error.WriteFailed; + } + } + + // Emit terminal modes that differ from defaults. We probably have + // some modes we want to emit before and some after, but for now for + // simplicity we just emit them all before. If we make this more complex + // later we should add test cases for it. + if (self.opts.emit == .vt and self.extra.modes) { + inline for (@typeInfo(modespkg.Mode).@"enum".fields) |field| { + const mode: modespkg.Mode = @enumFromInt(field.value); + const current = self.terminal.modes.get(mode); + const default_val = @field(self.terminal.modes.default, field.name); + + if (current != default_val) { + const tag: modespkg.ModeTag = @bitCast(@intFromEnum(mode)); + const prefix = if (tag.ansi) "" else "?"; + const suffix = if (current) "h" else "l"; + try writer.print("\x1b[{s}{d}{s}", .{ prefix, tag.value, suffix }); + } + } + + // If we have a pin_map, add the bytes we wrote to map. + if (self.pin_map) |*m| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + var extra_formatter: TerminalFormatter = self; + extra_formatter.content = .none; + extra_formatter.pin_map = null; + extra_formatter.extra = .none; + extra_formatter.extra.modes = true; + try extra_formatter.format(&discarding.writer); + + // Map all those bytes to the same pin. Use the top left to ensure + // the node pointer is always properly initialized. + m.map.appendNTimes( + m.alloc, + self.terminal.screens.active.pages.getTopLeft(.screen), + discarding.count, + ) catch return error.WriteFailed; + } + } + + var screen_formatter: ScreenFormatter = .init(self.terminal.screens.active, self.opts); + screen_formatter.content = self.content; + screen_formatter.extra = self.extra.screen; + screen_formatter.pin_map = self.pin_map; + try screen_formatter.format(writer); + + // Extra terminal state to emit after the screen contents so that + // it doesn't impact the emitted contents. + if (self.opts.emit == .vt) { + // Emit scrolling region using DECSTBM and DECSLRM + if (self.extra.scrolling_region) { + const region = &self.terminal.scrolling_region; + + // DECSTBM: top and bottom margins (1-indexed) + // Only emit if not the full screen + if (region.top != 0 or region.bottom != self.terminal.rows - 1) { + try writer.print("\x1b[{d};{d}r", .{ region.top + 1, region.bottom + 1 }); + } + + // DECSLRM: left and right margins (1-indexed) + // Only emit if not the full width + if (region.left != 0 or region.right != self.terminal.cols - 1) { + try writer.print("\x1b[{d};{d}s", .{ region.left + 1, region.right + 1 }); + } + } + + // Emit tabstop positions + if (self.extra.tabstops) { + // Clear all tabs (CSI 3 g) + try writer.print("\x1b[3g", .{}); + + // Set each configured tabstop by moving cursor and using HTS + for (0..self.terminal.cols) |col| { + if (self.terminal.tabstops.get(col)) { + // Move cursor to the column (1-indexed) + try writer.print("\x1b[{d}G", .{col + 1}); + // Set tab (HTS) + try writer.print("\x1bH", .{}); + } + } + } + + // Emit keyboard modes such as ModifyOtherKeys + if (self.extra.keyboard) { + // Only emit if modify_other_keys_2 is true + if (self.terminal.flags.modify_other_keys_2) { + try writer.print("\x1b[>4;2m", .{}); + } + } + + // Emit present working directory using OSC 7 + if (self.extra.pwd) { + const pwd = self.terminal.pwd.items; + if (pwd.len > 0) try writer.print("\x1b]7;{s}\x1b\\", .{pwd}); + } + + // If we have a pin_map, add the bytes we wrote to map. + if (self.pin_map) |*m| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + var extra_formatter: TerminalFormatter = self; + extra_formatter.content = .none; + extra_formatter.pin_map = null; + extra_formatter.extra = .none; + extra_formatter.extra.scrolling_region = self.extra.scrolling_region; + extra_formatter.extra.tabstops = self.extra.tabstops; + extra_formatter.extra.keyboard = self.extra.keyboard; + extra_formatter.extra.pwd = self.extra.pwd; + try extra_formatter.format(&discarding.writer); + + m.map.appendNTimes( + m.alloc, + if (m.map.items.len > 0) pin: { + const last = m.map.items[m.map.items.len - 1]; + break :pin .{ + .node = last.node, + .x = last.x, + .y = last.y, + }; + } else self.terminal.screens.active.pages.getTopLeft(.screen), + discarding.count, + ) catch return error.WriteFailed; + } + } + } +}; + +/// Screen formatter formats a single terminal screen (e.g. primary vs alt). +pub const ScreenFormatter = struct { + /// The screen to format. + screen: *const Screen, + + /// The common options + opts: Options, + + /// The content to include. + content: Content, + + /// Extra stuff to emit, such as cursor, style, hyperlinks, etc. + /// This information is ONLY emitted when the format is "vt". + extra: Extra, + + /// If non-null, then `map` will contain the Pin of every byte + /// byte written to the writer offset by the byte index. It is the + /// caller's responsibility to free the map. + /// + /// Note that some emitted bytes may not correspond to any Pin, such as + /// the extra data around screen state. For these, we'll map it to the + /// most previous pin so there is some continuity but its an arbitrary + /// choice. + /// + /// Warning: there is a significant performance hit to track this + pin_map: ?PinMap, + + pub const Content = union(enum) { + /// Emit no content, only terminal state such as modes, palette, etc. + /// via extra. + none, + + /// Emit the content specified by the selection. Null for all. + /// The selection is inclusive on both ends. + selection: ?Selection, + }; + + pub const Extra = packed struct { + /// Emit cursor position using CUP (CSI H). + cursor: bool, + + /// Emit current SGR style state based on the cursor's active style_id. + /// This reconstructs the SGR attributes (bold, italic, colors, etc.) at + /// the cursor position. + style: bool, + + /// Emit current hyperlink state using OSC 8 sequences. + /// This sets the active hyperlink based on cursor.hyperlink_id. + hyperlink: bool, + + /// Emit character protection mode using DECSCA. + protection: bool, + + /// Emit Kitty keyboard protocol state using CSI > u and CSI = sequences. + kitty_keyboard: bool, + + /// Emit character set designations and invocations. + /// This includes G0-G3 designations (ESC ( ) * +) and GL/GR invocations. + charsets: bool, + + /// Emit nothing. + pub const none: Extra = .{ + .cursor = false, + .style = false, + .hyperlink = false, + .protection = false, + .kitty_keyboard = false, + .charsets = false, + }; + + /// Emit style-relevant information only. + pub const styles: Extra = .{ + .cursor = false, + .style = true, + .hyperlink = true, + .protection = false, + .kitty_keyboard = false, + .charsets = false, + }; + + /// Emit everything. This reconstructs the screen state as closely + /// as possible. + pub const all: Extra = .{ + .cursor = true, + .style = true, + .hyperlink = true, + .protection = true, + .kitty_keyboard = true, + .charsets = true, + }; + + fn isSet(self: Extra) bool { + const Int = @typeInfo(Extra).@"struct".backing_integer.?; + const v: Int = @bitCast(self); + return v != 0; + } + }; + + pub fn init( + screen: *const Screen, + opts: Options, + ) ScreenFormatter { + return .{ + .screen = screen, + .opts = opts, + .content = .{ .selection = null }, + .extra = .none, + .pin_map = null, + }; + } + + pub fn format( + self: ScreenFormatter, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + switch (self.content) { + .none => {}, + + .selection => |selection_| { + // Emit our pagelist contents according to our selection. + var list_formatter: PageListFormatter = .init(&self.screen.pages, self.opts); + list_formatter.pin_map = self.pin_map; + if (selection_) |sel| { + list_formatter.top_left = sel.topLeft(self.screen); + list_formatter.bottom_right = sel.bottomRight(self.screen); + list_formatter.rectangle = sel.rectangle; + } + try list_formatter.format(writer); + }, + } + + // Emit extra screen state after content if we care. The state has + // to be emitted after since some state such as cursor position and + // style are impacted by content rendering. + switch (self.opts.emit) { + .plain => return, + .vt => if (!self.extra.isSet()) return, + + // HTML doesn't preserve any screen state because it has + // nothing to do with rendering. + .html => return, + } + + // Emit current SGR style state + if (self.extra.style) { + const cursor = &self.screen.cursor; + try writer.print("{f}", .{cursor.style.formatterVt()}); + } + + // Emit current hyperlink state using OSC 8 + if (self.extra.hyperlink) { + const cursor = &self.screen.cursor; + if (cursor.hyperlink) |link| { + // Start hyperlink with uri (and explicit id if present) + switch (link.id) { + .explicit => |id| try writer.print( + "\x1b]8;id={s};{s}\x1b\\", + .{ id, link.uri }, + ), + .implicit => try writer.print( + "\x1b]8;;{s}\x1b\\", + .{link.uri}, + ), + } + } + } + + // Emit character protection mode using DECSCA + if (self.extra.protection) { + const cursor = &self.screen.cursor; + if (cursor.protected) { + // DEC protected mode + try writer.print("\x1b[1\"q", .{}); + } + } + + // Emit Kitty keyboard protocol state using CSI = u + if (self.extra.kitty_keyboard) { + const current_flags = self.screen.kitty_keyboard.current(); + if (current_flags.int() != kitty.KeyFlags.disabled.int()) { + const flags = current_flags.int(); + try writer.print("\x1b[={d};1u", .{flags}); + } + } + + // Emit character set designations and invocations + if (self.extra.charsets) { + const charset = &self.screen.charset; + + // Emit G0-G3 designations + for (std.enums.values(charsets.Slots)) |slot| { + const cs = charset.charsets.get(slot); + if (cs != .utf8) { // Only emit non-default charsets + const intermediate: u8 = switch (slot) { + .G0 => '(', + .G1 => ')', + .G2 => '*', + .G3 => '+', + }; + const final: u8 = switch (cs) { + .ascii => 'B', + .british => 'A', + .dec_special => '0', + else => continue, + }; + try writer.print("\x1b{c}{c}", .{ intermediate, final }); + } + } + + // Emit GL invocation if not G0 + if (charset.gl != .G0) { + const seq = switch (charset.gl) { + .G0 => unreachable, + .G1 => "\x0e", // SO - Shift Out + .G2 => "\x1bn", // LS2 + .G3 => "\x1bo", // LS3 + }; + try writer.print("{s}", .{seq}); + } + + // Emit GR invocation if not G2 + if (charset.gr != .G2) { + const seq = switch (charset.gr) { + .G0 => unreachable, // GR can't be G0 + .G1 => "\x1b~", // LS1R + .G2 => unreachable, + .G3 => "\x1b|", // LS3R + }; + try writer.print("{s}", .{seq}); + } + } + + // Emit cursor position using CUP (CSI H) + if (self.extra.cursor) { + const cursor = &self.screen.cursor; + // CUP is 1-indexed + try writer.print("\x1b[{d};{d}H", .{ cursor.y + 1, cursor.x + 1 }); + } + + // If we have a pin_map, we need to count how many bytes the extras + // will emit so we can map them all to the same pin. We do this by + // formatting to a discarding writer with content=none. + if (self.pin_map) |*m| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + var extra_formatter: ScreenFormatter = self; + extra_formatter.content = .none; + extra_formatter.pin_map = null; + try extra_formatter.format(&discarding.writer); + + // Map all those bytes to the same pin. Use the first page node + // to ensure the node pointer is always properly initialized. + m.map.appendNTimes( + m.alloc, + if (m.map.items.len > 0) pin: { + // There is a weird Zig miscompilation here on 0.15.2. + // If I return the m.map.items value directly then we + // get undefined memory (even though we're copying a + // Pin struct). If we duplicate here like this we do + // not. + const last = m.map.items[m.map.items.len - 1]; + break :pin .{ + .node = last.node, + .x = last.x, + .y = last.y, + }; + } else self.screen.pages.getTopLeft(.screen), + discarding.count, + ) catch return error.WriteFailed; + } + } +}; + +/// PageList formatter formats multiple pages as represented by a PageList. +pub const PageListFormatter = struct { + /// The pagelist to format. + list: *const PageList, + + /// The common options + opts: Options, + + /// The bounds of the PageList to format. The top left and bottom right + /// MUST be ordered properly. + top_left: ?PageList.Pin, + bottom_right: ?PageList.Pin, + + /// If true, the boundaries define a rectangle selection where start_x + /// and end_x apply to every row, not just the first and last. + rectangle: bool, + + /// If non-null, then `map` will contain the Pin of every byte + /// byte written to the writer offset by the byte index. It is the + /// caller's responsibility to free the map. + /// + /// Warning: there is a significant performance hit to track this + pin_map: ?PinMap, + + pub fn init( + list: *const PageList, + opts: Options, + ) PageListFormatter { + return PageListFormatter{ + .list = list, + .opts = opts, + .top_left = null, + .bottom_right = null, + .rectangle = false, + .pin_map = null, + }; + } + + pub fn format( + self: PageListFormatter, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + const tl: PageList.Pin = self.top_left orelse self.list.getTopLeft(.screen); + const br: PageList.Pin = self.bottom_right orelse self.list.getBottomRight(.screen).?; + + // If we keep track of pins, we'll need this. + var point_map: std.ArrayList(Coordinate) = .empty; + defer if (self.pin_map) |*m| point_map.deinit(m.alloc); + + var page_state: ?PageFormatter.TrailingState = null; + var iter = tl.pageIterator(.right_down, br); + while (iter.next()) |chunk| { + assert(chunk.start < chunk.end); + assert(chunk.end > 0); + + var formatter: PageFormatter = .init(&chunk.node.data, self.opts); + formatter.start_y = chunk.start; + formatter.end_y = chunk.end - 1; + formatter.trailing_state = page_state; + formatter.rectangle = self.rectangle; + + // For rectangle selection, apply start_x and end_x to all chunks + if (self.rectangle) { + formatter.start_x = tl.x; + formatter.end_x = br.x; + } else { + // Otherwise only on the first/last, respectively. + if (chunk.node == tl.node) formatter.start_x = tl.x; + if (chunk.node == br.node) formatter.end_x = br.x; + } + + // If we're tracking pins, then we setup a point map for the + // page formatter (cause it can't track pins). And then we convert + // this to pins later. + if (self.pin_map) |*m| { + point_map.clearRetainingCapacity(); + formatter.point_map = .{ .alloc = m.alloc, .map = &point_map }; + } + + page_state = try formatter.formatWithState(writer); + + // If we're tracking pins then grab our points and write them + // to our pin map. + if (self.pin_map) |*m| { + for (point_map.items) |coord| { + m.map.append(m.alloc, .{ + .node = chunk.node, + .x = coord.x, + .y = @intCast(coord.y), + }) catch return error.WriteFailed; + } + } + } + } +}; + +/// Page formatter. +/// +/// For styled formatting such as VT, this will emit references for palette +/// colors. If you want to capture the palette as-is at the type of formatting, +/// you'll have to emit the sequences for setting up the palette prior to +/// this formatting. (TODO: A function to do this) +pub const PageFormatter = struct { + /// The page to format. + page: *const Page, + + /// The common options + opts: Options, + + /// Start and end points within the page to format. If end x is not given + /// then it will be the full width. If end y is not given then it will be + /// the full height. + /// + /// The start and end are both inclusive, so equal values will still + /// return a non-empty result (i.e. a single cell or row). + /// + /// The start x is considered the X in the first row and end X is + /// X in the final row. This isn't a rectangle selection by default. + /// + /// If start X falls on the second column of a wide character, then + /// the entire character will be included (as if you specified the + /// previous column). + start_x: size.CellCountInt, + start_y: size.CellCountInt, + end_x: ?size.CellCountInt, + end_y: ?size.CellCountInt, + + /// If true, the start x/y and end x/y define a rectangle selection. + /// In this case, the boundaries will apply to every row, not just + /// the first and last. + rectangle: bool, + + /// If non-null, then `map` will contain the x/y coordinate of every + /// 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, + map: *std.ArrayList(Coordinate), + }, + + /// The previous trailing state from the prior page. If you're iterating + /// over multiple pages this helps ensure that unwrapping and other + /// accounting works properly. + trailing_state: ?TrailingState, + + /// Trailing state. This is used to ensure that rows wrapped across + /// multiple pages are unwrapped properly, as well as other accounting + /// we may do in the future. + pub const TrailingState = struct { + rows: usize = 0, + cells: usize = 0, + + pub const empty: TrailingState = .{ .rows = 0, .cells = 0 }; + }; + + /// Initializes a page formatter. Other options can be set directly on the + /// struct after initialization and before calling `format()`. + pub fn init(page: *const Page, opts: Options) PageFormatter { + return .{ + .page = page, + .opts = opts, + .start_x = 0, + .start_y = 0, + .end_x = null, + .end_y = null, + .rectangle = false, + .point_map = null, + .trailing_state = null, + }; + } + + pub fn format( + self: PageFormatter, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + _ = try self.formatWithState(writer); + } + + pub fn formatWithState( + self: PageFormatter, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!TrailingState { + var blank_rows: usize = 0; + var blank_cells: usize = 0; + + // Continue our prior trailing state if we have it, but only if we're + // starting from the beginning (start_y and start_x are both 0). + // If a non-zero start position is specified, ignore trailing state. + if (self.trailing_state) |state| { + if (self.start_y == 0 and self.start_x == 0) { + blank_rows = state.rows; + blank_cells = state.cells; + } + } + + // Setup our starting column and perform some validation for overflows. + // Note: start_x only applies to the first row, end_x only applies to the last row. + const start_x: size.CellCountInt = self.start_x; + if (start_x >= self.page.size.cols) return .{ .rows = blank_rows, .cells = blank_cells }; + const end_x_unclamped: size.CellCountInt = self.end_x orelse self.page.size.cols - 1; + var end_x = @min(end_x_unclamped, self.page.size.cols - 1); + + // Setup our starting row and perform some validation for overflows. + const start_y: size.CellCountInt = self.start_y; + if (start_y >= self.page.size.rows) return .{ .rows = blank_rows, .cells = blank_cells }; + const end_y_unclamped: size.CellCountInt = self.end_y orelse self.page.size.rows - 1; + if (start_y > end_y_unclamped) return .{ .rows = blank_rows, .cells = blank_cells }; + var end_y = @min(end_y_unclamped, self.page.size.rows - 1); + + // Edge case: if our end x/y falls on a spacer head AND we're unwrapping, + // then we move the x/y to the start of the next row (if available). + if (self.opts.unwrap and !self.rectangle) { + const final_row = self.page.getRow(end_y); + const cells = self.page.getCells(final_row); + switch (cells[end_x].wide) { + .spacer_head => { + // Move to next row if available + // + // TODO: if unavailable, we should add to our trailing state + // + // so the pagelist formatter can be aware and maybe add + // another page + if (end_y < self.page.size.rows - 1) { + end_y += 1; + end_x = 0; + } + }, + + else => {}, + } + } + + // If we only have a single row, validate that start_x <= end_x + if (start_y == end_y and start_x > end_x) { + return .{ .rows = blank_rows, .cells = blank_cells }; + } + + // Wrap HTML output in monospace font styling + switch (self.opts.emit) { + .plain => {}, + + .html => { + // Setup our div. We use a buffer here that should always + // fit the stuff we need, in order to make counting bytes easier. + var buf: [1024]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const buf_writer = stream.writer(); + + // Monospace and whitespace preserving + buf_writer.writeAll("
2}{x:0>2}{x:0>2};", + .{ bg.r, bg.g, bg.b }, + ) catch return error.WriteFailed; + if (self.opts.foreground) |fg| buf_writer.print( + "color: #{x:0>2}{x:0>2}{x:0>2};", + .{ fg.r, fg.g, fg.b }, + ) catch return error.WriteFailed; + + buf_writer.writeAll("\">") catch return error.WriteFailed; + + const header = stream.getWritten(); + try writer.writeAll(header); + if (self.point_map) |*map| map.map.appendNTimes( + map.alloc, + .{ .x = 0, .y = 0 }, + header.len, + ) catch return error.WriteFailed; + }, + + .vt => { + // OSC 10 sets foreground color, OSC 11 sets background color + var buf: [512]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const buf_writer = stream.writer(); + if (self.opts.foreground) |fg| { + buf_writer.print( + "\x1b]10;rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", + .{ fg.r, fg.g, fg.b }, + ) catch return error.WriteFailed; + } + if (self.opts.background) |bg| { + buf_writer.print( + "\x1b]11;rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", + .{ bg.r, bg.g, bg.b }, + ) catch return error.WriteFailed; + } + + const header = stream.getWritten(); + try writer.writeAll(header); + if (self.point_map) |*map| map.map.appendNTimes( + map.alloc, + .{ .x = 0, .y = 0 }, + header.len, + ) catch return error.WriteFailed; + }, + } + + // Our style for non-plain formats + var style: Style = .{}; + + for (start_y..end_y + 1) |y_usize| { + const y: size.CellCountInt = @intCast(y_usize); + const row: *Row = self.page.getRow(y); + const cells: []const Cell = self.page.getCells(row); + + // Determine the x range for this row + // - First row: start_x to end of row (or end_x if single row) + // - Last row: start of row to end_x + // - Middle rows: full width + const cells_subset, const row_start_x = cells_subset: { + // The end is always straightforward + const row_end_x: size.CellCountInt = if (self.rectangle or y == end_y) + end_x + 1 + else + self.page.size.cols; + + // The first we have to check if our start X falls on the + // tail of a wide character. + const row_start_x: size.CellCountInt = if (start_x > 0 and + (self.rectangle or y == start_y)) + start_x: { + break :start_x switch (cells[start_x].wide) { + // Include the prior cell to get the full wide char + .spacer_tail => start_x - 1, + + // If we're a spacer head on our first row then we + // skip this whole row. + .spacer_head => continue, + + .narrow, .wide => start_x, + }; + } else 0; + + const subset = cells[row_start_x..row_end_x]; + break :cells_subset .{ subset, row_start_x }; + }; + + // If this row is blank, accumulate to avoid a bunch of extra + // work later. If it isn't blank, make sure we dump all our + // blanks. + if (!Cell.hasTextAny(cells_subset)) { + blank_rows += 1; + continue; + } + + if (blank_rows > 0) { + const sequence: []const u8 = switch (self.opts.emit) { + // Plaintext just uses standard newlines because newlines + // on their own usually move the cursor back in anywhere + // you type plaintext. + .plain => "\n", + + // VT uses \r\n because in a raw pty, \n alone doesn't + // guarantee moving the cursor back to column 0. \r + // makes it work for sure. + .vt => "\r\n", + + // HTML uses just \n because HTML rendering will move + // the cursor back. + .html => "\n", + }; + + for (0..blank_rows) |_| try writer.writeAll(sequence); + + // \r and \n map to the row that ends with this newline. + // If we're continuing (trailing state) then this will be + // in a prior page, so we just map to the first row of this + // page. + if (self.point_map) |*map| { + const start: Coordinate = if (map.map.items.len > 0) + map.map.items[map.map.items.len - 1] + else + .{ .x = 0, .y = 0 }; + + // The first one inherits the x value. + map.map.appendNTimes( + map.alloc, + .{ .x = start.x, .y = start.y }, + sequence.len, + ) catch return error.WriteFailed; + + // All others have x = 0 since they reference their prior + // blank line. + for (1..blank_rows) |y_offset_usize| { + const y_offset: size.CellCountInt = @intCast(y_offset_usize); + map.map.appendNTimes( + map.alloc, + .{ .x = 0, .y = start.y + y_offset }, + sequence.len, + ) catch return error.WriteFailed; + } + } + + blank_rows = 0; + } + + // If we're not wrapped, we always add a newline so after + // the row is printed we can add a newline. + if (!row.wrap or !self.opts.unwrap) blank_rows += 1; + + // If the row doesn't continue a wrap then we need to reset + // our blank cell count. + if (!row.wrap_continuation or !self.opts.unwrap) blank_cells = 0; + + // Go through each cell and print it + for (cells_subset, row_start_x..) |*cell, x_usize| { + const x: size.CellCountInt = @intCast(x_usize); + + // Skip spacers. These happen naturally when wide characters + // are printed again on the screen (for well-behaved terminals!) + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } + + // If we have a zero value, then we accumulate a counter. We + // only want to turn zero values into spaces if we have a non-zero + // char sometime later. + if (!cell.hasText()) { + blank_cells += 1; + continue; + } + if (cell.codepoint() == ' ' and self.opts.trim) { + blank_cells += 1; + continue; + } + + // This cell is not blank. If we have accumulated blank cells + // then we want to emit them now. + if (blank_cells > 0) { + try writer.splatByteAll(' ', blank_cells); + + if (self.point_map) |*map| { + // Map each blank cell to its coordinate. Blank cells can span + // multiple rows if they carry over from wrap continuation. + var remaining_blanks = blank_cells; + var blank_x = x; + var blank_y = y; + while (remaining_blanks > 0) : (remaining_blanks -= 1) { + if (blank_x > 0) { + // We have space in this row + blank_x -= 1; + } else if (blank_y > 0) { + // Wrap to previous row + blank_y -= 1; + blank_x = self.page.size.cols - 1; + } else { + // Can't go back further, just use (0, 0) + blank_x = 0; + blank_y = 0; + } + + map.map.append( + map.alloc, + .{ .x = blank_x, .y = blank_y }, + ) catch return error.WriteFailed; + } + } + + blank_cells = 0; + } + + switch (cell.content_tag) { + // We combine codepoint and graphemes because both have + // shared style handling. We use comptime to dup it. + inline .codepoint, .codepoint_grapheme => |tag| { + // Handle closing our styling if we go back to unstyled + // content. + if (self.opts.emit.styled() and + !cell.hasStyling() and + !style.default()) + { + try self.formatStyleClose(writer); + style = .{}; + } + + // If we're emitting styling and we have styles, then + // we need to load the style and emit any sequences + // as necessary. + if (self.opts.emit.styled() and cell.hasStyling()) style: { + // Get the style. + const cell_style = self.page.styles.get( + self.page.memory, + cell.style_id, + ); + + // If the style hasn't changed since our last + // emitted style, don't bloat the output. + if (cell_style.eql(style)) break :style; + + // We need to emit a closing tag if the style + // was non-default before, which means we set + // styles once. + const closing = !style.default(); + + // New style, emit it. + style = cell_style.*; + try self.formatStyleOpen( + writer, + &style, + closing, + ); + + // If we have a point map, we map the style to + // this cell. + if (self.point_map) |*map| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + try self.formatStyleOpen( + &discarding.writer, + &style, + closing, + ); + for (0..discarding.count) |_| map.map.append(map.alloc, .{ + .x = x, + .y = y, + }) catch return error.WriteFailed; + } + } + + try self.writeCell(tag, writer, cell); + + // If we have a point map, all codepoints map to this + // cell. + if (self.point_map) |*map| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + try self.writeCell(tag, &discarding.writer, cell); + for (0..discarding.count) |_| map.map.append(map.alloc, .{ + .x = x, + .y = y, + }) catch return error.WriteFailed; + } + }, + + // Unreachable since we do hasText() above + .bg_color_palette, + .bg_color_rgb, + => unreachable, + } + } + } + + // If the style is non-default, we need to close our style tag. + if (!style.default()) try self.formatStyleClose(writer); + + // Close the monospace wrapper for HTML output + if (self.opts.emit == .html) { + const closing = "
"; + try writer.writeAll(closing); + if (self.point_map) |*map| { + map.map.ensureUnusedCapacity( + map.alloc, + closing.len, + ) catch return error.WriteFailed; + map.map.appendNTimesAssumeCapacity( + map.map.items[map.map.items.len - 1], + closing.len, + ); + } + } + + return .{ .rows = blank_rows, .cells = blank_cells }; + } + + fn writeCell( + self: PageFormatter, + comptime tag: Cell.ContentTag, + writer: *std.Io.Writer, + cell: *const Cell, + ) !void { + try self.writeCodepointWithReplacement(writer, cell.content.codepoint); + if (comptime tag == .codepoint_grapheme) { + for (self.page.lookupGrapheme(cell).?) |cp| { + try self.writeCodepointWithReplacement(writer, cp); + } + } + } + + fn writeCodepointWithReplacement( + self: PageFormatter, + writer: *std.Io.Writer, + codepoint: u21, + ) !void { + // Search for our replacement + const r_: ?CodepointMap.Replacement = replacement: { + const map = self.opts.codepoint_map orelse break :replacement null; + const items = map.items(.range); + for (0..items.len) |forward_i| { + const i = items.len - forward_i - 1; + const range = items[i]; + if (range[0] <= codepoint and codepoint <= range[1]) { + const replacements = map.items(.replacement); + break :replacement replacements[i]; + } + } + + break :replacement null; + }; + + // If no replacement, write it directly. + const r = r_ orelse return try self.writeCodepoint( + writer, + codepoint, + ); + + switch (r) { + .codepoint => |v| try self.writeCodepoint( + writer, + v, + ), + + .string => |s| { + const view = std.unicode.Utf8View.init(s) catch unreachable; + var it = view.iterator(); + while (it.nextCodepoint()) |cp| try self.writeCodepoint( + writer, + cp, + ); + }, + } + } + + fn writeCodepoint( + self: PageFormatter, + writer: *std.Io.Writer, + codepoint: u21, + ) !void { + switch (self.opts.emit) { + .plain, .vt => try writer.print("{u}", .{codepoint}), + .html => { + switch (codepoint) { + '<' => try writer.writeAll("<"), + '>' => try writer.writeAll(">"), + '&' => try writer.writeAll("&"), + '"' => try writer.writeAll("""), + '\'' => try writer.writeAll("'"), + else => { + // For HTML, emit ASCII (< 0x80) directly, but encode + // all non-ASCII as numeric entities to avoid encoding + // detection issues (fixes #9426). We can't set the + // meta tag because we emit partial HTML so this ensures + // proper unicode handling. + if (codepoint < 0x80) { + try writer.print("{u}", .{codepoint}); + } else { + try writer.print("&#{d};", .{codepoint}); + } + }, + } + }, + } + } + + fn formatStyleOpen( + self: PageFormatter, + writer: *std.Io.Writer, + style: *const Style, + closing: bool, + ) std.Io.Writer.Error!void { + switch (self.opts.emit) { + .plain => unreachable, + + // Note: we don't use closing on purpose because VT sequences + // always reset the prior style. Our formatter always emits a + // \x1b[0m before emitting a new style if necessary. + .vt => { + var formatter = style.formatterVt(); + formatter.palette = self.opts.palette; + try writer.print("{f}", .{formatter}); + }, + + // We use `display: inline` so that the div doesn't impact + // layout since we're primarily using it as a CSS wrapper. + .html => { + if (closing) try writer.writeAll("
"); + var formatter = style.formatterHtml(); + formatter.palette = self.opts.palette; + try writer.print( + "
", + .{formatter}, + ); + }, + } + } + + fn formatStyleClose( + self: PageFormatter, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + const str: []const u8 = switch (self.opts.emit) { + .plain => return, + .vt => "\x1b[0m", + .html => "
", + }; + + try writer.writeAll(str); + if (self.point_map) |*m| { + assert(m.map.items.len > 0); + m.map.ensureUnusedCapacity( + m.alloc, + str.len, + ) catch return error.WriteFailed; + m.map.appendNTimesAssumeCapacity( + m.map.items[m.map.items.len - 1], + str.len, + ); + } + } +}; + +test "Page plain single line" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + // 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, .plain); + + // 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 + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello, world", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 12), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); +} + +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; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("1A⚡"); + + // 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, .plain); + + // Test our point map. + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Full string + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("1A⚡", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (2..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 2, .y = 0 }, + point_map.items[i], + ); + } + + // Wide only (from start) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.start_x = 2; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("⚡", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 2, .y = 0 }, + point_map.items[i], + ); + } + + // Wide only (from tail) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.start_x = 3; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("⚡", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 2, .y = 0 }, + point_map.items[i], + ); + } +} + +test "Page plain single wide char 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 = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("1A⚡"); + + // 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, .plain); + formatter.opts.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 }; + + // Full string + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("1A⚡", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 2), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (2..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[i], + ); + } + + // Full string (ending on spacer head) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.end_x = 2; + formatter.end_y = 0; + defer { + formatter.end_x = null; + formatter.end_y = null; + } + + _ = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("1A⚡", output); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (2..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[i], + ); + } + + // Wide only (from start) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.start_x = 2; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("⚡", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 2), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[i], + ); + } + + // Wide only (from tail) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.start_y = 1; + formatter.start_x = 1; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("⚡", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 2), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[i], + ); + } +} + +test "Page plain multiline" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + // 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, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\nworld", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[6 + i], + ); +} + +test "Page plain multiline rectangle" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + // 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, .plain); + formatter.start_x = 1; + formatter.end_x = 3; + formatter.rectangle = true; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("ell\norl", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..3) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i + 1), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[3]); // \n + for (0..3) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i + 1), .y = 1 }, + point_map.items[4 + i], + ); +} + +test "Page plain multi blank lines" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\n\r\n\r\nworld"); + + // 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, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\n\n\nworld", output); + try testing.expectEqual(@as(usize, page.size.rows - 3), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n after row 0 + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[6]); // \n after blank row 1 + try testing.expectEqual(Coordinate{ .x = 0, .y = 2 }, point_map.items[7]); // \n after blank row 2 + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 3 }, + point_map.items[8 + i], + ); +} + +test "Page plain trailing blank lines" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld\r\n\r\n"); + + // 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, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Verify output. We expect there to be no trailing newlines because + // we can't differentiate trailing blank lines as being meaningful because + // the page formatter can't see the cursor position. + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\nworld", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[6 + i], + ); +} + +test "Page plain trailing whitespace" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello \r\nworld "); + + // 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, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Verify output. We expect there to be no trailing newlines because + // we can't differentiate trailing blank lines as being meaningful because + // the page formatter can't see the cursor position. + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\nworld", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[6 + i], + ); +} + +test "Page plain trailing whitespace no trim" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello \r\nworld "); + + // 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, + .trim = false, + }); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Verify output. We expect there to be no trailing newlines because + // we can't differentiate trailing blank lines as being meaningful because + // the page formatter can't see the cursor position. + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello \nworld ", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 7), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..8) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[8]); // \n + for (0..7) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[9 + i], + ); +} + +test "Page plain with prior trailing state rows" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .plain); + formatter.trailing_state = .{ .rows = 2, .cells = 0 }; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\n\nhello", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \n first blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[1]); // \n second blank row + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[2 + i], + ); +} + +test "Page plain with prior trailing state cells no wrapped line" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .plain); + formatter.trailing_state = .{ .rows = 0, .cells = 3 }; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + // Blank cells are reset when row is not a wrap continuation + try testing.expectEqualStrings("hello", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); +} + +test "Page plain with prior trailing state cells with wrap continuation" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("world"); + + const pages = &t.screens.active.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + + // Surgically modify the first row to be a wrap continuation + const row = page.getRow(0); + row.wrap_continuation = true; + + var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); + formatter.trailing_state = .{ .rows = 0, .cells = 3 }; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + // Blank cells are preserved when row is a wrap continuation with unwrap enabled + try testing.expectEqualStrings(" world", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map - 3 spaces from prior trailing state + "world" + try testing.expectEqual(output.len, point_map.items.len); + // The 3 blank cells can't go back beyond (0,0) so they all map to (0,0) + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // space + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[1]); // space + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[2]); // space + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[3 + i], + ); +} + +test "Page plain soft-wrapped without unwrap" { + 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 = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world test"); + + const pages = &t.screens.active.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + // Without unwrap, wrapped lines show as separate lines + try testing.expectEqualStrings("hello worl\nd test", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..10) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \n + for (0..6) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[11 + i], + ); +} + +test "Page plain soft-wrapped with unwrap" { + 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 = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world test"); + + const pages = &t.screens.active.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + // With unwrap, wrapped lines are joined together + try testing.expectEqualStrings("hello world test", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..10) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + for (0..6) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[10 + i], + ); +} + +test "Page plain soft-wrapped 3 lines without unwrap" { + 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 = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world this is a test"); + + const pages = &t.screens.active.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + // Without unwrap, wrapped lines show as separate lines + try testing.expectEqualStrings("hello worl\nd this is\na test", output); + try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..10) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \n + for (0..9) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[11 + i], + ); + try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[20]); // \n + for (0..6) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 2 }, + point_map.items[21 + i], + ); +} + +test "Page plain soft-wrapped 3 lines with unwrap" { + 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 = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world this is a test"); + + const pages = &t.screens.active.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + // With unwrap, wrapped lines are joined together + try testing.expectEqualStrings("hello world this is a test", output); + try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); + + // Verify point map - unwrapped text spans 3 rows + try testing.expectEqual(output.len, point_map.items.len); + for (0..10) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + for (0..10) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[10 + i], + ); + for (0..6) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 2 }, + point_map.items[20 + i], + ); +} + +test "Page plain start_y subset" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld\r\ntest"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 1; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("world\ntest", output); + try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \n + for (0..4) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 2 }, + point_map.items[6 + i], + ); +} + +test "Page plain end_y subset" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld\r\ntest"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.end_y = 1; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\nworld", output); + try testing.expectEqual(@as(usize, 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[6 + i], + ); +} + +test "Page plain start_y and end_y range" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld\r\ntest\r\nfoo"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 1; + formatter.end_y = 2; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("world\ntest", output); + try testing.expectEqual(@as(usize, 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \n + for (0..4) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 2 }, + point_map.items[6 + i], + ); +} + +test "Page plain start_y out of bounds" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 30; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("", output); + try testing.expectEqual(@as(usize, 0), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); + + // Verify point map is empty + try testing.expectEqual(@as(usize, 0), point_map.items.len); +} + +test "Page plain end_y greater than rows" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.end_y = 30; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + // Should clamp to page.size.rows and work normally + try testing.expectEqualStrings("hello", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); +} + +test "Page plain end_y less than start_y" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 5; + formatter.end_y = 2; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("", output); + try testing.expectEqual(@as(usize, 0), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); + + // Verify point map is empty + try testing.expectEqual(@as(usize, 0), point_map.items.len); +} + +test "Page plain start_x on first row only" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 6; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("world", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 11), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i + 6), .y = 0 }, + point_map.items[i], + ); +} + +test "Page plain end_x on last row only" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("first line\r\nsecond line\r\nthird line"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.end_y = 2; + formatter.end_x = 4; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("first line\nsecond line\nthird", output); + try testing.expectEqual(@as(usize, 1), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..10) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \n + for (0..11) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[11 + i], + ); + try testing.expectEqual(Coordinate{ .x = 10, .y = 1 }, point_map.items[22]); // \n + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 2 }, + point_map.items[23 + i], + ); +} + +test "Page plain start_x and end_x multiline" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world\r\ntest case\r\nfoo bar"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 6; + formatter.end_y = 2; + formatter.end_x = 2; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + // First row: "world" (start_x=6 to end of row) + // Second row: "test case" (full row) + // Third row: "foo" (start to end_x=2, inclusive) + try testing.expectEqualStrings("world\ntest case\nfoo", output); + try testing.expectEqual(@as(usize, 1), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i + 6), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[5]); // \n + for (0..9) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[6 + i], + ); + try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[15]); // \n + for (0..3) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 2 }, + point_map.items[16 + i], + ); +} + +test "Page plain start_x out of bounds" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 100; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("", output); + try testing.expectEqual(@as(usize, 0), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); + + // Verify point map is empty + try testing.expectEqual(@as(usize, 0), point_map.items.len); +} + +test "Page plain end_x greater than cols" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.end_x = 100; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); +} + +test "Page plain end_x less than start_x single row" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 10; + formatter.end_y = 0; + formatter.end_x = 5; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("", output); + try testing.expectEqual(@as(usize, 0), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); + + // Verify point map is empty + try testing.expectEqual(@as(usize, 0), point_map.items.len); +} + +test "Page plain start_y non-zero ignores trailing state" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 1; + formatter.trailing_state = .{ .rows = 5, .cells = 10 }; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + // Should NOT output the 5 newlines from trailing_state because start_y is non-zero + try testing.expectEqualStrings("world", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[i], + ); +} + +test "Page plain start_x non-zero ignores trailing state" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 6; + formatter.trailing_state = .{ .rows = 2, .cells = 8 }; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + // Should NOT output the 2 newlines or 8 spaces from trailing_state because start_x is non-zero + try testing.expectEqualStrings("world", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 11), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i + 6), .y = 0 }, + point_map.items[i], + ); +} + +test "Page plain start_y and start_x zero uses trailing state" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 0; + formatter.start_x = 0; + formatter.trailing_state = .{ .rows = 2, .cells = 0 }; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + // SHOULD output the 2 newlines from trailing_state because both start_y and start_x are 0 + try testing.expectEqualStrings("\n\nhello", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \n first blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[1]); // \n second blank row + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[2 + i], + ); +} + +test "Page plain single line with styling" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, \x1b[1mworld\x1b[0m"); + + // 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, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello, world", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 12), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..12) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); +} + +test "Page VT single line plain text" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .vt); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello", output); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); +} + +test "Page VT single line with bold" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[1mhello\x1b[0m"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .vt); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello\x1b[0m", output); + + // Verify point map - style sequences should point to first character they style + try testing.expectEqual(output.len, point_map.items.len); + // \x1b[0m = 4 bytes, \x1b[1m = 4 bytes, total 8 bytes of style sequences + // All style bytes should map to the first styled character at (0, 0) + for (0..8) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 0 }, + point_map.items[i], + ); + // Then "hello" maps to its respective positions + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[8 + i], + ); +} + +test "Page VT multiple styles" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[1mhello \x1b[3mworld\x1b[0m"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .vt); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello \x1b[0m\x1b[1m\x1b[3mworld\x1b[0m", output); + + // Verify point map matches output length + try testing.expectEqual(output.len, point_map.items.len); +} + +test "Page VT with foreground color" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[31mred\x1b[0m"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .vt); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred\x1b[0m", output); + + // Verify point map - style sequences should point to first character they style + try testing.expectEqual(output.len, point_map.items.len); + // \x1b[0m = 4 bytes, \x1b[38;5;1m = 9 bytes, total 13 bytes of style sequences + // All style bytes should map to the first styled character at (0, 0) + for (0..13) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 0 }, + point_map.items[i], + ); + // Then "red" maps to its respective positions + for (0..3) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[13 + i], + ); +} + +test "Page VT with background and foreground colors" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .{ + .emit = .vt, + .background = .{ .r = 0x12, .g = 0x34, .b = 0x56 }, + .foreground = .{ .r = 0xab, .g = 0xcd, .b = 0xef }, + }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Should emit OSC 10 for foreground, OSC 11 for background, then the text + try testing.expectEqualStrings( + "\x1b]10;rgb:ab/cd/ef\x1b\\\x1b]11;rgb:12/34/56\x1b\\hello", + output, + ); +} + +test "Page VT multi-line with styles" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .vt); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[1mfirst\r\n\x1b[0m\x1b[3msecond\x1b[0m", output); + + // Verify point map matches output length + try testing.expectEqual(output.len, point_map.items.len); +} + +test "Page VT duplicate style not emitted twice" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[1mhel\x1b[1mlo\x1b[0m"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .vt); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello\x1b[0m", output); + + // Verify point map matches output length + try testing.expectEqual(output.len, point_map.items.len); +} + +test "PageList plain single line" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: PageListFormatter = .init(&t.screens.active.pages, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello, world", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + for (0..output.len) |i| try testing.expectEqual( + Pin{ .node = node, .x = @intCast(i), .y = 0 }, + pin_map.items[i], + ); +} + +test "PageList plain spanning two pages" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screens.active.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill the first page almost completely + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("page one"); + + // Verify we're still on one page + try testing.expect(pages.pages.first == pages.pages.last); + + // Add one more newline to push content to a second page + try s.nextSlice("\r\n"); + try testing.expect(pages.pages.first != pages.pages.last); + + // Write content on the second page + try s.nextSlice("page two"); + + // Format the entire PageList + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + try formatter.format(&builder.writer); + const full_output = builder.writer.buffered(); + const output = std.mem.trimStart(u8, full_output, "\n"); + try testing.expectEqualStrings("page one\npage two", output); + + // Verify pin map + try testing.expectEqual(full_output.len, pin_map.items.len); + const first_node = pages.pages.first.?; + const last_node = pages.pages.last.?; + const trimmed_count = full_output.len - output.len; + + // First part (trimmed blank lines) maps to first node + for (0..trimmed_count) |i| { + try testing.expectEqual(first_node, pin_map.items[i].node); + } + + // "page one" (8 chars) maps to first node + for (0..8) |i| { + const idx = trimmed_count + i; + try testing.expectEqual(first_node, pin_map.items[idx].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); + } + + // \n - maps to last node as it represents the transition to new page + try testing.expectEqual(last_node, pin_map.items[trimmed_count + 8].node); + + // "page two" (8 chars) maps to last node + for (0..8) |i| { + const idx = trimmed_count + 9 + i; + try testing.expectEqual(last_node, pin_map.items[idx].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); + } +} + +test "PageList soft-wrapped line spanning two pages without unwrap" { + 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 = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screens.active.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill the first page with soft-wrapped content + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("hello world test"); + + // Verify we're on two pages due to wrapping + try testing.expect(pages.pages.first != pages.pages.last); + + // Format without unwrap - should show line breaks + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + try formatter.format(&builder.writer); + const full_output = builder.writer.buffered(); + const output = std.mem.trimStart(u8, full_output, "\n"); + try testing.expectEqualStrings("hello worl\nd test", output); + + // Verify pin map + try testing.expectEqual(full_output.len, pin_map.items.len); + const first_node = pages.pages.first.?; + const last_node = pages.pages.last.?; + const trimmed_count = full_output.len - output.len; + + // First part (trimmed blank lines) maps to first node + for (0..trimmed_count) |i| { + try testing.expectEqual(first_node, pin_map.items[i].node); + } + + // First line maps to first node + for (0..10) |i| { + const idx = trimmed_count + i; + try testing.expectEqual(first_node, pin_map.items[idx].node); + } + + // \n - maps to last node as it represents the transition to new page + try testing.expectEqual(last_node, pin_map.items[trimmed_count + 10].node); + + // "d test" (6 chars) maps to last node + for (0..6) |i| { + const idx = trimmed_count + 11 + i; + try testing.expectEqual(last_node, pin_map.items[idx].node); + } +} + +test "PageList soft-wrapped line spanning two pages with unwrap" { + 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 = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screens.active.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill the first page with soft-wrapped content + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("hello world test"); + + // Verify we're on two pages due to wrapping + try testing.expect(pages.pages.first != pages.pages.last); + + // Format with unwrap - should join the wrapped lines + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: PageListFormatter = .init(pages, .{ .emit = .plain, .unwrap = true }); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + try formatter.format(&builder.writer); + const full_output = builder.writer.buffered(); + const output = std.mem.trimStart(u8, full_output, "\r\n"); + try testing.expectEqualStrings("hello world test", output); + + // Verify pin map + try testing.expectEqual(full_output.len, pin_map.items.len); + const first_node = pages.pages.first.?; + const last_node = pages.pages.last.?; + const trimmed_count = full_output.len - output.len; + + // First part (trimmed blank lines) maps to first node + for (0..trimmed_count) |i| { + try testing.expectEqual(first_node, pin_map.items[i].node); + } + + // First line from first page + for (0..10) |i| { + const idx = trimmed_count + i; + try testing.expectEqual(first_node, pin_map.items[idx].node); + } + + // "d test" (6 chars) from last page + for (0..6) |i| { + const idx = trimmed_count + 10 + i; + try testing.expectEqual(last_node, pin_map.items[idx].node); + } +} + +test "PageList VT spanning two pages" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screens.active.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill the first page almost completely + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("\x1b[1mpage one"); + + // Verify we're still on one page + try testing.expect(pages.pages.first == pages.pages.last); + + // Add one more newline to push content to a second page + try s.nextSlice("\r\n"); + try testing.expect(pages.pages.first != pages.pages.last); + + // New content is still styled + try s.nextSlice("page two"); + + // Format the entire PageList with VT + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: PageListFormatter = .init(pages, .vt); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + try formatter.format(&builder.writer); + const full_output = builder.writer.buffered(); + const output = std.mem.trimStart(u8, full_output, "\r\n"); + try testing.expectEqualStrings("\x1b[0m\x1b[1mpage one\x1b[0m\r\n\x1b[0m\x1b[1mpage two\x1b[0m", output); + + // Verify pin map + try testing.expectEqual(full_output.len, pin_map.items.len); + const first_node = pages.pages.first.?; + const last_node = pages.pages.last.?; + + // Just verify we have entries for both pages in the pin map + var first_count: usize = 0; + var last_count: usize = 0; + for (pin_map.items) |pin| { + if (pin.node == first_node) first_count += 1; + if (pin.node == last_node) last_count += 1; + } + try testing.expect(first_count > 0); + try testing.expect(last_count > 0); +} + +test "PageList plain with x offset on single page" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world\r\ntest case\r\nfoo bar"); + + const pages = &t.screens.active.pages; + const node = pages.pages.first.?; + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = .{ .node = node, .y = 0, .x = 6 }; + formatter.bottom_right = .{ .node = node, .y = 2, .x = 2 }; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("world\ntest case\nfoo", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + for (pin_map.items) |pin| { + try testing.expectEqual(node, pin.node); + } + + // "world" starts at x=6, y=0 + for (0..5) |i| { + try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + } +} + +test "PageList plain with x offset spanning two pages" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screens.active.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill first page almost completely + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("hello world"); + + // Verify we're still on one page + try testing.expect(pages.pages.first == pages.pages.last); + + // Push to second page + try s.nextSlice("\r\n"); + try testing.expect(pages.pages.first != pages.pages.last); + + try s.nextSlice("foo bar test"); + + const first_node = pages.pages.first.?; + const last_node = pages.pages.last.?; + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = .{ .node = first_node, .y = first_node.data.size.rows - 1, .x = 6 }; + formatter.bottom_right = .{ .node = last_node, .y = 1, .x = 2 }; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const full_output = builder.writer.buffered(); + const output = std.mem.trimStart(u8, full_output, "\n"); + try testing.expectEqualStrings("world\nfoo", output); + + // Verify pin map + try testing.expectEqual(full_output.len, pin_map.items.len); + const trimmed_count = full_output.len - output.len; + + // "world" (5 chars) from first page + for (0..5) |i| { + const idx = trimmed_count + i; + try testing.expectEqual(first_node, pin_map.items[idx].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[idx].x); + } + + // \n - maps to last node as it represents the transition to new page + try testing.expectEqual(last_node, pin_map.items[trimmed_count + 5].node); + + // "foo" (3 chars) from last page + for (0..3) |i| { + const idx = trimmed_count + 6 + i; + try testing.expectEqual(last_node, pin_map.items[idx].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); + } +} + +test "PageList plain with start_x only" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screens.active.pages; + const node = pages.pages.first.?; + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = .{ .node = node, .y = 0, .x = 6 }; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("world", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + for (0..5) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + } +} + +test "PageList plain with end_x only" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world\r\ntest"); + + const pages = &t.screens.active.pages; + const node = pages.pages.first.?; + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.bottom_right = .{ .node = node, .y = 1, .x = 2 }; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello world\ntes", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + + // "hello world" (11 chars) on y=0 + for (0..11) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + } + + // \n + try testing.expectEqual(node, pin_map.items[11].node); + + // "tes" (3 chars) on y=1 + for (0..3) |i| { + try testing.expectEqual(node, pin_map.items[12 + i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[12 + i].x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[12 + i].y); + } +} + +test "PageList plain rectangle basic" { + 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 = 30, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Lorem ipsum dolor\r\n"); + try s.nextSlice("sit amet, consectetur\r\n"); + try s.nextSlice("adipiscing elit, sed do\r\n"); + try s.nextSlice("eiusmod tempor incididunt\r\n"); + try s.nextSlice("ut labore et dolore"); + + const pages = &t.screens.active.pages; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?; + formatter.bottom_right = pages.pin(.{ .screen = .{ .x = 6, .y = 3 } }).?; + formatter.rectangle = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + const expected = + \\t ame + \\ipisc + \\usmod + ; + try testing.expectEqualStrings(expected, output); +} + +test "PageList plain rectangle with EOL" { + 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 = 30, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Lorem ipsum dolor\r\n"); + try s.nextSlice("sit amet, consectetur\r\n"); + try s.nextSlice("adipiscing elit, sed do\r\n"); + try s.nextSlice("eiusmod tempor incididunt\r\n"); + try s.nextSlice("ut labore et dolore"); + + const pages = &t.screens.active.pages; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = pages.pin(.{ .screen = .{ .x = 12, .y = 0 } }).?; + formatter.bottom_right = pages.pin(.{ .screen = .{ .x = 26, .y = 4 } }).?; + formatter.rectangle = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + const expected = + \\dolor + \\nsectetur + \\lit, sed do + \\or incididunt + \\ dolore + ; + try testing.expectEqualStrings(expected, output); +} + +test "PageList plain rectangle more complex with breaks" { + 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 = 30, + .rows = 8, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Lorem ipsum dolor\r\n"); + try s.nextSlice("sit amet, consectetur\r\n"); + try s.nextSlice("adipiscing elit, sed do\r\n"); + try s.nextSlice("eiusmod tempor incididunt\r\n"); + try s.nextSlice("ut labore et dolore\r\n"); + try s.nextSlice("\r\n"); + try s.nextSlice("magna aliqua. Ut enim\r\n"); + try s.nextSlice("ad minim veniam, quis"); + + const pages = &t.screens.active.pages; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = pages.pin(.{ .screen = .{ .x = 11, .y = 2 } }).?; + formatter.bottom_right = pages.pin(.{ .screen = .{ .x = 26, .y = 7 } }).?; + formatter.rectangle = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + const expected = + \\elit, sed do + \\por incididunt + \\t dolore + \\ + \\a. Ut enim + \\niam, quis + ; + try testing.expectEqualStrings(expected, output); +} + +test "TerminalFormatter plain no selection" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + const formatter: TerminalFormatter = .init(&t, .plain); + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("hello\nworld", builder.writer.buffered()); +} + +test "TerminalFormatter vt with palette" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Modify some palette colors using VT sequences + try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + try s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); + try s.nextSlice("test"); + + const formatter: TerminalFormatter = .init(&t, .vt); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify the palettes match + try testing.expectEqual(t.colors.palette.current[0], t2.colors.palette.current[0]); + try testing.expectEqual(t.colors.palette.current[1], t2.colors.palette.current[1]); + try testing.expectEqual(t.colors.palette.current[255], t2.colors.palette.current[255]); +} + +test "TerminalFormatter with selection" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("line1\r\nline2\r\nline3"); + + var formatter: TerminalFormatter = .init(&t, .plain); + formatter.content = .{ .selection = .init( + t.screens.active.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + t.screens.active.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, + false, + ) }; + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("line2", builder.writer.buffered()); +} + +test "TerminalFormatter plain with pin_map" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: TerminalFormatter = .init(&t, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello, world", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + for (0..output.len) |i| try testing.expectEqual( + Pin{ .node = node, .x = @intCast(i), .y = 0 }, + pin_map.items[i], + ); +} + +test "TerminalFormatter plain multiline with pin_map" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: TerminalFormatter = .init(&t, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\nworld", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + // "hello" (5 chars) + for (0..5) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + } + // "\n" maps to end of first line + try testing.expectEqual(node, pin_map.items[5].node); + // "world" (5 chars) + for (0..5) |i| { + const idx = 6 + i; + try testing.expectEqual(node, pin_map.items[idx].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[idx].y); + } +} + +test "TerminalFormatter vt with palette and pin_map" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Modify some palette colors using VT sequences + try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + try s.nextSlice("test"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Verify pin map - palette bytes should be mapped to top left + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + for (0..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } +} + +test "TerminalFormatter with selection and pin_map" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("line1\r\nline2\r\nline3"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: TerminalFormatter = .init(&t, .plain); + formatter.content = .{ .selection = .init( + t.screens.active.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + t.screens.active.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, + false, + ) }; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("line2", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + // "line2" (5 chars) from row 1 + for (0..5) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[i].y); + } +} + +test "Screen plain single line" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: ScreenFormatter = .init(t.screens.active, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello, world", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + for (0..output.len) |i| try testing.expectEqual( + Pin{ .node = node, .x = @intCast(i), .y = 0 }, + pin_map.items[i], + ); +} + +test "Screen plain multiline" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: ScreenFormatter = .init(t.screens.active, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\nworld", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + // "hello" (5 chars) + for (0..5) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + } + // "\n" maps to end of first line + try testing.expectEqual(node, pin_map.items[5].node); + // "world" (5 chars) + for (0..5) |i| { + const idx = 6 + i; + try testing.expectEqual(node, pin_map.items[idx].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[idx].y); + } +} + +test "Screen plain with selection" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("line1\r\nline2\r\nline3"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: ScreenFormatter = .init(t.screens.active, .plain); + formatter.content = .{ .selection = .init( + t.screens.active.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + t.screens.active.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, + false, + ) }; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("line2", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + // "line2" (5 chars) from row 1 + for (0..5) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[i].y); + } +} + +test "Screen vt with cursor position" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Position cursor at a specific location + try s.nextSlice("hello\r\nworld"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: ScreenFormatter = .init(t.screens.active, .vt); + formatter.extra.cursor = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify cursor positions match + try testing.expectEqual(t.screens.active.cursor.x, t2.screens.active.cursor.x); + try testing.expectEqual(t.screens.active.cursor.y, t2.screens.active.cursor.y); + + // Verify pin map - the extras should be mapped to the last pin + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + const content_len = "hello\r\nworld".len; + // Content bytes map to their positions + for (0..content_len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } + // Extra bytes (cursor position) map to last content pin + for (content_len..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } +} + +test "Screen vt with style" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set some style attributes + try s.nextSlice("\x1b[1;31mhello"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: ScreenFormatter = .init(t.screens.active, .vt); + formatter.extra.style = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify styles match + try testing.expect(t.screens.active.cursor.style.eql(t2.screens.active.cursor.style)); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + for (0..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } +} + +test "Screen vt with hyperlink" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set a hyperlink + try s.nextSlice("\x1b]8;;http://example.com\x1b\\hello"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: ScreenFormatter = .init(t.screens.active, .vt); + formatter.extra.hyperlink = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify hyperlinks match + const has_link1 = t.screens.active.cursor.hyperlink != null; + const has_link2 = t2.screens.active.cursor.hyperlink != null; + try testing.expectEqual(has_link1, has_link2); + + if (has_link1) { + const link1 = t.screens.active.cursor.hyperlink.?; + const link2 = t2.screens.active.cursor.hyperlink.?; + try testing.expectEqualStrings(link1.uri, link2.uri); + } + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + for (0..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } +} + +test "Screen vt with protection" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Enable protection mode + try s.nextSlice("\x1b[1\"qhello"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: ScreenFormatter = .init(t.screens.active, .vt); + formatter.extra.protection = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify protection state matches + try testing.expectEqual(t.screens.active.cursor.protected, t2.screens.active.cursor.protected); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + for (0..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } +} + +test "Screen vt with kitty keyboard" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set kitty keyboard flags (disambiguate + report_events = 3) + try s.nextSlice("\x1b[=3;1uhello"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: ScreenFormatter = .init(t.screens.active, .vt); + formatter.extra.kitty_keyboard = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify kitty keyboard state matches + const flags1 = t.screens.active.kitty_keyboard.current().int(); + const flags2 = t2.screens.active.kitty_keyboard.current().int(); + try testing.expectEqual(flags1, flags2); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + for (0..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } +} + +test "Screen vt with charsets" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set G0 to DEC special and shift to G1 + try s.nextSlice("\x1b(0\x0ehello"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: ScreenFormatter = .init(t.screens.active, .vt); + formatter.extra.charsets = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify charset state matches + try testing.expectEqual(t.screens.active.charset.gl, t2.screens.active.charset.gl); + try testing.expectEqual(t.screens.active.charset.gr, t2.screens.active.charset.gr); + try testing.expectEqual( + t.screens.active.charset.charsets.get(.G0), + t2.screens.active.charset.charsets.get(.G0), + ); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screens.active.pages.pages.first.?; + for (0..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } +} + +test "Terminal vt with scrolling region" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set scrolling region: top=5, bottom=20 + try s.nextSlice("\x1b[6;21rhello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.scrolling_region = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify scrolling regions match + try testing.expectEqual(t.scrolling_region.top, t2.scrolling_region.top); + try testing.expectEqual(t.scrolling_region.bottom, t2.scrolling_region.bottom); + try testing.expectEqual(t.scrolling_region.left, t2.scrolling_region.left); + try testing.expectEqual(t.scrolling_region.right, t2.scrolling_region.right); +} + +test "Terminal vt with modes" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Enable some modes that differ from defaults + try s.nextSlice("\x1b[?2004h"); // Bracketed paste + try s.nextSlice("\x1b[?1000h"); // Mouse event normal + try s.nextSlice("\x1b[?7l"); // Disable wraparound (default is true) + try s.nextSlice("hello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.modes = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify modes match + try testing.expectEqual(t.modes.get(.bracketed_paste), t2.modes.get(.bracketed_paste)); + try testing.expectEqual(t.modes.get(.mouse_event_normal), t2.modes.get(.mouse_event_normal)); + try testing.expectEqual(t.modes.get(.wraparound), t2.modes.get(.wraparound)); +} + +test "Terminal vt with tabstops" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Clear all tabs and set custom tabstops + try s.nextSlice("\x1b[3g"); // Clear all tabs + try s.nextSlice("\x1b[5G\x1bH"); // Set tab at column 5 + try s.nextSlice("\x1b[15G\x1bH"); // Set tab at column 15 + try s.nextSlice("\x1b[30G\x1bH"); // Set tab at column 30 + try s.nextSlice("hello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.tabstops = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify tabstops match (columns are 0-indexed in the API) + try testing.expectEqual(t.tabstops.get(4), t2.tabstops.get(4)); + try testing.expectEqual(t.tabstops.get(14), t2.tabstops.get(14)); + try testing.expectEqual(t.tabstops.get(29), t2.tabstops.get(29)); + try testing.expect(t2.tabstops.get(4)); // Column 5 (1-indexed) + try testing.expect(t2.tabstops.get(14)); // Column 15 (1-indexed) + try testing.expect(t2.tabstops.get(29)); // Column 30 (1-indexed) + try testing.expect(!t2.tabstops.get(8)); // Not a tab +} + +test "Terminal vt with keyboard modes" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set modify other keys mode 2 + try s.nextSlice("\x1b[>4;2m"); + try s.nextSlice("hello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.keyboard = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify keyboard mode matches + try testing.expectEqual(t.flags.modify_other_keys_2, t2.flags.modify_other_keys_2); + try testing.expect(t2.flags.modify_other_keys_2); +} + +test "Terminal vt with pwd" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set pwd using OSC 7 + try s.nextSlice("\x1b]7;file://host/home/user\x1b\\hello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.pwd = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify pwd matches + try testing.expectEqualStrings(t.pwd.items, t2.pwd.items); +} + +test "Page html with multiple styles" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set bold, then italic, then reset + try s.nextSlice("\x1b[1mbold\x1b[3mitalic\x1b[0mnormal"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + try testing.expectEqualStrings( + "
" ++ + "
bold
" ++ + "
italic
" ++ + "normal" ++ + "
", + output, + ); +} + +test "Page html plain text" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Plain text without styles should be wrapped in monospace div + try testing.expectEqualStrings( + "
hello, world
", + output, + ); +} + +test "Page html with colors" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set red foreground, blue background + try s.nextSlice("\x1b[31;44mcolored"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + try testing.expectEqualStrings( + "
" ++ + "
colored
" ++ + "
", + output, + ); +} + +test "TerminalFormatter html with palette" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Modify some palette colors + try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + try s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); + try s.nextSlice("test"); + + var formatter: TerminalFormatter = .init(&t, .{ .emit = .html }); + formatter.extra.palette = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Verify palette CSS variables are emitted + try testing.expect(std.mem.indexOf(u8, output, "") != null); + try testing.expect(std.mem.indexOf(u8, output, "test") != null); +} + +test "Page html with background and foreground colors" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ + .emit = .html, + .background = .{ .r = 0x12, .g = 0x34, .b = 0x56 }, + .foreground = .{ .r = 0xab, .g = 0xcd, .b = 0xef }, + }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + try testing.expectEqualStrings( + "
hello
", + output, + ); +} + +test "Page html with escaping" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("&\"'text"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + try testing.expectEqualStrings( + "
<tag>&"'text
", + output, + ); + + // Verify point map length matches output + try testing.expectEqual(output.len, point_map.items.len); + + // Opening wrapper div + const wrapper_start = "
"; + const wrapper_start_len = wrapper_start.len; + for (0..wrapper_start_len) |i| try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[i]); + + // Verify each character maps correctly, accounting for escaping + const offset = wrapper_start_len; + // < (4 bytes: <) -> x=0 + for (0..4) |i| try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[offset + i]); + // t (1 byte) -> x=1 + try testing.expectEqual(Coordinate{ .x = 1, .y = 0 }, point_map.items[offset + 4]); + // a (1 byte) -> x=2 + try testing.expectEqual(Coordinate{ .x = 2, .y = 0 }, point_map.items[offset + 5]); + // g (1 byte) -> x=3 + try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[offset + 6]); + // > (4 bytes: >) -> x=4 + for (0..4) |i| try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[offset + 7 + i]); + // & (5 bytes: &) -> x=5 + for (0..5) |i| try testing.expectEqual(Coordinate{ .x = 5, .y = 0 }, point_map.items[offset + 11 + i]); + // " (6 bytes: ") -> x=6 + for (0..6) |i| try testing.expectEqual(Coordinate{ .x = 6, .y = 0 }, point_map.items[offset + 16 + i]); + // ' (5 bytes: ') -> x=7 + for (0..5) |i| try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[offset + 22 + i]); + // t (1 byte) -> x=8 + try testing.expectEqual(Coordinate{ .x = 8, .y = 0 }, point_map.items[offset + 27]); + // e (1 byte) -> x=9 + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[offset + 28]); + // x (1 byte) -> x=10 + try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[offset + 29]); + // t (1 byte) -> x=11 + try testing.expectEqual(Coordinate{ .x = 11, .y = 0 }, point_map.items[offset + 30]); +} + +test "Page html with unicode as numeric entities" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Box drawing characters that caused issue #9426 + try s.nextSlice("╰─ ❯"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Expected: box drawing chars as numeric entities + // ╰ = U+2570 = 9584, ─ = U+2500 = 9472, ❯ = U+276F = 10095 + try testing.expectEqualStrings( + "
╰─ ❯
", + output, + ); +} + +test "Page html ascii characters unchanged" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // ASCII should be emitted directly + try testing.expectEqualStrings( + "
hello world
", + output, + ); +} + +test "Page html mixed ascii and unicode" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("test ╰─❯ ok"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Mix of ASCII and Unicode entities + try testing.expectEqualStrings( + "
test ╰─❯ ok
", + output, + ); +} + +test "Page VT with palette option emits RGB" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set a custom palette color and use it + try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + try s.nextSlice("\x1b[31mred"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + // Without palette option - should emit palette index + { + builder.clearRetainingCapacity(); + var formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred\x1b[0m", output); + } + + // With palette option - should emit RGB directly + { + builder.clearRetainingCapacity(); + var opts: Options = .vt; + opts.palette = &t.colors.palette.current; + var formatter: PageFormatter = .init(page, opts); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[38;2;171;205;239mred\x1b[0m", output); + } +} + +test "Page html with palette option emits RGB" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set a custom palette color and use it + try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + try s.nextSlice("\x1b[31mred"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + // Without palette option - should emit CSS variable + { + builder.clearRetainingCapacity(); + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings( + "
" ++ + "
red
" ++ + "
", + output, + ); + } + + // With palette option - should emit RGB directly + { + builder.clearRetainingCapacity(); + var opts: Options = .{ .emit = .html }; + opts.palette = &t.colors.palette.current; + var formatter: PageFormatter = .init(page, opts); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings( + "
" ++ + "
red
" ++ + "
", + output, + ); + } +} + +test "Page VT style reset properly closes styles" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set bold, then reset with SGR 0 + try s.nextSlice("\x1b[1mbold\x1b[0mnormal"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + builder.clearRetainingCapacity(); + var formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // The reset should properly close the bold style + try testing.expectEqualStrings("\x1b[0m\x1b[1mbold\x1b[0mnormal", output); +} + +test "Page codepoint_map single replacement" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + // Replace 'o' with 'x' + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ 'o', 'o' }, + .replacement = .{ .codepoint = 'x' }, + }); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hellx wxrld", output); + + // Verify point map - each output byte should map to original cell position + try testing.expectEqual(output.len, point_map.items.len); + // "hello world" -> "hellx wxrld" + // h e l l o w o r l d + // 0 1 2 3 4 5 6 7 8 9 10 + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // h + try testing.expectEqual(Coordinate{ .x = 1, .y = 0 }, point_map.items[1]); // e + try testing.expectEqual(Coordinate{ .x = 2, .y = 0 }, point_map.items[2]); // l + try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[3]); // l + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[4]); // x (was o) + try testing.expectEqual(Coordinate{ .x = 5, .y = 0 }, point_map.items[5]); // space + try testing.expectEqual(Coordinate{ .x = 6, .y = 0 }, point_map.items[6]); // w + try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[7]); // x (was o) + try testing.expectEqual(Coordinate{ .x = 8, .y = 0 }, point_map.items[8]); // r + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[9]); // l + try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[10]); // d +} + +test "Page codepoint_map conflicting replacement prefers last" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + // Replace 'o' with 'x', then with 'y' - should prefer last + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ 'o', 'o' }, + .replacement = .{ .codepoint = 'x' }, + }); + try map.append(alloc, .{ + .range = .{ 'o', 'o' }, + .replacement = .{ .codepoint = 'y' }, + }); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("helly", output); +} + +test "Page codepoint_map replace with string" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + // Replace 'o' with a multi-byte string + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ 'o', 'o' }, + .replacement = .{ .string = "XYZ" }, + }); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hellXYZ", output); + + // Verify point map - string replacements should all map to the original cell + try testing.expectEqual(output.len, point_map.items.len); + // "hello" -> "hellXYZ" + // h e l l o + // 0 1 2 3 4 + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // h + try testing.expectEqual(Coordinate{ .x = 1, .y = 0 }, point_map.items[1]); // e + try testing.expectEqual(Coordinate{ .x = 2, .y = 0 }, point_map.items[2]); // l + try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[3]); // l + // All bytes of the replacement string "XYZ" should point to position 4 (where 'o' was) + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[4]); // X + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // Y + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // Z +} + +test "Page codepoint_map range replacement" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("abcdefg"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + // Replace 'b' through 'e' with 'X' + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ 'b', 'e' }, + .replacement = .{ .codepoint = 'X' }, + }); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("aXXXXfg", output); +} + +test "Page codepoint_map multiple ranges" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + // Replace 'a'-'m' with 'A' and 'n'-'z' with 'Z' + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ 'a', 'm' }, + .replacement = .{ .codepoint = 'A' }, + }); + try map.append(alloc, .{ + .range = .{ 'n', 'z' }, + .replacement = .{ .codepoint = 'Z' }, + }); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + // h e l l o w o r l d + // A A A A Z Z Z Z A A + try testing.expectEqualStrings("AAAAZ ZZZAA", output); +} + +test "Page codepoint_map unicode replacement" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello ⚡ world"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + // Replace lightning bolt with fire emoji + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ '⚡', '⚡' }, + .replacement = .{ .string = "🔥" }, + }); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello 🔥 world", output); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + // "hello ⚡ world" + // h e l l o ⚡ w o r l d + // 0 1 2 3 4 5 6 8 9 10 11 12 + // Note: ⚡ is a wide character occupying cells 6-7 + for (0..6) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + // 🔥 is 4 UTF-8 bytes, all should map to cell 6 (where ⚡ was) + const fire_start = 6; // "hello " is 6 bytes + for (0..4) |i| try testing.expectEqual( + Coordinate{ .x = 6, .y = 0 }, + point_map.items[fire_start + i], + ); + // " world" follows + const world_start = fire_start + 4; + for (0..6) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(8 + i), .y = 0 }, + point_map.items[world_start + i], + ); +} + +test "Page codepoint_map with styled formats" { + 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 = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[31mred text\x1b[0m"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + // Replace 'e' with 'X' in styled text + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ 'e', 'e' }, + .replacement = .{ .codepoint = 'X' }, + }); + + var opts: Options = .vt; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + // Should preserve styles while replacing text + // "red text" becomes "rXd tXxt" + // VT format uses \x1b[38;5;1m for palette color 1 + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mrXd tXxt\x1b[0m", output); +} + +test "Page codepoint_map empty map" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + // Empty map should not change anything + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello world", output); +} diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index 23b10950e..96dfcfdf3 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; @@ -856,13 +855,17 @@ fn HashMapUnmanaged( pub fn layoutForCapacity(new_capacity: Size) Layout { assert(new_capacity == 0 or std.math.isPowerOfTwo(new_capacity)); + // Cast to usize to prevent overflow in size calculations. + // See: https://github.com/ziglang/zig/pull/19048 + const cap: usize = new_capacity; + // Pack our metadata, keys, and values. const meta_start = @sizeOf(Header); - const meta_end = @sizeOf(Header) + new_capacity * @sizeOf(Metadata); + const meta_end = @sizeOf(Header) + cap * @sizeOf(Metadata); const keys_start = std.mem.alignForward(usize, meta_end, key_align); - const keys_end = keys_start + new_capacity * @sizeOf(K); + const keys_end = keys_start + cap * @sizeOf(K); const vals_start = std.mem.alignForward(usize, keys_end, val_align); - const vals_end = vals_start + new_capacity * @sizeOf(V); + const vals_end = vals_start + cap * @sizeOf(V); // Our total memory size required is the end of our values // aligned to the base required alignment. @@ -1512,3 +1515,26 @@ test "OffsetHashMap remake map" { try expectEqual(5, map.get(5).?); } } + +test "layoutForCapacity no overflow for large capacity" { + // Test that layoutForCapacity correctly handles large capacities without overflow. + // Prior to the fix, new_capacity (u32) was multiplied before widening to usize, + // causing overflow when new_capacity * @sizeOf(K) exceeded 2^32. + // See: https://github.com/ghostty-org/ghostty/issues/9862 + const Map = AutoHashMapUnmanaged(u64, u64); + + // Use 2^30 capacity - this would overflow in u32 when multiplied by @sizeOf(u64)=8 + // 0x40000000 * 8 = 0x2_0000_0000 which wraps to 0 in u32 + const large_cap: Map.Size = 1 << 30; + const layout = Map.layoutForCapacity(large_cap); + + // With the fix, total_size should be at least cap * (sizeof(K) + sizeof(V)) + // = 2^30 * 16 = 2^34 bytes = 16 GiB + // Without the fix, this would wrap and produce a much smaller value. + const min_expected: usize = @as(usize, large_cap) * (@sizeOf(u64) + @sizeOf(u64)); + try expect(layout.total_size >= min_expected); + + // Also verify the individual offsets don't wrap + try expect(layout.keys_start > 0); + try expect(layout.vals_start > layout.keys_start); +} 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 099002f39..deeabcfb7 100644 --- a/src/terminal/kitty/color.zig +++ b/src/terminal/kitty/color.zig @@ -16,6 +16,13 @@ pub const OSC = struct { /// We must reply with the same string terminator (ST) as used in the /// request. terminator: Terminator = .st, + + /// We don't currently support encoding this to C in any way. + pub const C = void; + + pub fn cval(_: OSC) C { + return {}; + } }; pub const Special = enum { 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 f917c104a..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"); @@ -30,7 +28,7 @@ pub fn execute( // If storage is disabled then we disable the full protocol. This means // we don't even respond to queries so the terminal completely acts as // if this feature is not supported. - if (!terminal.screen.kitty_images.enabled()) { + if (!terminal.screens.active.kitty_images.enabled()) { log.debug("kitty graphics requested but disabled", .{}); return null; } @@ -55,7 +53,7 @@ pub fn execute( // The `q` setting inherits the value from the starting command // unless `q` is set >= 1 on this command. If it is, then we save // that as the new `q` setting. - const storage = &terminal.screen.kitty_images; + const storage = &terminal.screens.active.kitty_images; if (storage.loading) |loading| switch (cmd.quiet) { // q=0 we use whatever the start command value is .no => quiet = loading.quiet, @@ -196,7 +194,7 @@ fn display( }; // Verify the requested image exists if we have an ID - const storage = &terminal.screen.kitty_images; + const storage = &terminal.screens.active.kitty_images; const img_: ?Image = if (d.image_id != 0) storage.imageById(d.image_id) else @@ -223,8 +221,8 @@ fn display( // Track a new pin for our cursor. The cursor is always tracked but we // don't want this one to move with the cursor. - const pin = terminal.screen.pages.trackPin( - terminal.screen.cursor.page_pin.*, + const pin = terminal.screens.active.pages.trackPin( + terminal.screens.active.cursor.page_pin.*, ) catch |err| { log.warn("failed to create pin for Kitty graphics err={}", .{err}); result.message = "EINVAL: failed to prepare terminal state"; @@ -252,7 +250,7 @@ fn display( result.placement_id, p, ) catch |err| { - p.deinit(&terminal.screen); + p.deinit(terminal.screens.active); encodeError(&result, err); return result; }; @@ -271,7 +269,7 @@ fn display( }; terminal.setCursorPos( - terminal.screen.cursor.y, + terminal.screens.active.cursor.y, pin.x + size.cols + 1, ); }, @@ -287,7 +285,7 @@ fn delete( terminal: *Terminal, cmd: *const Command, ) Response { - const storage = &terminal.screen.kitty_images; + const storage = &terminal.screens.active.kitty_images; storage.delete(alloc, terminal, cmd.control.delete); // Delete never responds on success @@ -304,7 +302,7 @@ fn loadAndAddImage( display: ?command.Display = null, } { const t = cmd.transmission().?; - const storage = &terminal.screen.kitty_images; + const storage = &terminal.screens.active.kitty_images; // Determine our image. This also handles chunking and early exit. var loading: LoadingImage = if (storage.loading) |loading| loading: { @@ -496,7 +494,7 @@ test "kittygfx default format is rgba" { const resp = execute(alloc, &t, &cmd).?; try testing.expect(resp.ok()); - const storage = &t.screen.kitty_images; + const storage = &t.screens.active.kitty_images; const img = storage.imageById(1).?; try testing.expectEqual(command.Transmission.Format.rgba, img.format); } diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index 268f71601..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"); @@ -433,6 +432,7 @@ pub const LoadingImage = struct { ) catch |err| switch (err) { error.WuffsError => return error.InvalidData, error.OutOfMemory => return error.OutOfMemory, + error.Overflow => return error.InvalidData, }; defer alloc.free(result.data); 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 8aef0ece5..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; @@ -232,7 +232,7 @@ pub const ImageStorage = struct { // Deinit the placement and remove it const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(&t.screen); + entry.value_ptr.deinit(t.screens.active); self.placements.removeByPtr(entry.key_ptr); if (delete_images) self.deleteIfUnused(alloc, image_id); } @@ -247,7 +247,7 @@ pub const ImageStorage = struct { .id => |v| self.deleteById( alloc, - &t.screen, + t.screens.active, v.image_id, v.placement_id, v.delete, @@ -257,7 +257,7 @@ pub const ImageStorage = struct { const img = self.imageByNumber(v.image_number) orelse break :newest; self.deleteById( alloc, - &t.screen, + t.screens.active, img.id, v.placement_id, v.delete, @@ -269,8 +269,8 @@ pub const ImageStorage = struct { alloc, t, .{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } }, delete_images, {}, @@ -332,7 +332,7 @@ pub const ImageStorage = struct { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t) orelse continue; if (rect.top_left.x <= x and rect.bottom_right.x >= x) { - entry.value_ptr.deinit(&t.screen); + entry.value_ptr.deinit(t.screens.active); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -350,7 +350,7 @@ pub const ImageStorage = struct { // v.y is in active coords so we want to convert it to a pin // so we can compare by page offsets. - const target_pin = t.screen.pages.pin(.{ .active = .{ + const target_pin = t.screens.active.pages.pin(.{ .active = .{ .y = std.math.cast(size.CellCountInt, v.y - 1) orelse break :row, } }) orelse break :row; @@ -364,7 +364,7 @@ pub const ImageStorage = struct { var target_pin_copy = target_pin; target_pin_copy.x = rect.top_left.x; if (target_pin_copy.isBetween(rect.top_left, rect.bottom_right)) { - entry.value_ptr.deinit(&t.screen); + entry.value_ptr.deinit(t.screens.active); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -387,7 +387,7 @@ pub const ImageStorage = struct { if (entry.value_ptr.z == v.z) { const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(&t.screen); + entry.value_ptr.deinit(t.screens.active); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, image_id); } @@ -411,7 +411,7 @@ pub const ImageStorage = struct { while (it.next()) |entry| { if (entry.key_ptr.image_id >= v.first or entry.key_ptr.image_id <= v.last) { const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(&t.screen); + entry.value_ptr.deinit(t.screens.active); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, image_id); } @@ -490,7 +490,7 @@ pub const ImageStorage = struct { comptime filter: ?fn (@TypeOf(filter_ctx), Placement) bool, ) void { // Convert our target point to a pin for comparison. - const target_pin = t.screen.pages.pin(p) orelse return; + const target_pin = t.screens.active.pages.pin(p) orelse return; var it = self.placements.iterator(); while (it.next()) |entry| { @@ -498,7 +498,7 @@ pub const ImageStorage = struct { const rect = entry.value_ptr.rect(img, t) orelse continue; if (target_pin.isBetween(rect.top_left, rect.bottom_right)) { if (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; - entry.value_ptr.deinit(&t.screen); + entry.value_ptr.deinit(t.screens.active); self.placements.removeByPtr(entry.key_ptr); if (delete_unused) self.deleteIfUnused(alloc, img.id); } @@ -811,7 +811,7 @@ fn trackPin( t: *terminal.Terminal, pt: point.Coordinate, ) !*PageList.Pin { - return try t.screen.pages.trackPin(t.screen.pages.pin(.{ + return try t.screens.active.pages.trackPin(t.screens.active.pages.pin(.{ .active = pt, }).?); } @@ -825,7 +825,7 @@ test "storage: add placement with zero placement id" { t.height_px = 100; var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 0, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); @@ -850,10 +850,10 @@ test "storage: delete all placements and images" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -865,7 +865,7 @@ test "storage: delete all placements and images" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete all placements and images preserves limit" { @@ -873,10 +873,10 @@ test "storage: delete all placements and images preserves limit" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); s.total_limit = 5000; try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); @@ -890,7 +890,7 @@ test "storage: delete all placements and images preserves limit" { try testing.expectEqual(@as(usize, 0), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 5000), s.total_limit); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete all placements" { @@ -898,10 +898,10 @@ test "storage: delete all placements" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -913,7 +913,7 @@ test "storage: delete all placements" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete all placements by image id" { @@ -921,10 +921,10 @@ test "storage: delete all placements by image id" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -936,7 +936,7 @@ test "storage: delete all placements by image id" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); } test "storage: delete all placements by image id and unused images" { @@ -944,10 +944,10 @@ test "storage: delete all placements by image id and unused images" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -959,7 +959,7 @@ test "storage: delete all placements by image id and unused images" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); } test "storage: delete placement by specific id" { @@ -967,10 +967,10 @@ test "storage: delete placement by specific id" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -987,7 +987,7 @@ test "storage: delete placement by specific id" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 2), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); - try testing.expectEqual(tracked + 2, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 2, t.screens.active.pages.countTrackedPins()); } test "storage: delete intersecting cursor" { @@ -997,23 +997,23 @@ test "storage: delete intersecting cursor" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); - t.screen.cursorAbsolute(12, 12); + t.screens.active.cursorAbsolute(12, 12); s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = false }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -1029,23 +1029,23 @@ test "storage: delete intersecting cursor plus unused" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); - t.screen.cursorAbsolute(12, 12); + t.screens.active.cursorAbsolute(12, 12); s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = true }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -1061,23 +1061,23 @@ test "storage: delete intersecting cursor hits multiple" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); - t.screen.cursorAbsolute(26, 26); + t.screens.active.cursorAbsolute(26, 26); s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = true }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 1), s.images.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete by column" { @@ -1087,10 +1087,10 @@ test "storage: delete by column" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); @@ -1104,7 +1104,7 @@ test "storage: delete by column" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -1122,7 +1122,7 @@ test "storage: delete by column 1x1" { t.height_px = 100; var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 0 }) } }); @@ -1153,10 +1153,10 @@ test "storage: delete by row" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); @@ -1170,7 +1170,7 @@ test "storage: delete by row" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -1188,7 +1188,7 @@ test "storage: delete by row 1x1" { t.height_px = 100; var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 0 }) } }); try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 1 }) } }); @@ -1217,10 +1217,10 @@ test "storage: delete images by range 1" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -1234,7 +1234,7 @@ test "storage: delete images by range 1" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 3), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete images by range 2" { @@ -1242,10 +1242,10 @@ test "storage: delete images by range 2" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -1259,7 +1259,7 @@ test "storage: delete images by range 2" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete images by range 3" { @@ -1267,10 +1267,10 @@ test "storage: delete images by range 3" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -1284,7 +1284,7 @@ test "storage: delete images by range 3" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 3), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete images by range 4" { @@ -1292,10 +1292,10 @@ test "storage: delete images by range 4" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -1309,7 +1309,7 @@ test "storage: delete images by range 4" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: aspect ratio calculation when only columns or rows specified" { diff --git a/src/terminal/kitty/graphics_unicode.zig b/src/terminal/kitty/graphics_unicode.zig index a4a25e751..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; @@ -893,7 +893,7 @@ test "unicode placement: none" { try t.printString("hello\nworld\n1\n2"); // No placements - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); var it = placementIterator(pin, null); try testing.expect(it.next() == null); } @@ -908,7 +908,7 @@ test "unicode placement: single row/col" { try t.printString("\u{10EEEE}\u{0305}\u{0305}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -933,7 +933,7 @@ test "unicode placement: continuation break" { try t.printString("\u{10EEEE}\u{0305}\u{030E}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -968,7 +968,7 @@ test "unicode placement: continuation with diacritics set" { try t.printString("\u{10EEEE}\u{0305}\u{030E}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -995,7 +995,7 @@ test "unicode placement: continuation with no col" { try t.printString("\u{10EEEE}\u{0305}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1022,7 +1022,7 @@ test "unicode placement: continuation with no diacritics" { try t.printString("\u{10EEEE}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1049,7 +1049,7 @@ test "unicode placement: run ending" { try t.printString("ABC"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1076,7 +1076,7 @@ test "unicode placement: run starting in the middle" { try t.printString("\u{10EEEE}\u{0305}\u{030D}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1102,7 +1102,7 @@ test "unicode placement: specifying image id as palette" { try t.printString("\u{10EEEE}\u{0305}\u{0305}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1127,7 +1127,7 @@ test "unicode placement: specifying image id with high bits" { try t.printString("\u{10EEEE}\u{0305}\u{0305}\u{030E}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1153,7 +1153,7 @@ test "unicode placement: specifying placement id as palette" { try t.printString("\u{10EEEE}\u{0305}\u{0305}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1180,7 +1180,7 @@ test "unicode render placement: dog 4x2" { var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); defer t.deinit(alloc); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); const image: Image = .{ .id = 1, .width = 500, .height = 306 }; try s.addImage(alloc, image); @@ -1193,7 +1193,7 @@ test "unicode render placement: dog 4x2" { // Row 1 { const p: Placement = .{ - .pin = t.screen.cursor.page_pin.*, + .pin = t.screens.active.cursor.page_pin.*, .image_id = 1, .placement_id = 0, .col = 0, @@ -1214,7 +1214,7 @@ test "unicode render placement: dog 4x2" { // Row 2 { const p: Placement = .{ - .pin = t.screen.cursor.page_pin.*, + .pin = t.screens.active.cursor.page_pin.*, .image_id = 1, .placement_id = 0, .col = 0, @@ -1247,7 +1247,7 @@ test "unicode render placement: dog 2x2 with blank cells" { var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); defer t.deinit(alloc); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); const image: Image = .{ .id = 1, .width = 500, .height = 306 }; try s.addImage(alloc, image); @@ -1260,7 +1260,7 @@ test "unicode render placement: dog 2x2 with blank cells" { // Row 1 { const p: Placement = .{ - .pin = t.screen.cursor.page_pin.*, + .pin = t.screens.active.cursor.page_pin.*, .image_id = 1, .placement_id = 0, .col = 0, @@ -1281,7 +1281,7 @@ test "unicode render placement: dog 2x2 with blank cells" { // Row 2 { const p: Placement = .{ - .pin = t.screen.cursor.page_pin.*, + .pin = t.screens.active.cursor.page_pin.*, .image_id = 1, .placement_id = 0, .col = 0, @@ -1313,7 +1313,7 @@ test "unicode render placement: dog 1x1" { var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); defer t.deinit(alloc); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screens.active); const image: Image = .{ .id = 1, .width = 500, .height = 306 }; try s.addImage(alloc, image); @@ -1326,7 +1326,7 @@ test "unicode render placement: dog 1x1" { // Row 1 { const p: Placement = .{ - .pin = t.screen.cursor.page_pin.*, + .pin = t.screens.active.cursor.page_pin.*, .image_id = 1, .placement_id = 0, .col = 0, diff --git a/src/terminal/kitty/key.zig b/src/terminal/kitty/key.zig index 0883c90f2..8594c4c39 100644 --- a/src/terminal/kitty/key.zig +++ b/src/terminal/kitty/key.zig @@ -8,7 +8,7 @@ const std = @import("std"); pub const FlagStack = struct { const len = 8; - flags: [len]Flags = @splat(.{}), + flags: [len]Flags = @splat(.disabled), idx: u3 = 0, /// Return the current stack value @@ -51,12 +51,12 @@ pub const FlagStack = struct { // could send a huge number of pop commands to waste cpu. if (n >= self.flags.len) { self.idx = 0; - self.flags = @splat(.{}); + self.flags = @splat(.disabled); return; } for (0..n) |_| { - self.flags[self.idx] = .{}; + self.flags[self.idx] = .disabled; self.idx -%= 1; } } @@ -83,6 +83,15 @@ pub const Flags = packed struct(u5) { report_all: bool = false, report_associated: bool = false, + /// Kitty keyboard protocol disabled (all flags off). + pub const disabled: Flags = .{ + .disambiguate = false, + .report_events = false, + .report_alternates = false, + .report_all = false, + .report_associated = false, + }; + /// Sets all modes on. pub const @"true": Flags = .{ .disambiguate = true, diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 6875ba89d..06c930014 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -1,12 +1,9 @@ -const builtin = @import("builtin"); - const charsets = @import("charsets.zig"); -const sanitize = @import("sanitize.zig"); const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); -const hyperlink = @import("hyperlink.zig"); -const sgr = @import("sgr.zig"); +const render = @import("render.zig"); +const stream_readonly = @import("stream_readonly.zig"); const style = @import("style.zig"); pub const apc = @import("apc.zig"); pub const dcs = @import("dcs.zig"); @@ -14,11 +11,14 @@ pub const osc = @import("osc.zig"); 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"); pub const parse_table = @import("parse_table.zig"); pub const search = @import("search.zig"); +pub const sgr = @import("sgr.zig"); pub const size = @import("size.zig"); pub const tmux = if (options.tmux_control_mode) @import("tmux.zig") else struct {}; pub const x11_color = @import("x11_color.zig"); @@ -26,6 +26,7 @@ pub const x11_color = @import("x11_color.zig"); pub const Charset = charsets.Charset; pub const CharsetSlot = charsets.Slots; pub const CharsetActiveSlot = charsets.ActiveSlot; +pub const charsetTable = charsets.table; pub const Cell = page.Cell; pub const Coordinate = point.Coordinate; pub const CSI = Parser.Action.CSI; @@ -36,14 +37,19 @@ pub const PageList = @import("PageList.zig"); pub const Parser = @import("Parser.zig"); 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 ScreenType = Terminal.ScreenType; +pub const ScreenSet = @import("ScreenSet.zig"); +pub const Scrollbar = PageList.Scrollbar; pub const Selection = @import("Selection.zig"); pub const SizeReportStyle = csi.SizeReportStyle; pub const StringMap = @import("StringMap.zig"); pub const Style = style.Style; pub const Terminal = @import("Terminal.zig"); pub const Stream = stream.Stream; +pub const StreamAction = stream.Action; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; @@ -59,8 +65,6 @@ pub const EraseLine = csi.EraseLine; pub const TabClear = csi.TabClear; pub const Attribute = sgr.Attribute; -pub const isSafePaste = sanitize.isSafePaste; - pub const Options = @import("build_options.zig").Options; pub const options = @import("terminal_options"); diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index 9a74db73c..13b7c1eac 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -43,7 +43,7 @@ pub const ModeState = struct { } /// Get the value of a mode. - pub fn get(self: *ModeState, mode: Mode) bool { + pub fn get(self: *const ModeState, mode: Mode) bool { switch (mode) { inline else => |mode_comptime| { const entry = comptime entryForMode(mode_comptime); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 897a5ef0f..f62b7a6cd 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -9,12 +9,13 @@ 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; const kitty_color = @import("kitty/color.zig"); const osc_color = @import("osc/color.zig"); +const string_encoding = @import("../os/string_encoding.zig"); pub const color = osc_color; const log = std.log.scoped(.osc); @@ -44,19 +45,33 @@ pub const Command = union(Key) { /// Subsequent text (until a OSC "133;B" or OSC "133;I" command) is a /// prompt string (as if followed by OSC 133;P;k=i\007). Note: I've noticed /// not all shells will send the prompt end code. - /// - /// "aid" is an optional "application identifier" that helps disambiguate - /// nested shell sessions. It can be anything but is usually a process ID. - /// - /// "kind" tells us which kind of semantic prompt sequence this is: - /// - primary: normal, left-aligned first-line prompt (initial, default) - /// - continuation: an editable continuation line - /// - secondary: a non-editable continuation line - /// - right: a right-aligned prompt that may need adjustment during reflow prompt_start: struct { + /// "aid" is an optional "application identifier" that helps disambiguate + /// nested shell sessions. It can be anything but is usually a process ID. aid: ?[:0]const u8 = null, + /// "kind" tells us which kind of semantic prompt sequence this is: + /// - primary: normal, left-aligned first-line prompt (initial, default) + /// - continuation: an editable continuation line + /// - secondary: a non-editable continuation line + /// - right: a right-aligned prompt that may need adjustment during reflow kind: enum { primary, continuation, secondary, right } = .primary, + /// If true, the shell will not redraw the prompt on resize so don't erase it. + /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers redraw: bool = true, + /// Use a special key instead of arrow keys to move the cursor on + /// mouse click. Useful if arrow keys have side-effets like triggering + /// auto-complete. The shell integration script should bind the special + /// key as needed. + /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + special_key: bool = false, + /// If true, the shell is capable of handling mouse click events. + /// Ghostty will then send a click event to the shell when the user + /// clicks somewhere in the prompt. The shell can then move the cursor + /// to that position or perform some other appropriate action. If false, + /// Ghostty may generate a number of fake key events to move the cursor + /// which is not very robust. + /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + click_events: bool = false, }, /// End of prompt and start of user input, terminated by a OSC "133;C" @@ -72,7 +87,11 @@ pub const Command = union(Key) { /// OSC "133;I" then this is the start of a continuation input line. /// If we see anything else, it is the start of the output area (or end /// of command). - end_of_input: void, + end_of_input: struct { + /// The command line that the user entered. + /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + cmdline: ?[:0]const u8 = null, + }, /// End of current command. /// @@ -252,6 +271,8 @@ pub const Terminator = enum { /// Some applications and terminals use BELL (0x07) as the string terminator. bel, + pub const C = LibEnum(.c, &.{ "st", "bel" }); + /// Initialize the terminator based on the last byte seen. If the /// last byte is a BEL then we use BEL, otherwise we just assume ST. pub fn init(ch: ?u8) Terminator { @@ -270,6 +291,13 @@ pub const Terminator = enum { }; } + pub fn cval(self: Terminator) C { + return switch (self) { + .st => .st, + .bel => .bel, + }; + } + pub fn format( self: Terminator, comptime _: []const u8, @@ -425,9 +453,9 @@ pub const Parser = struct { conemu_guimacro, }; - pub fn init() Parser { + pub fn init(alloc: ?Allocator) Parser { var result: Parser = .{ - .alloc = null, + .alloc = alloc, .state = .empty, .command = .invalid, .buf_start = 0, @@ -450,12 +478,6 @@ pub const Parser = struct { return result; } - pub fn initAlloc(alloc: Allocator) Parser { - var result: Parser = .init(); - result.alloc = alloc; - return result; - } - /// This must be called to clean up any allocated memory. pub fn deinit(self: *Parser) void { self.reset(); @@ -502,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={}", @@ -1026,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; @@ -1286,7 +1310,7 @@ pub const Parser = struct { 'C' => { self.state = .semantic_option_start; - self.command = .{ .end_of_input = {} }; + self.command = .{ .end_of_input = .{} }; self.complete = true; }, @@ -1456,11 +1480,20 @@ pub const Parser = struct { .prompt_start => |*v| v.aid = value, else => {}, } + } else if (mem.eql(u8, self.temp_state.key, "cmdline")) { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + switch (self.command) { + .end_of_input => |*v| v.cmdline = string_encoding.printfQDecode(value) catch null, + else => {}, + } + } else if (mem.eql(u8, self.temp_state.key, "cmdline_url")) { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + switch (self.command) { + .end_of_input => |*v| v.cmdline = string_encoding.urlPercentDecode(value) catch null, + else => {}, + } } else if (mem.eql(u8, self.temp_state.key, "redraw")) { - // Kitty supports a "redraw" option for prompt_start. I can't find - // this documented anywhere but can see in the code that this is used - // by shell environments to tell the terminal that the shell will NOT - // redraw the prompt so we should attempt to resize it. + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers switch (self.command) { .prompt_start => |*v| { const valid = if (value.len == 1) valid: { @@ -1479,7 +1512,48 @@ pub const Parser = struct { }, else => {}, } + } else if (mem.eql(u8, self.temp_state.key, "special_key")) { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + switch (self.command) { + .prompt_start => |*v| { + const valid = if (value.len == 1) valid: { + switch (value[0]) { + '0' => v.special_key = false, + '1' => v.special_key = true, + else => break :valid false, + } + + break :valid true; + } else false; + + if (!valid) { + log.info("OSC 133 A invalid special_key value: {s}", .{value}); + } + }, + else => {}, + } + } else if (mem.eql(u8, self.temp_state.key, "click_events")) { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + switch (self.command) { + .prompt_start => |*v| { + const valid = if (value.len == 1) valid: { + switch (value[0]) { + '0' => v.click_events = false, + '1' => v.click_events = true, + else => break :valid false, + } + + break :valid true; + } else false; + + if (!valid) { + log.info("OSC 133 A invalid click_events value: {s}", .{value}); + } + }, + else => {}, + } } else if (mem.eql(u8, self.temp_state.key, "k")) { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers // The "k" marks the kind of prompt, or "primary" if we don't know. // This can be used to distinguish between the first (initial) prompt, // a continuation, etc. @@ -1529,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; }; @@ -1548,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; @@ -1559,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; }; @@ -1579,6 +1658,7 @@ pub const Parser = struct { }, }, }) catch |err| { + @branchHint(.cold); log.warn("unable to append kitty color protocol option: {}", .{err}); return; }; @@ -1609,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; @@ -1691,10 +1772,10 @@ test { _ = osc_color; } -test "OSC: change_window_title" { +test "OSC 0: change_window_title" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); p.next('0'); p.next(';'); p.next('a'); @@ -1704,10 +1785,65 @@ test "OSC: change_window_title" { try testing.expectEqualStrings("ab", cmd.change_window_title); } -test "OSC: change_window_title with 2" { +test "OSC 0: longer than buffer" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); + + const input = "0;" ++ "a" ** (Parser.MAX_BUF + 2); + for (input) |ch| p.next(ch); + + try testing.expect(p.end(null) == null); + try testing.expect(p.complete == false); +} + +test "OSC 0: one shorter than buffer length" { + const testing = std.testing; + + var p: Parser = .init(null); + + const prefix = "0;"; + const title = "a" ** (Parser.MAX_BUF - prefix.len - 1); + const input = prefix ++ title; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings(title, cmd.change_window_title); +} + +test "OSC 0: exactly at buffer length" { + const testing = std.testing; + + var p: Parser = .init(null); + + const prefix = "0;"; + const title = "a" ** (Parser.MAX_BUF - prefix.len); + const input = prefix ++ title; + for (input) |ch| p.next(ch); + + // This should be null because we always reserve space for a null terminator. + try testing.expect(p.end(null) == null); + try testing.expect(p.complete == false); +} + +test "OSC 1: change_window_icon" { + const testing = std.testing; + + var p: Parser = .init(null); + p.next('1'); + p.next(';'); + p.next('a'); + p.next('b'); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .change_window_icon); + try testing.expectEqualStrings("ab", cmd.change_window_icon); +} + +test "OSC 2: change_window_title with 2" { + const testing = std.testing; + + var p: Parser = .init(null); p.next('2'); p.next(';'); p.next('a'); @@ -1717,10 +1853,10 @@ test "OSC: change_window_title with 2" { try testing.expectEqualStrings("ab", cmd.change_window_title); } -test "OSC: change_window_title with utf8" { +test "OSC 2: change_window_title with utf8" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); p.next('2'); p.next(';'); // '—' EM DASH U+2014 (E2 80 94) @@ -1739,10 +1875,10 @@ test "OSC: change_window_title with utf8" { try testing.expectEqualStrings("— ‐", cmd.change_window_title); } -test "OSC: change_window_title empty" { +test "OSC 2: change_window_title empty" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); p.next('2'); p.next(';'); const cmd = p.end(null).?.*; @@ -1750,210 +1886,26 @@ test "OSC: change_window_title empty" { try testing.expectEqualStrings("", cmd.change_window_title); } -test "OSC: change_window_icon" { +test "OSC 4: empty param" { const testing = std.testing; - var p: Parser = .init(); - p.next('1'); - p.next(';'); - p.next('a'); - p.next('b'); - const cmd = p.end(null).?.*; - try testing.expect(cmd == .change_window_icon); - try testing.expectEqualStrings("ab", cmd.change_window_icon); -} + var p: Parser = .init(null); -test "OSC: prompt_start" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;A"; + const input = "4;;"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.aid == null); - try testing.expect(cmd.prompt_start.redraw); + const cmd = p.end('\x1b'); + try testing.expect(cmd == null); } -test "OSC: prompt_start with single option" { +// See src/terminal/osc/color.zig for more OSC 4 tests. + +// See src/terminal/osc/color.zig for OSC 5 tests. + +test "OSC 7: report pwd" { const testing = std.testing; - var p: Parser = .init(); - - const input = "133;A;aid=14"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); -} - -test "OSC: prompt_start with redraw disabled" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;A;redraw=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(!cmd.prompt_start.redraw); -} - -test "OSC: prompt_start with redraw invalid value" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;A;redraw=42"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.redraw); - try testing.expect(cmd.prompt_start.kind == .primary); -} - -test "OSC: prompt_start with continuation" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;A;k=c"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.kind == .continuation); -} - -test "OSC: prompt_start with secondary" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;A;k=s"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.kind == .secondary); -} - -test "OSC: end_of_command no exit code" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;D"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_command); -} - -test "OSC: end_of_command with exit code" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;D;25"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_command); - try testing.expectEqual(@as(u8, 25), cmd.end_of_command.exit_code.?); -} - -test "OSC: prompt_end" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;B"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_end); -} - -test "OSC: end_of_input" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;C"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); -} - -test "OSC: get/set clipboard" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "52;s;?"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 's'); - try testing.expectEqualStrings("?", cmd.clipboard_contents.data); -} - -test "OSC: get/set clipboard (optional parameter)" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "52;;?"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 'c'); - try testing.expectEqualStrings("?", cmd.clipboard_contents.data); -} - -test "OSC: get/set clipboard with allocator" { - const testing = std.testing; - - var p: Parser = .initAlloc(testing.allocator); - defer p.deinit(); - - const input = "52;s;?"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 's'); - try testing.expectEqualStrings("?", cmd.clipboard_contents.data); -} - -test "OSC: clear clipboard" { - const testing = std.testing; - - var p: Parser = .init(); - defer p.deinit(); - - const input = "52;;"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 'c'); - try testing.expectEqualStrings("", cmd.clipboard_contents.data); -} - -test "OSC: report pwd" { - const testing = std.testing; - - var p: Parser = .init(); + var p: Parser = .init(null); const input = "7;file:///tmp/example"; for (input) |ch| p.next(ch); @@ -1963,10 +1915,10 @@ test "OSC: report pwd" { try testing.expectEqualStrings("file:///tmp/example", cmd.report_pwd.value); } -test "OSC: report pwd empty" { +test "OSC 7: report pwd empty" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "7;"; for (input) |ch| p.next(ch); @@ -1975,613 +1927,10 @@ test "OSC: report pwd empty" { try testing.expectEqualStrings("", cmd.report_pwd.value); } -test "OSC: pointer cursor" { +test "OSC 8: hyperlink" { const testing = std.testing; - var p: Parser = .init(); - - const input = "22;pointer"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .mouse_shape); - try testing.expectEqualStrings("pointer", cmd.mouse_shape.value); -} - -test "OSC: longer than buffer" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "0;" ++ "a" ** (Parser.MAX_BUF + 2); - for (input) |ch| p.next(ch); - - try testing.expect(p.end(null) == null); - try testing.expect(p.complete == false); -} - -test "OSC: one shorter than buffer length" { - const testing = std.testing; - - var p: Parser = .init(); - - const prefix = "0;"; - const title = "a" ** (Parser.MAX_BUF - prefix.len - 1); - const input = prefix ++ title; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .change_window_title); - try testing.expectEqualStrings(title, cmd.change_window_title); -} - -test "OSC: exactly at buffer length" { - const testing = std.testing; - - var p: Parser = .init(); - - const prefix = "0;"; - const title = "a" ** (Parser.MAX_BUF - prefix.len); - const input = prefix ++ title; - for (input) |ch| p.next(ch); - - // This should be null because we always reserve space for a null terminator. - try testing.expect(p.end(null) == null); - try testing.expect(p.complete == false); -} - -test "OSC: OSC 9;1 ConEmu sleep" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;1;420"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_sleep); - try testing.expectEqual(420, cmd.conemu_sleep.duration_ms); -} - -test "OSC: OSC 9;1 ConEmu sleep with no value default to 100ms" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;1;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_sleep); - try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); -} - -test "OSC: OSC 9;1 conemu sleep cannot exceed 10000ms" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;1;12345"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_sleep); - try testing.expectEqual(10000, cmd.conemu_sleep.duration_ms); -} - -test "OSC: OSC 9;1 conemu sleep invalid input" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;1;foo"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_sleep); - try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); -} - -test "OSC: OSC 9;1 conemu sleep -> desktop notification 1" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;1"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("1", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;1 conemu sleep -> desktop notification 2" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;1a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("1a", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9 show desktop notification" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;Hello world"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("", cmd.show_desktop_notification.title); - try testing.expectEqualStrings("Hello world", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9 show single character desktop notification" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;H"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("", cmd.show_desktop_notification.title); - try testing.expectEqualStrings("H", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 777 show desktop notification with title" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "777;notify;Title;Body"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title"); - try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); -} - -test "OSC: OSC 9;2 ConEmu message box" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;2;hello world"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_show_message_box); - try testing.expectEqualStrings("hello world", cmd.conemu_show_message_box); -} - -test "OSC: 9;2 ConEmu message box invalid input" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;2"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); -} - -test "OSC: 9;2 ConEmu message box empty message" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;2;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_show_message_box); - try testing.expectEqualStrings("", cmd.conemu_show_message_box); -} - -test "OSC: 9;2 ConEmu message box spaces only message" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;2; "; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_show_message_box); - try testing.expectEqualStrings(" ", cmd.conemu_show_message_box); -} - -test "OSC: OSC 9;2 message box -> desktop notification 1" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;2"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;2 message box -> desktop notification 2" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;2a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("2a", cmd.show_desktop_notification.body); -} - -test "OSC: 9;3 ConEmu change tab title" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;3;foo bar"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_change_tab_title); - try testing.expectEqualStrings("foo bar", cmd.conemu_change_tab_title.value); -} - -test "OSC: 9;3 ConEmu change tab title reset" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;3;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - const expected_command: Command = .{ .conemu_change_tab_title = .reset }; - try testing.expectEqual(expected_command, cmd); -} - -test "OSC: 9;3 ConEmu change tab title spaces only" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;3; "; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_change_tab_title); - try testing.expectEqualStrings(" ", cmd.conemu_change_tab_title.value); -} - -test "OSC: OSC 9;3 change tab title -> desktop notification 1" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;3"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("3", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;3 message box -> desktop notification 2" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;3a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("3a", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;4 ConEmu progress set" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;1;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expect(cmd.conemu_progress_report.progress == 100); -} - -test "OSC: OSC 9;4 ConEmu progress set overflow" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;1;900"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expectEqual(100, cmd.conemu_progress_report.progress); -} - -test "OSC: OSC 9;4 ConEmu progress set single digit" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;1;9"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expect(cmd.conemu_progress_report.progress == 9); -} - -test "OSC: OSC 9;4 ConEmu progress set double digit" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;1;94"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expectEqual(94, cmd.conemu_progress_report.progress); -} - -test "OSC: OSC 9;4 ConEmu progress set extra semicolon ignored" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;1;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expectEqual(100, cmd.conemu_progress_report.progress); -} - -test "OSC: OSC 9;4 ConEmu progress remove with no progress" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;0;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .remove); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC: OSC 9;4 ConEmu progress remove with double semicolon" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;0;;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .remove); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC: OSC 9;4 ConEmu progress remove ignores progress" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;0;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .remove); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC: OSC 9;4 ConEmu progress remove extra semicolon" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;0;100;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .remove); -} - -test "OSC: OSC 9;4 ConEmu progress error" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;2"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .@"error"); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC: OSC 9;4 ConEmu progress error with progress" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;2;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .@"error"); - try testing.expect(cmd.conemu_progress_report.progress == 100); -} - -test "OSC: OSC 9;4 progress pause" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;4"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .pause); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC: OSC 9;4 ConEmu progress pause with progress" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;4;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .pause); - try testing.expect(cmd.conemu_progress_report.progress == 100); -} - -test "OSC: OSC 9;4 progress -> desktop notification 1" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("4", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;4 progress -> desktop notification 2" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("4;", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;4 progress -> desktop notification 3" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;5"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("4;5", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;4 progress -> desktop notification 4" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;5a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("4;5a", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;5 ConEmu wait input" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;5"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_wait_input); -} - -test "OSC: OSC 9;5 ConEmu wait ignores trailing characters" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;5;foo"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_wait_input); -} - -test "OSC: empty param" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "4;;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b'); - try testing.expect(cmd == null); -} - -test "OSC: hyperlink" { - const testing = std.testing; - - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;;http://example.com"; for (input) |ch| p.next(ch); @@ -2591,10 +1940,10 @@ test "OSC: hyperlink" { try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } -test "OSC: hyperlink with id set" { +test "OSC 8: hyperlink with id set" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;id=foo;http://example.com"; for (input) |ch| p.next(ch); @@ -2605,10 +1954,10 @@ test "OSC: hyperlink with id set" { try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } -test "OSC: hyperlink with empty id" { +test "OSC 8: hyperlink with empty id" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;id=;http://example.com"; for (input) |ch| p.next(ch); @@ -2619,10 +1968,10 @@ test "OSC: hyperlink with empty id" { try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } -test "OSC: hyperlink with incomplete key" { +test "OSC 8: hyperlink with incomplete key" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;id;http://example.com"; for (input) |ch| p.next(ch); @@ -2633,10 +1982,10 @@ test "OSC: hyperlink with incomplete key" { try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } -test "OSC: hyperlink with empty key" { +test "OSC 8: hyperlink with empty key" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;=value;http://example.com"; for (input) |ch| p.next(ch); @@ -2647,10 +1996,10 @@ test "OSC: hyperlink with empty key" { try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } -test "OSC: hyperlink with empty key and id" { +test "OSC 8: hyperlink with empty key and id" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;=value:id=foo;http://example.com"; for (input) |ch| p.next(ch); @@ -2661,10 +2010,10 @@ test "OSC: hyperlink with empty key and id" { try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } -test "OSC: hyperlink with empty uri" { +test "OSC 8: hyperlink with empty uri" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;id=foo;"; for (input) |ch| p.next(ch); @@ -2673,10 +2022,10 @@ test "OSC: hyperlink with empty uri" { try testing.expect(cmd == null); } -test "OSC: hyperlink end" { +test "OSC 8: hyperlink end" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;;"; for (input) |ch| p.next(ch); @@ -2685,11 +2034,595 @@ test "OSC: hyperlink end" { try testing.expect(cmd == .hyperlink_end); } -test "OSC: kitty color protocol" { +test "OSC 9: show desktop notification" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;Hello world"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("", cmd.show_desktop_notification.title); + try testing.expectEqualStrings("Hello world", cmd.show_desktop_notification.body); +} + +test "OSC 9: show single character desktop notification" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;H"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("", cmd.show_desktop_notification.title); + try testing.expectEqualStrings("H", cmd.show_desktop_notification.body); +} + +test "OSC 9;1: ConEmu sleep" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;1;420"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_sleep); + try testing.expectEqual(420, cmd.conemu_sleep.duration_ms); +} + +test "OSC 9;1: ConEmu sleep with no value default to 100ms" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;1;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_sleep); + try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); +} + +test "OSC 9;1: conemu sleep cannot exceed 10000ms" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;1;12345"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_sleep); + try testing.expectEqual(10000, cmd.conemu_sleep.duration_ms); +} + +test "OSC 9;1: conemu sleep invalid input" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;1;foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_sleep); + try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); +} + +test "OSC 9;1: conemu sleep -> desktop notification 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;1"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("1", cmd.show_desktop_notification.body); +} + +test "OSC 9;1: conemu sleep -> desktop notification 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;1a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("1a", cmd.show_desktop_notification.body); +} + +test "OSC 9;2: ConEmu message box" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;2;hello world"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_show_message_box); + try testing.expectEqualStrings("hello world", cmd.conemu_show_message_box); +} + +test "OSC 9;2: ConEmu message box invalid input" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;2"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); +} + +test "OSC 9;2: ConEmu message box empty message" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;2;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_show_message_box); + try testing.expectEqualStrings("", cmd.conemu_show_message_box); +} + +test "OSC 9;2: ConEmu message box spaces only message" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;2; "; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_show_message_box); + try testing.expectEqualStrings(" ", cmd.conemu_show_message_box); +} + +test "OSC 9;2: message box -> desktop notification 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;2"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); +} + +test "OSC 9;2: message box -> desktop notification 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;2a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("2a", cmd.show_desktop_notification.body); +} + +test "OSC 9;3: ConEmu change tab title" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;3;foo bar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_change_tab_title); + try testing.expectEqualStrings("foo bar", cmd.conemu_change_tab_title.value); +} + +test "OSC 9;3: ConEmu change tab title reset" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;3;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + const expected_command: Command = .{ .conemu_change_tab_title = .reset }; + try testing.expectEqual(expected_command, cmd); +} + +test "OSC 9;3: ConEmu change tab title spaces only" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;3; "; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_change_tab_title); + try testing.expectEqualStrings(" ", cmd.conemu_change_tab_title.value); +} + +test "OSC 9;3: change tab title -> desktop notification 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;3"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("3", cmd.show_desktop_notification.body); +} + +test "OSC 9;3: message box -> desktop notification 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;3a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("3a", cmd.show_desktop_notification.body); +} + +test "OSC 9;4: ConEmu progress set" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;1;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expect(cmd.conemu_progress_report.progress == 100); +} + +test "OSC 9;4: ConEmu progress set overflow" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;1;900"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expectEqual(100, cmd.conemu_progress_report.progress); +} + +test "OSC 9;4: ConEmu progress set single digit" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;1;9"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expect(cmd.conemu_progress_report.progress == 9); +} + +test "OSC 9;4: ConEmu progress set double digit" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;1;94"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expectEqual(94, cmd.conemu_progress_report.progress); +} + +test "OSC 9;4: ConEmu progress set extra semicolon ignored" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;1;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expectEqual(100, cmd.conemu_progress_report.progress); +} + +test "OSC 9;4: ConEmu progress remove with no progress" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;0;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .remove); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress remove with double semicolon" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;0;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .remove); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress remove ignores progress" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;0;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .remove); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress remove extra semicolon" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;0;100;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .remove); +} + +test "OSC 9;4: ConEmu progress error" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;2"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .@"error"); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress error with progress" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;2;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .@"error"); + try testing.expect(cmd.conemu_progress_report.progress == 100); +} + +test "OSC 9;4: progress pause" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;4"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .pause); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress pause with progress" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;4;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .pause); + try testing.expect(cmd.conemu_progress_report.progress == 100); +} + +test "OSC 9;4: progress -> desktop notification 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("4", cmd.show_desktop_notification.body); +} + +test "OSC 9;4: progress -> desktop notification 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("4;", cmd.show_desktop_notification.body); +} + +test "OSC 9;4: progress -> desktop notification 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;5"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("4;5", cmd.show_desktop_notification.body); +} + +test "OSC 9;4: progress -> desktop notification 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;5a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("4;5a", cmd.show_desktop_notification.body); +} + +test "OSC 9;5: ConEmu wait input" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;5"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_wait_input); +} + +test "OSC 9;5: ConEmu wait ignores trailing characters" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;5;foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_wait_input); +} + +test "OSC 9;6: ConEmu guimacro 1" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;6;a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_guimacro); + try testing.expectEqualStrings("a", cmd.conemu_guimacro); +} + +test "OSC: 9;6: ConEmu guimacro 2" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;6;ab"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_guimacro); + try testing.expectEqualStrings("ab", cmd.conemu_guimacro); +} + +test "OSC: 9;6: ConEmu guimacro 3 incomplete -> desktop notification" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;6"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("6", cmd.show_desktop_notification.body); +} + +// See src/terminal/osc/color.zig for OSC 10 tests. + +// See src/terminal/osc/color.zig for OSC 11 tests. + +// See src/terminal/osc/color.zig for OSC 12 tests. + +// See src/terminal/osc/color.zig for OSC 13 tests. + +// See src/terminal/osc/color.zig for OSC 14 tests. + +// See src/terminal/osc/color.zig for OSC 15 tests. + +// See src/terminal/osc/color.zig for OSC 16 tests. + +// See src/terminal/osc/color.zig for OSC 17 tests. + +// See src/terminal/osc/color.zig for OSC 18 tests. + +// See src/terminal/osc/color.zig for OSC 19 tests. + +test "OSC 21: kitty color protocol" { const testing = std.testing; const Kind = kitty_color.Kind; - var p: Parser = .initAlloc(testing.allocator); + var p: Parser = .init(testing.allocator); defer p.deinit(); const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; @@ -2757,10 +2690,10 @@ test "OSC: kitty color protocol" { } } -test "OSC: kitty color protocol without allocator" { +test "OSC 21: kitty color protocol without allocator" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); defer p.deinit(); const input = "21;foreground=?"; @@ -2768,10 +2701,10 @@ test "OSC: kitty color protocol without allocator" { try testing.expect(p.end('\x1b') == null); } -test "OSC: kitty color protocol double reset" { +test "OSC 21: kitty color protocol double reset" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); + var p: Parser = .init(testing.allocator); defer p.deinit(); const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; @@ -2784,10 +2717,10 @@ test "OSC: kitty color protocol double reset" { p.reset(); } -test "OSC: kitty color protocol reset after invalid" { +test "OSC 21: kitty color protocol reset after invalid" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); + var p: Parser = .init(testing.allocator); defer p.deinit(); const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; @@ -2805,10 +2738,10 @@ test "OSC: kitty color protocol reset after invalid" { p.reset(); } -test "OSC: kitty color protocol no key" { +test "OSC 21: kitty color protocol no key" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); + var p: Parser = .init(testing.allocator); defer p.deinit(); const input = "21;"; @@ -2819,44 +2752,588 @@ test "OSC: kitty color protocol no key" { try testing.expectEqual(0, cmd.kitty_color_protocol.list.items.len); } -test "OSC: 9;6: ConEmu guimacro 1" { +test "OSC 22: pointer cursor" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); - defer p.deinit(); + var p: Parser = .init(null); - const input = "9;6;a"; + const input = "22;pointer"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_guimacro); - try testing.expectEqualStrings("a", cmd.conemu_guimacro); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .mouse_shape); + try testing.expectEqualStrings("pointer", cmd.mouse_shape.value); } -test "OSC: 9;6: ConEmu guimacro 2" { +test "OSC 52: get/set clipboard" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); - defer p.deinit(); + var p: Parser = .init(null); - const input = "9;6;ab"; + const input = "52;s;?"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_guimacro); - try testing.expectEqualStrings("ab", cmd.conemu_guimacro); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 's'); + try testing.expectEqualStrings("?", cmd.clipboard_contents.data); } -test "OSC: 9;6: ConEmu guimacro 3 incomplete -> desktop notification" { +test "OSC 52: get/set clipboard (optional parameter)" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); + var p: Parser = .init(null); + + const input = "52;;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 'c'); + try testing.expectEqualStrings("?", cmd.clipboard_contents.data); +} + +test "OSC 52: get/set clipboard with allocator" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); defer p.deinit(); - const input = "9;6"; + const input = "52;s;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 's'); + try testing.expectEqualStrings("?", cmd.clipboard_contents.data); +} + +test "OSC 52: clear clipboard" { + const testing = std.testing; + + var p: Parser = .init(null); + defer p.deinit(); + + const input = "52;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 'c'); + try testing.expectEqualStrings("", cmd.clipboard_contents.data); +} + +// See src/terminal/osc/color.zig for OSC 104 tests. + +// See src/terminal/osc/color.zig for OSC 105 tests. + +// See src/terminal/osc/color.zig for OSC 110 tests. + +// See src/terminal/osc/color.zig for OSC 111 tests. + +// See src/terminal/osc/color.zig for OSC 112 tests. + +// See src/terminal/osc/color.zig for OSC 113 tests. + +// See src/terminal/osc/color.zig for OSC 114 tests. + +// See src/terminal/osc/color.zig for OSC 115 tests. + +// See src/terminal/osc/color.zig for OSC 116 tests. + +// See src/terminal/osc/color.zig for OSC 117 tests. + +// See src/terminal/osc/color.zig for OSC 118 tests. + +// See src/terminal/osc/color.zig for OSC 119 tests. + +test "OSC 133: prompt_start" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.aid == null); + try testing.expect(cmd.prompt_start.redraw); +} + +test "OSC 133: prompt_start with single option" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;aid=14"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); +} + +test "OSC 133: prompt_start with redraw disabled" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;redraw=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(!cmd.prompt_start.redraw); +} + +test "OSC 133: prompt_start with redraw invalid value" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;redraw=42"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.redraw); + try testing.expect(cmd.prompt_start.kind == .primary); +} + +test "OSC 133: prompt_start with continuation" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;k=c"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.kind == .continuation); +} + +test "OSC 133: prompt_start with secondary" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;k=s"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.kind == .secondary); +} + +test "OSC 133: prompt_start with special_key" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;special_key=1"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.special_key == true); +} + +test "OSC 133: prompt_start with special_key invalid" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;special_key=bobr"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.special_key == false); +} + +test "OSC 133: prompt_start with special_key 0" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;special_key=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.special_key == false); +} + +test "OSC 133: prompt_start with special_key empty" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;special_key="; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.special_key == false); +} + +test "OSC 133: prompt_start with click_events true" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;click_events=1"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.click_events == true); +} + +test "OSC 133: prompt_start with click_events false" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;click_events=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.click_events == false); +} + +test "OSC 133: prompt_start with click_events empty" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;click_events="; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.click_events == false); +} + +test "OSC 133: end_of_command no exit code" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;D"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_command); +} + +test "OSC 133: end_of_command with exit code" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;D;25"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_command); + try testing.expectEqual(@as(u8, 25), cmd.end_of_command.exit_code.?); +} + +test "OSC 133: prompt_end" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;B"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_end); +} + +test "OSC 133: end_of_input" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); +} + +test "OSC 133: end_of_input with cmdline 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=echo bobr\\ kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=echo bobr\\nkurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr\nkurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=$'echo bobr kurwa'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 5" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline='echo bobr kurwa'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 6" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline='echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 7" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=$'echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 8" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=$'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 9" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=$'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 10" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline="; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr%20kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr%3bkurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr;kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr%3kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 5" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr%kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 6" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr%kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 7" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr kurwa%20"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa ", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 8" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr kurwa%2"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 9" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr kurwa%2"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC: OSC 777 show desktop notification with title" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "777;notify;Title;Body"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("6", cmd.show_desktop_notification.body); + try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title"); + try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); } diff --git a/src/terminal/osc/color.zig b/src/terminal/osc/color.zig index 8a8e8b942..9fd81ed63 100644 --- a/src/terminal/osc/color.zig +++ b/src/terminal/osc/color.zig @@ -279,7 +279,7 @@ pub const ColoredTarget = struct { color: RGB, }; -test "osc4" { +test "OSC 4:" { const testing = std.testing; const alloc = testing.allocator; @@ -401,7 +401,7 @@ test "osc4" { } } -test "osc5" { +test "OSC 5:" { const testing = std.testing; const alloc = testing.allocator; @@ -433,7 +433,7 @@ test "osc5" { } } -test "osc4: multiple requests" { +test "OSC 4: multiple requests" { const testing = std.testing; const alloc = testing.allocator; @@ -489,7 +489,7 @@ test "osc4: multiple requests" { } } -test "osc104" { +test "OSC 104:" { const testing = std.testing; const alloc = testing.allocator; @@ -540,7 +540,7 @@ test "osc104" { } } -test "osc104 empty index" { +test "OSC 104: empty index" { const testing = std.testing; const alloc = testing.allocator; @@ -557,7 +557,7 @@ test "osc104 empty index" { ); } -test "osc104 invalid index" { +test "OSC 104: invalid index" { const testing = std.testing; const alloc = testing.allocator; @@ -570,7 +570,7 @@ test "osc104 invalid index" { ); } -test "osc104 reset all" { +test "OSC 104: reset all" { const testing = std.testing; const alloc = testing.allocator; @@ -583,7 +583,7 @@ test "osc104 reset all" { ); } -test "osc105 reset all" { +test "OSC 105: reset all" { const testing = std.testing; const alloc = testing.allocator; @@ -597,7 +597,7 @@ test "osc105 reset all" { } // OSC 10-19: Get/Set Dynamic Colors -test "dynamic" { +test "OSC 10: OSC 11: OSC 12: OSC: 13: OSC 14: OSC 15: OSC: 16: OSC 17: OSC 18: OSC 19: dynamic" { const testing = std.testing; const alloc = testing.allocator; @@ -625,7 +625,7 @@ test "dynamic" { } } -test "dynamic multiple" { +test "OSC 10: OSC 11: OSC 12: OSC: 13: OSC 14: OSC 15: OSC: 16: OSC 17: OSC 18: OSC 19: dynamic multiple" { const testing = std.testing; const alloc = testing.allocator; @@ -657,7 +657,7 @@ test "dynamic multiple" { } // OSC 110-119: Reset Dynamic Colors -test "reset dynamic" { +test "OSC 110: OSC 111: OSC 112: OSC: 113: OSC 114: OSC 115: OSC: 116: OSC 117: OSC 118: OSC 119: reset dynamic" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 331168a27..124ff2545 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -3,15 +3,17 @@ 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 style = @import("style.zig"); +const stylepkg = @import("style.zig"); +const Style = stylepkg.Style; +const StyleId = stylepkg.Id; +const StyleSet = stylepkg.Set; const size = @import("size.zig"); const getOffset = size.getOffset; const Offset = size.Offset; @@ -86,7 +88,7 @@ pub const Page = struct { assert(std.heap.page_size_min % @max( @alignOf(Row), @alignOf(Cell), - style.Set.base_align.toByteUnits(), + StyleSet.base_align.toByteUnits(), ) == 0); } @@ -105,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. @@ -124,7 +135,7 @@ pub const Page = struct { grapheme_map: GraphemeMap, /// The available set of styles in use on this page. - styles: style.Set, + styles: StyleSet, /// The structures used for tracking hyperlinks within the page. /// The map maps cell offsets to hyperlink IDs and the IDs are in @@ -133,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. @@ -235,8 +208,7 @@ pub const Page = struct { .memory = @alignCast(buf.start()[0..l.total_size]), .rows = rows, .cells = cells, - .dirty = buf.member(usize, l.dirty_start), - .styles = style.Set.init( + .styles = StyleSet.init( buf.add(l.styles_start), l.styles_layout, .{}, @@ -264,6 +236,7 @@ pub const Page = struct { ), .size = .{ .cols = cap.cols, .rows = cap.rows }, .capacity = cap, + .dirty = false, }; } @@ -372,7 +345,7 @@ pub const Page = struct { const alloc = arena.allocator(); var graphemes_seen: usize = 0; - var styles_seen = std.AutoHashMap(style.Id, usize).init(alloc); + var styles_seen = std.AutoHashMap(StyleId, usize).init(alloc); defer styles_seen.deinit(); var hyperlinks_seen = std.AutoHashMap(hyperlink.Id, usize).init(alloc); defer hyperlinks_seen.deinit(); @@ -409,7 +382,7 @@ pub const Page = struct { } } - if (cell.style_id != style.default_id) { + if (cell.style_id != stylepkg.default_id) { // If a cell has a style, it must be present in the styles // set. Accessing it with `get` asserts that. _ = self.styles.get( @@ -683,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 @@ -749,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 @@ -767,7 +738,7 @@ pub const Page = struct { for (other_cells) |cell| { assert(!cell.hasGrapheme()); assert(!cell.hyperlink); - assert(cell.style_id == style.default_id); + assert(cell.style_id == stylepkg.default_id); } } @@ -782,18 +753,12 @@ pub const Page = struct { // hit an integrity check if we have to return an error because // the page can't fit the new memory. dst_cell.hyperlink = false; - dst_cell.style_id = style.default_id; + dst_cell.style_id = stylepkg.default_id; if (dst_cell.content_tag == .codepoint_grapheme) { dst_cell.content_tag = .codepoint; } 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 = style.default_id; - } - // Copy the grapheme codepoints const cps = other.lookupGrapheme(src_cell).?; @@ -867,7 +832,7 @@ pub const Page = struct { try self.setHyperlink(dst_row, dst_cell, dst_id); } - if (src_cell.style_id != style.default_id) style: { + if (src_cell.style_id != stylepkg.default_id) style: { dst_row.styled = true; if (other == self) { @@ -995,7 +960,7 @@ pub const Page = struct { // The destination row has styles if any of the cells are styled if (!dst_row.styled) dst_row.styled = styled: for (dst_cells) |c| { - if (c.style_id != style.default_id) break :styled true; + if (c.style_id != stylepkg.default_id) break :styled true; } else false; // Clear our source row now that the copy is complete. We can NOT @@ -1087,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 == style.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) { @@ -1134,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 @@ -1146,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; @@ -1195,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. @@ -1225,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), ), }; @@ -1418,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.* = .{ @@ -1437,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 @@ -1462,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()); @@ -1472,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; @@ -1498,217 +1509,23 @@ pub const Page = struct { return self.grapheme_map.map(self.memory).capacity(); } - /// Options for encoding the page as UTF-8. - pub const EncodeUtf8Options = struct { - /// The range of rows to encode. If end_y is null, then it will - /// encode to the end of the page. - start_y: size.CellCountInt = 0, - end_y: ?size.CellCountInt = null, - - /// If true, this will unwrap soft-wrapped lines. If false, this will - /// dump the screen as it is visually seen in a rendered window. - unwrap: bool = true, - - /// Preceding state from encoding the prior page. Used to preserve - /// blanks properly across multiple pages. - preceding: TrailingUtf8State = .{}, - - /// If non-null, this will be cleared and filled with the x/y - /// coordinates of each byte in the UTF-8 encoded output. - /// The index in the array is the byte offset in the output - /// where 0 is the cursor of the writer when the function is - /// called. - cell_map: ?*CellMap = null, - - /// Trailing state for UTF-8 encoding. - pub const TrailingUtf8State = struct { - rows: usize = 0, - cells: usize = 0, - }; - }; - - /// See cell_map - pub const CellMap = struct { - alloc: Allocator, - map: std.ArrayList(CellMapEntry), - - pub fn init(alloc: Allocator) CellMap { - return .{ - .alloc = alloc, - .map = .empty, - }; - } - - pub fn deinit(self: *CellMap) void { - self.map.deinit(self.alloc); - } - }; - - /// The x/y coordinate of a single cell in the cell map. - pub const CellMapEntry = struct { - y: size.CellCountInt, - x: size.CellCountInt, - }; - - /// Encode the page contents as UTF-8. + /// Checks if the row contains any styles and sets + /// the styled flag to false if none are found. /// - /// If preceding is non-null, then it will be used to initialize our - /// blank rows/cells count so that we can accumulate blanks across - /// multiple pages. - /// - /// Note: Many tests for this function are done via Screen.dumpString - /// tests since that function is a thin wrapper around this one and - /// it makes it easier to test input contents. - pub fn encodeUtf8( - self: *const Page, - writer: *std.Io.Writer, - opts: EncodeUtf8Options, - ) anyerror!EncodeUtf8Options.TrailingUtf8State { - var blank_rows: usize = opts.preceding.rows; - var blank_cells: usize = opts.preceding.cells; - - const start_y: size.CellCountInt = opts.start_y; - const end_y: size.CellCountInt = opts.end_y orelse self.size.rows; - - // We can probably avoid this by doing the logic below in a different - // way. The reason this exists is so that when we end a non-blank - // line with a newline, we can correctly map the cell map over to - // the correct x value. - // - // For example "A\nB". The cell map for "\n" should be (1, 0). - // This is tested in Screen.zig so feel free to refactor this. - var last_x: size.CellCountInt = 0; - - for (start_y..end_y) |y_usize| { - const y: size.CellCountInt = @intCast(y_usize); - const row: *Row = self.getRow(y); - const cells: []const Cell = self.getCells(row); - - // If this row is blank, accumulate to avoid a bunch of extra - // work later. If it isn't blank, make sure we dump all our - // blanks. - if (!Cell.hasTextAny(cells)) { - blank_rows += 1; - continue; - } - for (1..blank_rows + 1) |i| { - try writer.writeByte('\n'); - - // This is tested in Screen.zig, i.e. one test is - // "cell map with newlines" - if (opts.cell_map) |cell_map| { - try cell_map.map.append(cell_map.alloc, .{ - .x = last_x, - .y = @intCast(y - blank_rows + i - 1), - }); - last_x = 0; - } - } - blank_rows = 0; - - // If we're not wrapped, we always add a newline so after - // the row is printed we can add a newline. - if (!row.wrap or !opts.unwrap) blank_rows += 1; - - // If the row doesn't continue a wrap then we need to reset - // our blank cell count. - if (!row.wrap_continuation or !opts.unwrap) blank_cells = 0; - - // Go through each cell and print it - for (cells, 0..) |*cell, x_usize| { - const x: size.CellCountInt = @intCast(x_usize); - - // Skip spacers - switch (cell.wide) { - .narrow, .wide => {}, - .spacer_head, .spacer_tail => continue, - } - - // If we have a zero value, then we accumulate a counter. We - // only want to turn zero values into spaces if we have a non-zero - // char sometime later. - if (!cell.hasText()) { - blank_cells += 1; - continue; - } - if (blank_cells > 0) { - try writer.splatByteAll(' ', blank_cells); - if (opts.cell_map) |cell_map| { - for (0..blank_cells) |i| try cell_map.map.append(cell_map.alloc, .{ - .x = @intCast(x - blank_cells + i), - .y = y, - }); - } - - blank_cells = 0; - } - - switch (cell.content_tag) { - .codepoint => { - try writer.print("{u}", .{cell.content.codepoint}); - if (opts.cell_map) |cell_map| { - last_x = x + 1; - try cell_map.map.append(cell_map.alloc, .{ - .x = x, - .y = y, - }); - } - }, - - .codepoint_grapheme => { - try writer.print("{u}", .{cell.content.codepoint}); - if (opts.cell_map) |cell_map| { - last_x = x + 1; - try cell_map.map.append(cell_map.alloc, .{ - .x = x, - .y = y, - }); - } - - for (self.lookupGrapheme(cell).?) |cp| { - try writer.print("{u}", .{cp}); - if (opts.cell_map) |cell_map| try cell_map.map.append(cell_map.alloc, .{ - .x = x, - .y = y, - }); - } - }, - - // Unreachable since we do hasText() above - .bg_color_palette, - .bg_color_rgb, - => unreachable, - } - } - } - - return .{ .rows = blank_rows, .cells = blank_cells }; + /// 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 the bitset for the dirty bits on this page. - /// - /// 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), - }; - } - - /// 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 { @@ -1717,10 +1534,8 @@ pub const Page = struct { rows_size: usize, cells_start: usize, cells_size: usize, - dirty_start: usize, - dirty_size: usize, styles_start: usize, - styles_layout: style.Set.Layout, + styles_layout: StyleSet.Layout, grapheme_alloc_start: usize, grapheme_alloc_layout: GraphemeAlloc.Layout, grapheme_map_start: usize, @@ -1745,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: style.Set.Layout = .init(cap.styles); - const styles_start = alignForward(usize, dirty_end, style.Set.base_align.toByteUnits()); + const styles_layout: StyleSet.Layout = .init(cap.styles); + 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); @@ -1798,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, @@ -1886,16 +1688,14 @@ pub const Capacity = struct { const string_alloc_start = alignBackward(usize, hyperlink_set_start - layout.string_alloc_layout.total_size, StringAlloc.base_align.toByteUnits()); const grapheme_map_start = alignBackward(usize, string_alloc_start - layout.grapheme_map_layout.total_size, GraphemeMap.base_align.toByteUnits()); const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align.toByteUnits()); - const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, style.Set.base_align.toByteUnits()); + const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, StyleSet.base_align.toByteUnits()); // 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; @@ -1959,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) { @@ -1986,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; } }; @@ -2014,7 +1828,7 @@ pub const Cell = packed struct(u64) { /// The style ID to use for this cell within the style map. Zero /// is always the default style so no lookup is required. - style_id: style.Id = 0, + style_id: StyleId = 0, /// The wide property of this cell, for wide characters. Characters in /// a terminal grid can only be 1 or 2 cells wide. A wide character @@ -2080,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; } @@ -2090,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, @@ -2102,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, @@ -2115,15 +1929,15 @@ 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 { - return self.style_id != style.default_id; + pub inline fn hasStyling(self: Cell) bool { + return self.style_id != stylepkg.default_id; } /// Returns true if the cell has no text or styling. @@ -2141,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; } @@ -2263,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" { @@ -2316,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()); } @@ -2361,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()); @@ -2417,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, @@ -2625,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/sanitize.zig b/src/terminal/sanitize.zig deleted file mode 100644 index f96e8a00e..000000000 --- a/src/terminal/sanitize.zig +++ /dev/null @@ -1,14 +0,0 @@ -const std = @import("std"); - -/// Returns true if the data looks safe to paste. -pub fn isSafePaste(data: []const u8) bool { - return std.mem.indexOf(u8, data, "\n") == null and - std.mem.indexOf(u8, data, "\x1b[201~") == null; -} - -test isSafePaste { - const testing = std.testing; - try testing.expect(isSafePaste("hello")); - try testing.expect(!isSafePaste("hello\n")); - try testing.expect(!isSafePaste("hello\nworld")); -} diff --git a/src/terminal/search.zig b/src/terminal/search.zig index d9f6c5663..e69603c25 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -1,883 +1,22 @@ //! Search functionality for the terminal. -//! -//! At the time of writing this comment, this is a **work in progress**. -//! -//! Search at the time of writing is implemented using a simple -//! boyer-moore-horspool algorithm. The suboptimal part of the implementation -//! is that we need to encode each terminal page into a text buffer in order -//! to apply BMH to it. This is because the terminal page is not laid out -//! in a flat text form. -//! -//! To minimize memory usage, we use a sliding window to search for the -//! needle. The sliding window only keeps the minimum amount of page data -//! in memory to search for a needle (i.e. `needle.len - 1` bytes of overlap -//! between terminal pages). -//! -//! Future work: -//! -//! - PageListSearch on a PageList concurrently with another thread -//! - Handle pruned pages in a PageList to ensure we don't keep references -//! - Repeat search a changing active area of the screen -//! - Reverse search so that more recent matches are found first -//! -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const CircBuf = @import("../datastruct/main.zig").CircBuf; -const terminal = @import("main.zig"); -const point = terminal.point; -const Page = terminal.Page; -const PageList = terminal.PageList; -const Pin = PageList.Pin; -const Selection = terminal.Selection; -const Screen = terminal.Screen; +pub const options = @import("terminal_options"); -/// Searches for a term in a PageList structure. -/// -/// At the time of writing, this does not support searching a pagelist -/// simultaneously as its being used by another thread. This will be resolved -/// in the future. -pub const PageListSearch = struct { - /// The list we're searching. - list: *PageList, +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; - /// The sliding window of page contents and nodes to search. - window: SlidingWindow, - - /// Initialize the page list search. - /// - /// The needle is not copied and must be kept alive for the duration - /// of the search operation. - pub fn init( - alloc: Allocator, - list: *PageList, - needle: []const u8, - ) Allocator.Error!PageListSearch { - var window = try SlidingWindow.init(alloc, needle); - errdefer window.deinit(); - - return .{ - .list = list, - .window = window, - }; - } - - pub fn deinit(self: *PageListSearch) void { - self.window.deinit(); - } - - /// Find the next match for the needle in the pagelist. This returns - /// null when there are no more matches. - pub fn next(self: *PageListSearch) Allocator.Error!?Selection { - // Try to search for the needle in the window. If we find a match - // then we can return that and we're done. - if (self.window.next()) |sel| return sel; - - // Get our next node. If we have a value in our window then we - // can determine the next node. If we don't, we've never setup the - // window so we use our first node. - var node_: ?*PageList.List.Node = if (self.window.meta.last()) |meta| - meta.node.next - else - self.list.pages.first; - - // Add one pagelist node at a time, look for matches, and repeat - // until we find a match or we reach the end of the pagelist. - // This append then next pattern limits memory usage of the window. - while (node_) |node| : (node_ = node.next) { - try self.window.append(node); - if (self.window.next()) |sel| return sel; - } - - // We've reached the end of the pagelist, no matches. - return null; - } +// 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, }; -/// 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 -/// (2) we've accounted for overlaps across pages to fit the needle. -/// -/// The sliding window is first initialized empty. Pages are then appended -/// in the order to search them. If you're doing a reverse search then the -/// pages should be appended in reverse order and the needle should be -/// reversed. -/// -/// All appends grow the window. The window is only pruned when a searc -/// is done (positive or negative match) via `next()`. -/// -/// To avoid unnecessary memory growth, the recommended usage is to -/// call `next()` until it returns null and then `append` the next page -/// and repeat the process. This will always maintain the minimum -/// required memory to search for the needle. -const SlidingWindow = struct { - /// The allocator to use for all the data within this window. We - /// store this rather than passing it around because its already - /// part of multiple elements (eg. Meta's CellMap) and we want to - /// ensure we always use a consistent allocator. Additionally, only - /// a small amount of sliding windows are expected to be in use - /// at any one time so the memory overhead isn't that large. - alloc: Allocator, +test { + @import("std").testing.refAllDecls(@This()); - /// The data buffer is a circular buffer of u8 that contains the - /// encoded page text that we can use to search for the needle. - data: DataBuf, - - /// The meta buffer is a circular buffer that contains the metadata - /// about the pages we're searching. This usually isn't that large - /// so callers must iterate through it to find the offset to map - /// data to meta. - meta: MetaBuf, - - /// 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. - data_offset: usize = 0, - - /// The needle we're searching for. Does not own the memory. - needle: []const u8, - - /// A buffer to store the overlap search data. This is used to search - /// overlaps between pages where the match starts on one page and - /// ends on another. The length is always `needle.len * 2`. - overlap_buf: []u8, - - const DataBuf = CircBuf(u8, 0); - const MetaBuf = CircBuf(Meta, undefined); - const Meta = struct { - node: *PageList.List.Node, - cell_map: Page.CellMap, - - pub fn deinit(self: *Meta) void { - self.cell_map.deinit(); - } - }; - - pub fn init( - alloc: Allocator, - needle: []const u8, - ) Allocator.Error!SlidingWindow { - var data = try DataBuf.init(alloc, 0); - errdefer data.deinit(alloc); - - var meta = try MetaBuf.init(alloc, 0); - errdefer meta.deinit(alloc); - - const overlap_buf = try alloc.alloc(u8, needle.len * 2); - errdefer alloc.free(overlap_buf); - - return .{ - .alloc = alloc, - .data = data, - .meta = meta, - .needle = needle, - .overlap_buf = overlap_buf, - }; - } - - pub fn deinit(self: *SlidingWindow) void { - self.alloc.free(self.overlap_buf); - self.data.deinit(self.alloc); - - var meta_it = self.meta.iterator(.forward); - while (meta_it.next()) |meta| meta.deinit(); - self.meta.deinit(self.alloc); - } - - /// Clear all data but retain allocated capacity. - pub fn clearAndRetainCapacity(self: *SlidingWindow) void { - var meta_it = self.meta.iterator(.forward); - while (meta_it.next()) |meta| meta.deinit(); - self.meta.clear(); - self.data.clear(); - self.data_offset = 0; - } - - /// Search the window for the next occurrence of the needle. As - /// the window moves, the window will prune itself while maintaining - /// the invariant that the window is always big enough to contain - /// the needle. - pub fn next(self: *SlidingWindow) ?Selection { - const slices = slices: { - // If we have less data then the needle then we can't possibly match - const data_len = self.data.len(); - if (data_len < self.needle.len) return null; - - break :slices self.data.getPtrSlice( - self.data_offset, - data_len - self.data_offset, - ); - }; - - // Search the first slice for the needle. - if (std.mem.indexOf(u8, slices[0], self.needle)) |idx| { - return self.selection( - idx, - self.needle.len, - ); - } - - // Search the overlap buffer for the needle. - if (slices[0].len > 0 and slices[1].len > 0) overlap: { - // Get up to needle.len - 1 bytes from each side (as much as - // we can) and store it in the overlap buffer. - const prefix: []const u8 = prefix: { - const len = @min(slices[0].len, self.needle.len - 1); - const idx = slices[0].len - len; - break :prefix slices[0][idx..]; - }; - const suffix: []const u8 = suffix: { - const len = @min(slices[1].len, self.needle.len - 1); - break :suffix slices[1][0..len]; - }; - const overlap_len = prefix.len + suffix.len; - assert(overlap_len <= self.overlap_buf.len); - @memcpy(self.overlap_buf[0..prefix.len], prefix); - @memcpy(self.overlap_buf[prefix.len..overlap_len], suffix); - - // Search the overlap - const idx = std.mem.indexOf( - u8, - 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( - 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( - slices[0].len + idx, - self.needle.len, - ); - } - - // No match. We keep `needle.len - 1` bytes available to - // handle the future overlap case. - var meta_it = self.meta.iterator(.reverse); - prune: { - var saved: usize = 0; - while (meta_it.next()) |meta| { - const needed = self.needle.len - 1 - saved; - if (meta.cell_map.map.items.len >= needed) { - // We save up to this meta. We set our data offset - // to exactly where it needs to be to continue - // searching. - self.data_offset = meta.cell_map.map.items.len - needed; - break; - } - - saved += meta.cell_map.map.items.len; - } else { - // If we exited the while loop naturally then we - // never got the amount we needed and so there is - // nothing to prune. - assert(saved < self.needle.len - 1); - break :prune; - } - - const prune_count = self.meta.len() - meta_it.idx; - if (prune_count == 0) { - // This can happen if we need to save up to the first - // meta value to retain our window. - break :prune; - } - - // We can now delete all the metas up to but NOT including - // the meta we found through meta_it. - meta_it = self.meta.iterator(.forward); - var prune_data_len: usize = 0; - for (0..prune_count) |_| { - const meta = meta_it.next().?; - prune_data_len += meta.cell_map.map.items.len; - meta.deinit(); - } - self.meta.deleteOldest(prune_count); - self.data.deleteOldest(prune_data_len); - } - - // Our data offset now moves to needle.len - 1 from the end so - // that we can handle the overlap case. - self.data_offset = self.data.len() - self.needle.len + 1; - - self.assertIntegrity(); - 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. - /// - /// 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( - self: *SlidingWindow, - start_offset: usize, - len: usize, - ) Selection { - const start = start_offset + self.data_offset; - 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); - - // 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; - - // 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); - - // 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; - - // 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) { - // 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(); - if (comptime std.debug.runtime_safety) { - assert(meta_it.idx == meta_count); - assert(meta_it.next().?.node == tl.node); - } - self.meta.deleteOldest(meta_count); - - // Delete all the data up to our current index. - assert(tl_meta_consumed > 0); - self.data.deleteOldest(tl_meta_consumed); - } - - self.assertIntegrity(); - return .init(tl, br, false); - } - - /// 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.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.map.items.len; - continue; - } - - // We found the meta that contains the start of the match. - const map = meta.cell_map.map.items[meta_i]; - return .{ - .node = meta.node, - .y = map.y, - .x = map.x, - }; - } - - // Unreachable because it is a precondition that the index is - // within the data buffer. - unreachable; - } - - /// Add a new node to the sliding window. This will always grow - /// the sliding window; data isn't pruned until it is consumed - /// via a search (via next()). - pub fn append( - self: *SlidingWindow, - node: *PageList.List.Node, - ) Allocator.Error!void { - // Initialize our metadata for the node. - var meta: Meta = .{ - .node = node, - .cell_map = .{ - .alloc = self.alloc, - .map = .empty, - }, - }; - errdefer meta.deinit(); - - // This is suboptimal but we need to encode the page once to - // temporary memory, and then copy it into our circular buffer. - // In the future, we should benchmark and see if we can encode - // directly into the circular buffer. - var encoded: std.Io.Writer.Allocating = .init(self.alloc); - defer encoded.deinit(); - - // Encode the page into the buffer. - const page: *const Page = &meta.node.data; - _ = page.encodeUtf8( - &encoded.writer, - .{ .cell_map = &meta.cell_map }, - ) catch { - // writer uses anyerror but the only realistic error on - // an ArrayList is out of memory. - return error.OutOfMemory; - }; - assert(meta.cell_map.map.items.len == encoded.written().len); - - // Ensure our buffers are big enough to store what we need. - try self.data.ensureUnusedCapacity(self.alloc, encoded.written().len); - try self.meta.ensureUnusedCapacity(self.alloc, 1); - - // Append our new node to the circular buffer. - try self.data.appendSlice(encoded.written()); - try self.meta.append(meta); - - self.assertIntegrity(); - } - - fn assertIntegrity(self: *const SlidingWindow) void { - if (comptime !std.debug.runtime_safety) return; - - // We don't run integrity checks on Valgrind because its soooooo slow, - // Valgrind is our integrity checker, and we run these during unit - // tests (non-Valgrind) anyways so we're verifying anyways. - if (std.valgrind.runningOnValgrind() > 0) return; - - // Integrity check: verify our data matches our metadata exactly. - var meta_it = self.meta.iterator(.forward); - var data_len: usize = 0; - while (meta_it.next()) |m| data_len += m.cell_map.map.items.len; - assert(data_len == self.data.len()); - - // Integrity check: verify our data offset is within bounds. - assert(self.data_offset < self.data.len()); - } -}; - -test "PageListSearch single page" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 0); - defer s.deinit(); - try s.testWriteString("hello. boo! hello. boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - - var search = try PageListSearch.init(alloc, &s.pages, "boo!"); - defer search.deinit(); - - // We should be able to find two matches. - { - const sel = (try search.next()).?; - 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 sel = (try search.next()).?; - 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((try search.next()) == null); - try testing.expect((try search.next()) == null); -} - -test "SlidingWindow empty on init" { - const testing = std.testing; - const alloc = testing.allocator; - - var w = try SlidingWindow.init(alloc, "boo!"); - defer w.deinit(); - try testing.expectEqual(0, w.data.len()); - try testing.expectEqual(0, w.meta.len()); -} - -test "SlidingWindow single append" { - const testing = std.testing; - const alloc = testing.allocator; - - var w = try SlidingWindow.init(alloc, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; - 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 sel = w.next().?; - 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 no match" { - const testing = std.testing; - const alloc = testing.allocator; - - var w = try SlidingWindow.init(alloc, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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); - - // No matches - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // Should still keep the page - try testing.expectEqual(1, w.meta.len()); -} - -test "SlidingWindow two pages" { - const testing = std.testing; - const alloc = testing.allocator; - - var w = try SlidingWindow.init(alloc, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; - try testing.expectEqual(point.Point{ .active = .{ - .x = 76, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 79, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.end()).?); - } - { - const sel = w.next().?; - try testing.expectEqual(point.Point{ .active = .{ - .x = 7, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 10, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.end()).?); - } - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); -} - -test "SlidingWindow two pages match across boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - var w = try SlidingWindow.init(alloc, "hello, world"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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("hell"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("o, world!"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - - // Add both pages - const node: *PageList.List.Node = s.pages.pages.first.?; - try w.append(node); - try w.append(node.next.?); - - // Search should find a match - { - const sel = w.next().?; - try testing.expectEqual(point.Point{ .active = .{ - .x = 76, - .y = 22, - } }, 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); - - // We shouldn't prune because we don't have enough space - try testing.expectEqual(2, w.meta.len()); -} - -test "SlidingWindow two pages no match prunes first page" { - const testing = std.testing; - const alloc = testing.allocator; - - var w = try SlidingWindow.init(alloc, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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 nothing - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // We should've pruned our page because the second page - // has enough text to contain our needle. - try testing.expectEqual(1, w.meta.len()); -} - -test "SlidingWindow two pages no match keeps both pages" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 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!"); - - // Imaginary needle for search. Doesn't match! - var needle_list: std.ArrayList(u8) = .empty; - defer needle_list.deinit(alloc); - try needle_list.appendNTimes(alloc, 'x', first_page_rows * s.pages.cols); - const needle: []const u8 = needle_list.items; - - var w = try SlidingWindow.init(alloc, needle); - defer w.deinit(); - - // Add both pages - const node: *PageList.List.Node = s.pages.pages.first.?; - try w.append(node); - try w.append(node.next.?); - - // Search should find nothing - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // No pruning because both pages are needed to fit needle. - try testing.expectEqual(2, w.meta.len()); -} - -test "SlidingWindow single append across circular buffer boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - var w = try SlidingWindow.init(alloc, "abc"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 0); - defer s.deinit(); - try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); - - // We are trying to break a circular buffer boundary so the way we - // do this is to duplicate the data then do a failing search. This - // will cause the first page to be pruned. The next time we append we'll - // put it in the middle of the circ buffer. We assert this so that if - // our implementation changes our test will fail. - try testing.expect(s.pages.pages.first == s.pages.pages.last); - const node: *PageList.List.Node = s.pages.pages.first.?; - try w.append(node); - try w.append(node); - { - // No wrap around yet - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len == 0); - } - - // Search non-match, prunes page - try testing.expect(w.next() == null); - try testing.expectEqual(1, w.meta.len()); - - // Change the needle, just needs to be the same length (not a real API) - w.needle = "boo"; - - // Add new page, now wraps - try w.append(node); - { - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len > 0); - } - { - const sel = w.next().?; - try testing.expectEqual(point.Point{ .active = .{ - .x = 19, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 21, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); - } - try testing.expect(w.next() == null); -} - -test "SlidingWindow single append match on boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - var w = try SlidingWindow.init(alloc, "abcd"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 0); - defer s.deinit(); - try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); - - // We are trying to break a circular buffer boundary so the way we - // do this is to duplicate the data then do a failing search. This - // will cause the first page to be pruned. The next time we append we'll - // put it in the middle of the circ buffer. We assert this so that if - // our implementation changes our test will fail. - try testing.expect(s.pages.pages.first == s.pages.pages.last); - const node: *PageList.List.Node = s.pages.pages.first.?; - try w.append(node); - try w.append(node); - { - // No wrap around yet - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len == 0); - } - - // Search non-match, prunes page - try testing.expect(w.next() == null); - try testing.expectEqual(1, w.meta.len()); - - // Change the needle, just needs to be the same length (not a real API) - w.needle = "boo!"; - - // Add new page, now wraps - try w.append(node); - { - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len > 0); - } - { - const sel = w.next().?; - try testing.expectEqual(point.Point{ .active = .{ - .x = 21, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); - } - try testing.expect(w.next() == null); + // Non-public APIs + _ = @import("search/sliding_window.zig"); } diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig new file mode 100644 index 000000000..8f2d73f16 --- /dev/null +++ b/src/terminal/search/Thread.zig @@ -0,0 +1,877 @@ +//! Search thread that handles searching a terminal for a string match. +//! This is expected to run on a dedicated thread to try to prevent too much +//! overhead to other terminal read/write operations. +//! +//! The current architecture of search does acquire global locks for accessing +//! terminal data, so there's still added contention, but we do our best to +//! minimize this by trading off memory usage (copying data to minimize lock +//! time). +pub const Thread = @This(); + +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 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"); + +const ScreenSearch = @import("screen.zig").ScreenSearch; +const ViewportSearch = @import("viewport.zig").ViewportSearch; + +const log = std.log.scoped(.search_thread); + +// TODO: Some stuff that could be improved: +// - pause the refresh timer when the terminal isn't focused +// - we probably want to know our progress through the search +// for viewport matches so we can show n/total UI. +// - notifications should be coalesced to avoid spamming a massive +// amount of events if the terminal is changing rapidly. + +/// The interval at which we refresh the terminal state to check if +/// there are any changes that require us to re-search. This should be +/// balanced to be fast enough to be responsive but not so fast that +/// we hold the terminal lock too often. +const REFRESH_INTERVAL = 24; // 40 FPS + +/// Allocator used for some state +alloc: std.mem.Allocator, + +/// The mailbox that can be used to send this thread messages. Note +/// this is a blocking queue so if it is full you will get errors (or block). +mailbox: *Mailbox, + +/// The event loop for the search thread. +loop: xev.Loop, + +/// This can be used to wake up the renderer and force a render safely from +/// any thread. +wakeup: xev.Async, +wakeup_c: xev.Completion = .{}, + +/// This can be used to stop the thread on the next loop iteration. +stop: xev.Async, +stop_c: xev.Completion = .{}, + +/// The timer used for refreshing the terminal state to determine if +/// we have a stale active area, viewport, screen change, etc. This is +/// CPU intensive so we stop doing this under certain conditions. +refresh: xev.Timer, +refresh_c: xev.Completion = .{}, +refresh_active: bool = false, + +/// Search state. Starts as null and is populated when a search is +/// started (a needle is given). +search: ?Search = null, + +/// The options used to initialize this thread. +opts: Options, + +/// Initialize the thread. This does not START the thread. This only sets +/// up all the internal state necessary prior to starting the thread. It +/// is up to the caller to start the thread with the threadMain entrypoint. +pub fn init(alloc: Allocator, opts: Options) !Thread { + // The mailbox for messaging this thread + var mailbox = try Mailbox.create(alloc); + errdefer mailbox.destroy(alloc); + + // Create our event loop. + var loop = try xev.Loop.init(.{}); + errdefer loop.deinit(); + + // This async handle is used to "wake up" the renderer and force a render. + var wakeup_h = try xev.Async.init(); + errdefer wakeup_h.deinit(); + + // This async handle is used to stop the loop and force the thread to end. + var stop_h = try xev.Async.init(); + errdefer stop_h.deinit(); + + // Refresh timer, see comments. + var refresh_h = try xev.Timer.init(); + errdefer refresh_h.deinit(); + + return .{ + .alloc = alloc, + .mailbox = mailbox, + .loop = loop, + .wakeup = wakeup_h, + .stop = stop_h, + .refresh = refresh_h, + .opts = opts, + }; +} + +/// Clean up the thread. This is only safe to call once the thread +/// completes executing; the caller must join prior to this. +pub fn deinit(self: *Thread) void { + self.refresh.deinit(); + self.wakeup.deinit(); + self.stop.deinit(); + self.loop.deinit(); + // Nothing can possibly access the mailbox anymore, destroy it. + self.mailbox.destroy(self.alloc); + + if (self.search) |*s| s.deinit(); +} + +/// The main entrypoint for the thread. +pub fn threadMain(self: *Thread) void { + // Call child function so we can use errors... + self.threadMain_() catch |err| { + // In the future, we should expose this on the thread struct. + log.warn("search thread err={}", .{err}); + }; +} + +fn threadMain_(self: *Thread) !void { + defer log.debug("search thread exited", .{}); + + // Right now, on Darwin, `std.Thread.setName` can only name the current + // thread, and we have no way to get the current thread from within it, + // so instead we use this code to name the thread instead. + if (comptime builtin.os.tag.isDarwin()) { + internal_os.macos.pthread_setname_np(&"search".*); + + // We can run with lower priority than other threads. + const class: internal_os.macos.QosClass = .utility; + if (internal_os.macos.setQosClass(class)) { + log.debug("thread QoS class set class={}", .{class}); + } else |err| { + log.warn("error setting QoS class err={}", .{err}); + } + } + + // Start the async handlers + self.wakeup.wait(&self.loop, &self.wakeup_c, Thread, self, wakeupCallback); + self.stop.wait(&self.loop, &self.stop_c, Thread, self, stopCallback); + + // Send an initial wakeup so we drain our mailbox immediately. + try self.wakeup.notify(); + + // Start the refresh timer + self.startRefreshTimer(); + + // Run + log.debug("starting search thread", .{}); + 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 + // while also listening for messages. + while (true) { + // If our loop is canceled then we drain our messages and quit. + if (self.loop.stopped()) { + while (self.mailbox.pop()) |message| { + log.debug("mailbox message ignored during shutdown={}", .{message}); + } + + return; + } + + const s: *Search = if (self.search) |*s| s else { + // If we're not actively searching, we can block the loop + // until it does some work. + try self.loop.run(.once); + continue; + }; + + // If we have an active search, we always send any pending + // notifications. Even if the search is complete, there may be + // notifications to send. + if (self.opts.event_cb) |cb| { + s.notify( + self.alloc, + cb, + self.opts.event_userdata, + ); + } + + if (s.isComplete()) { + // If our search is complete, there's no more work to do, we + // can block until we have an xev action. + try self.loop.run(.once); + continue; + } + + // Tick the search. This will trigger any event callbacks, lock + // for data loading, etc. + switch (s.tick()) { + // We're complete now when we were not before. Notify! + .complete => {}, + + // Forward progress was made. + .progress => {}, + + // All searches are blocked. Let's grab the lock and feed data. + .blocked => { + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + s.feed(self.alloc, self.opts.terminal); + }, + } + + // We have an active search, so we only want to process messages + // we have but otherwise return immediately so we can continue the + // search. If the above completed the search, we still want to + // go around the loop as quickly as possible to send notifications, + // and then we'll block on the loop next time. + try self.loop.run(.no_wait); + } +} + +/// Drain the mailbox. +fn drainMailbox(self: *Thread) !void { + while (self.mailbox.pop()) |message| { + log.debug("mailbox message={}", .{message}); + switch (message) { + .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.* }); + } + } +} + +/// Change the search term to the given value. +fn changeNeedle(self: *Thread, needle: []const u8) !void { + log.debug("changing search needle to '{s}'", .{needle}); + + // 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. + if (needle.len == 0) return; + + // Setup our search state. + self.search = try .init(self.alloc, needle); + + // We need to grab the terminal lock and do an initial feed. + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + self.search.?.feed(self.alloc, self.opts.terminal); +} + +fn startRefreshTimer(self: *Thread) void { + // Set our active state so it knows we're running. We set this before + // even checking the active state in case we have a pending shutdown. + self.refresh_active = true; + + // If our timer is already active, then we don't have to do anything. + if (self.refresh_c.state() == .active) return; + + // Start the timer which loops + self.refresh.run( + &self.loop, + &self.refresh_c, + REFRESH_INTERVAL, + Thread, + self, + refreshCallback, + ); +} + +fn stopRefreshTimer(self: *Thread) void { + // This will stop the refresh on the next iteration. + self.refresh_active = false; +} + +fn wakeupCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Async.WaitError!void, +) xev.CallbackAction { + _ = r catch |err| { + log.warn("error in wakeup err={}", .{err}); + return .rearm; + }; + + const self = self_.?; + + // When we wake up, we drain the mailbox. Mailbox producers should + // wake up our thread after publishing. + self.drainMailbox() catch |err| + log.warn("error draining mailbox err={}", .{err}); + + return .rearm; +} + +fn stopCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Async.WaitError!void, +) xev.CallbackAction { + _ = r catch unreachable; + self_.?.loop.stop(); + return .disarm; +} + +fn refreshCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Timer.RunError!void, +) xev.CallbackAction { + _ = r catch unreachable; + const self: *Thread = self_ orelse { + // This shouldn't happen so we log it. + log.warn("refresh callback fired without data set", .{}); + return .disarm; + }; + + // Run our feed if we have a search active. + if (self.search) |*s| { + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + s.feed(self.alloc, self.opts.terminal); + } + + // Only continue if we're still active + if (self.refresh_active) self.refresh.run( + &self.loop, + &self.refresh_c, + REFRESH_INTERVAL, + Thread, + self, + refreshCallback, + ); + + return .disarm; +} + +pub const Options = struct { + /// Mutex that must be held while reading/writing the terminal. + mutex: *Mutex, + + /// The terminal data to search. + terminal: *Terminal, + + /// The callback for events from the search thread along with optional + /// userdata. This can be null if you don't want to receive events, + /// which could be useful for a one-time search (although, odd, you + /// should use our search structures directly then). + event_cb: ?EventCallback = null, + event_userdata: ?*anyopaque = null, +}; + +pub const EventCallback = *const fn (event: Event, userdata: ?*anyopaque) void; + +/// The type used for sending messages to the thread. +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: 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 FlattenedHighlight, + + pub const SelectedMatch = struct { + idx: usize, + highlight: FlattenedHighlight, + }; +}; + +/// Search state. +const Search = struct { + /// Active viewport search for the active screen. + viewport: ViewportSearch, + + /// The searchers for all the screens. + screens: std.EnumMap(ScreenSet.Key, ScreenSearch), + + /// 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, + + /// 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, + ) Allocator.Error!Search { + 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_screen = .{ .key = .primary }, + .last_complete = false, + .stale_viewport_matches = true, + }; + } + + pub fn deinit(self: *Search) void { + self.viewport.deinit(); + var it = self.screens.iterator(); + while (it.next()) |entry| entry.value.deinit(); + } + + /// Returns true if all searches on all screens are complete. + pub fn isComplete(self: *Search) bool { + var it = self.screens.iterator(); + while (it.next()) |entry| { + if (!entry.value.state.isComplete()) return false; + } + + return true; + } + + pub const Tick = enum { + /// All searches are complete. + complete, + + /// Progress was made on at least one screen. + progress, + + /// All incomplete searches are blocked on feed. + blocked, + }; + + /// Tick the search forward as much as possible without acquiring + /// the big lock. Returns the overall tick progress. + pub fn tick(self: *Search) Tick { + var result: Tick = .complete; + var it = self.screens.iterator(); + while (it.next()) |entry| { + if (entry.value.tick()) { + result = .progress; + } else |err| switch (err) { + // Ignore... nothing we can do. + error.OutOfMemory => log.warn( + "error ticking screen search key={} err={}", + .{ entry.key, err }, + ), + + // Ignore, good for us. State remains whatever it is. + error.SearchComplete => {}, + + // Ignore, too, progressed + error.FeedRequired => switch (result) { + // If we think we're complete, we're not because we're + // blocked now (nothing made progress). + .complete => result = .blocked, + + // If we made some progress, we remain in progress + // since blocked means no progress at all. + .progress => {}, + + // If we're blocked already then we remain blocked. + .blocked => {}, + }, + } + } + + // log.debug("tick result={}", .{result}); + return result; + } + + /// Grab the mutex and update any state that requires it, such as + /// feeding additional data to the searches or updating the active screen. + pub fn feed( + self: *Search, + alloc: Allocator, + t: *Terminal, + ) void { + // Update our active screen + 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 + // searchers for screens that no longer exist and add searchers + // for screens that do exist but we don't have yet. + { + // Remove screens we have that no longer exist or changed. + var it = self.screens.iterator(); + while (it.next()) |entry| { + const remove: bool = remove: { + // If the screen doesn't exist at all, remove it. + const actual = t.screens.all.get(entry.key) orelse break :remove true; + + // If the screen pointer changed, remove it, the screen + // was totally reinitialized. + break :remove actual != entry.value.screen; + }; + + if (remove) { + entry.value.deinit(); + _ = self.screens.remove(entry.key); + } + } + } + { + // Add screens that exist but we don't have yet. + var it = t.screens.all.iterator(); + while (it.next()) |entry| { + if (self.screens.contains(entry.key)) continue; + self.screens.put(entry.key, ScreenSearch.init( + alloc, + entry.value.*, + self.viewport.needle(), + ) catch |err| switch (err) { + error.OutOfMemory => { + // OOM is probably going to sink the entire ship but + // we can just ignore it and wait on the next + // reconciliation to try again. + log.warn( + "error initializing screen search for key={} err={}", + .{ entry.key, err }, + ); + continue; + }, + }); + } + } + + // 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; + } else |err| switch (err) { + error.OutOfMemory => log.warn( + "error updating viewport search err={}", + .{err}, + ), + } + + // Feed data + var it = self.screens.iterator(); + while (it.next()) |entry| { + if (entry.value.state.needsFeed()) { + entry.value.feed() catch |err| switch (err) { + error.OutOfMemory => log.warn( + "error feeding screen search key={} err={}", + .{ entry.key, err }, + ), + }; + } + } + } + + /// Notify about any changes to the search state. + /// + /// This doesn't require any locking as it only reads internal state. + pub fn notify( + self: *Search, + alloc: Allocator, + cb: EventCallback, + ud: ?*anyopaque, + ) void { + 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_screen.total) { + log.debug("notifying total matches={}", .{total}); + self.last_screen.total = total; + cb(.{ .total_matches = total }, ud); + } + + // Check our viewport matches. If they're stale, we do the + // viewport search now. We do this as part of notify and not + // tick because the viewport search is very fast and doesn't + // require ticked progress or feeds. + if (self.stale_viewport_matches) viewport: { + // We always make stale as false. Even if we fail below + // we require a re-feed to re-search the viewport. The feed + // process will make it stale again. + self.stale_viewport_matches = false; + + 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={}", + .{err}, + ); + + // Reset the viewport so we force an update on the + // next feed. + self.viewport.reset(); + break :viewport; + }, + }; + } + + 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); + } + } +}; + +const TestUserData = struct { + const Self = @This(); + reset: std.Thread.ResetEvent = .{}, + total: usize = 0, + selected: ?Event.SelectedMatch = null, + viewport: []FlattenedHighlight = &.{}, + + 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 }); + defer t.deinit(alloc); + + var stream = t.vtStream(); + defer stream.deinit(); + try stream.nextSlice("Hello, world"); + + var ud: TestUserData = .{}; + defer ud.deinit(); + var thread: Thread = try .init(alloc, .{ + .mutex = &mutex, + .terminal = &t, + .event_cb = &TestUserData.callback, + .event_userdata = &ud, + }); + defer thread.deinit(); + + var os_thread = try std.Thread.spawn( + .{}, + threadMain, + .{&thread}, + ); + + // Start our search + _ = thread.mailbox.push( + .{ .change_needle = try .init( + alloc, + @as([]const u8, "world"), + ) }, + .forever, + ); + try thread.wakeup.notify(); + + // Wait for completion + try ud.reset.timedWait(100 * std.time.ns_per_ms); + + // Stop the thread + try thread.stop.notify(); + os_thread.join(); + + // 1 total matches + try testing.expectEqual(1, ud.total); + try testing.expectEqual(1, ud.viewport.len); + { + const sel = ud.viewport[0].untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 0, + } }, 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).?); + } +} diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig new file mode 100644 index 000000000..236f4c7a6 --- /dev/null +++ b/src/terminal/search/active.zig @@ -0,0 +1,175 @@ +const std = @import("std"); +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 SlidingWindow = @import("sliding_window.zig").SlidingWindow; +const Terminal = @import("../Terminal.zig"); + +/// Searches for a substring within the active area of a PageList. +/// +/// The distinction for "active area" is important because it is the +/// only part of a PageList that is mutable. Therefore, its the only part +/// of the terminal that needs to be repeatedly searched as the contents +/// change. +/// +/// This struct specializes in searching only within that active area, +/// and handling the active area moving as new lines are added to the bottom. +pub const ActiveSearch = struct { + window: SlidingWindow, + + pub fn init( + alloc: Allocator, + needle: []const u8, + ) Allocator.Error!ActiveSearch { + // We just do a forward search since the active area is usually + // pretty small so search results are instant anyways. This avoids + // a small amount of work to reverse things. + var window: SlidingWindow = try .init(alloc, .forward, needle); + errdefer window.deinit(); + return .{ .window = window }; + } + + pub fn deinit(self: *ActiveSearch) void { + self.window.deinit(); + } + + /// Update the active area to reflect the current state of the PageList. + /// + /// This doesn't do the search, it only copies the necessary data + /// to perform the search later. This lets the caller hold the lock + /// on the PageList for a minimal amount of time. + /// + /// This returns the first page (in reverse order) covered by this + /// search. This allows the history search to overlap and search history. + /// There CAN BE duplicates, and this page CAN BE mutable, so the history + /// search results should prune anything that's in the active area. + /// + /// If the return value is null it means the active area covers the entire + /// PageList, currently. + pub fn update( + self: *ActiveSearch, + list: *const PageList, + ) Allocator.Error!?*PageList.List.Node { + // Clear our previous sliding window + self.window.clearAndRetainCapacity(); + + // First up, add enough pages to cover the active area. + var rem: usize = list.rows; + var node_ = list.pages.last; + var last_node: ?*PageList.List.Node = null; + while (node_) |node| : (node_ = node.prev) { + _ = try self.window.append(node); + last_node = node; + + // If we reached our target amount, then this is the last + // page that contains the active area. We go to the previous + // page once more since its the first page of our required + // overlap. + if (rem <= node.data.size.rows) { + node_ = node.prev; + break; + } + + rem -= node.data.size.rows; + } + + // Next, add enough overlap to cover needle.len - 1 bytes (if it + // exists) so we can cover the overlap. + while (node_) |node| : (node_ = node.prev) { + // If the last row of this node isn't wrapped we can't overlap. + const row = node.data.getRow(node.data.size.rows - 1); + if (!row.wrap) break; + + // We could be more accurate here and count bytes since the + // last wrap but its complicated and unlikely multiple pages + // wrap so this should be fine. + const added = try self.window.append(node); + if (added >= self.window.needle.len - 1) break; + } + + // Return the last node we added to our window. + return last_node; + } + + /// 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) ?FlattenedHighlight { + return self.window.next(); + } +}; + +test "simple search" { + 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: ActiveSearch = try .init(alloc, "Fizz"); + defer search.deinit(); + _ = try search.update(&t.screens.active.pages); + + { + 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).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); + } + { + 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).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(search.next() == null); +} + +test "clear screen and search" { + 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: ActiveSearch = try .init(alloc, "Fizz"); + defer search.deinit(); + _ = 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"); + _ = 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); +} diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig new file mode 100644 index 000000000..4bfd241e7 --- /dev/null +++ b/src/terminal/search/pagelist.zig @@ -0,0 +1,441 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const testing = std.testing; +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 Terminal = @import("../Terminal.zig"); +const SlidingWindow = @import("sliding_window.zig").SlidingWindow; + +/// Searches for a term in a PageList structure. +/// +/// This searches in reverse order starting from the given node. +/// +/// This assumes that nodes do not change contents. For nodes that change +/// contents, look at ActiveSearch, which is designed to re-search the active +/// area since it assumed to change. When integrating ActiveSearch with +/// PageListSearch, the caller should start the PageListSearch from the +/// returned node from ActiveSearch.update(). +/// +/// Concurrent access to a PageList or nodes in a PageList are not allowed, +/// so the caller should ensure that necessary locks are held. Each function +/// documents whether it accesses the PageList or not. For example, you can +/// safely call `next()` without holding a lock, but you must hold a lock +/// while calling `feed()`. +pub const PageListSearch = struct { + /// The list we're searching. + list: *PageList, + + /// The sliding window of page contents and nodes to search. + window: SlidingWindow, + + /// The tracked pin for our current position in the pagelist. This + /// will always point to the CURRENT node we're searching from so that + /// we can track if we move. + pin: *Pin, + + /// Initialize the page list search. The needle is copied so it can + /// be freed immediately. + /// + /// Accesses the PageList/Node so the caller must ensure it is safe + /// to do so if there is any concurrent access. + pub fn init( + alloc: Allocator, + needle: []const u8, + list: *PageList, + start: *PageList.List.Node, + ) Allocator.Error!PageListSearch { + // We put a tracked pin into the node that we're starting from. + // By using a tracked pin, we can keep our pagelist references safe + // because if the pagelist prunes pages, the tracked pin will + // be moved somewhere safe. + const pin = try list.trackPin(.{ + .node = start, + .y = start.data.size.rows - 1, + .x = start.data.size.cols - 1, + }); + errdefer list.untrackPin(pin); + + // Create our sliding window we'll use for searching. + var window: SlidingWindow = try .init(alloc, .reverse, needle); + errdefer window.deinit(); + + // We always feed our initial page data into the window, because + // we have the lock anyways and this lets our `pin` point to our + // current node and feed to work properly. + _ = try window.append(start); + + return .{ + .list = list, + .window = window, + .pin = pin, + }; + } + + /// Modifies the PageList (to untrack a pin) so the caller must ensure + /// that it is safe to do so. + pub fn deinit(self: *PageListSearch) void { + self.window.deinit(); + self.list.untrackPin(self.pin); + } + + /// Return the next match in the loaded page nodes. If this returns + /// null then the PageList search needs to be fed the next node(s). + /// Call, `feed` to do this. + /// + /// Beware that the selection returned may point to a node that + /// is freed if the caller does not hold necessary locks on the + /// PageList while searching. The pins should be validated prior to + /// final use. + /// + /// This does NOT access the PageList, so it can be called without + /// a lock held. + pub fn next(self: *PageListSearch) ?FlattenedHighlight { + return self.window.next(); + } + + /// Feed more data to the sliding window from the pagelist. This will + /// feed enough data to cover at least one match (needle length) if it + /// exists; this doesn't perform the search, it only feeds data. + /// + /// This accesses nodes in the PageList, so the caller must ensure + /// it is safe to do so (i.e. hold necessary locks). + /// + /// 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; + + // Start at our previous node and then continue adding until we + // get our desired amount of data. + var node_: ?*PageList.List.Node = self.pin.node.prev; + while (node_) |node| : (node_ = node.prev) { + rem -|= try self.window.append(node); + + // Move our tracked pin to the new node. + self.pin.node = node; + + if (rem == 0) break; + } + + // True if we fed any data. + return rem < self.window.needle.len; + } +}; + +test "simple search" { + 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: PageListSearch = try .init( + alloc, + "Fizz", + &t.screens.active.pages, + t.screens.active.pages.pages.last.?, + ); + defer search.deinit(); + + { + 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).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); + } + { + 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).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(search.next() == null); + + // We should not be able to feed since we have one page + try testing.expect(!try search.feed()); +} + +test "feed multiple pages with matches" { + 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(); + + // Fill up first page + const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("Fizz"); + try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); + + // Create second page + try s.nextSlice("\r\n"); + try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); + try s.nextSlice("Buzz\r\nFizz"); + + var search: PageListSearch = try .init( + alloc, + "Fizz", + &t.screens.active.pages, + t.screens.active.pages.pages.last.?, + ); + defer search.deinit(); + + // First match on the last page + const sel1 = search.next(); + try testing.expect(sel1 != null); + try testing.expect(search.next() == null); + + // Feed should succeed and load the first page + try testing.expect(try search.feed()); + + // Now we should find the match on the first page + const sel2 = search.next(); + try testing.expect(sel2 != null); + try testing.expect(search.next() == null); + + // No more pages to feed + try testing.expect(!try search.feed()); +} + +test "feed multiple pages no matches" { + 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(); + + // Fill up first page + const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("Hello"); + + // Create second page + try s.nextSlice("\r\n"); + try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); + try s.nextSlice("World"); + + var search: PageListSearch = try .init( + alloc, + "Nope", + &t.screens.active.pages, + t.screens.active.pages.pages.last.?, + ); + defer search.deinit(); + + // No matches on last page + try testing.expect(search.next() == null); + + // Feed first page + try testing.expect(try search.feed()); + + // Still no matches + try testing.expect(search.next() == null); + + // No more pages + try testing.expect(!try search.feed()); +} + +test "feed iteratively through multiple matches" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + + // Fill first page with a match at the end + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("Page1Test"); + try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); + + // Create second page with a match + try s.nextSlice("\r\n"); + try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); + try s.nextSlice("Page2Test"); + + var search: PageListSearch = try .init( + alloc, + "Test", + &t.screens.active.pages, + t.screens.active.pages.pages.last.?, + ); + defer search.deinit(); + + // Match on page 2 + try testing.expect(search.next() != null); + try testing.expect(search.next() == null); + + // Feed page 1 + try testing.expect(try search.feed()); + try testing.expect(search.next() != null); + try testing.expect(search.next() == null); + + // No more pages + try testing.expect(!try search.feed()); +} + +test "feed with match spanning page boundary" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + + // Fill first page ending with "Te" + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + for (0..t.screens.active.pages.cols - 2) |_| try s.nextSlice("x"); + try s.nextSlice("Te"); + try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); + + // Second page starts with "st" + try s.nextSlice("st"); + try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); + + var search: PageListSearch = try .init( + alloc, + "Test", + &t.screens.active.pages, + t.screens.active.pages.pages.last.?, + ); + defer search.deinit(); + + // No complete match on last page alone (only has "st") + try testing.expect(search.next() == null); + + // Feed first page - this should give us enough data to find "Test" + try testing.expect(try search.feed()); + + // Should find the spanning match + 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 = .init(sel.start, sel.end, false) }, + ); + defer alloc.free(str); + try testing.expectEqualStrings(str, "Test"); + } + + // No more matches + try testing.expect(search.next() == null); + + // No more pages + try testing.expect(!try search.feed()); +} + +test "feed with match spanning page boundary with newline" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + + // Fill first page ending with "Te" + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + for (0..t.screens.active.pages.cols - 2) |_| try s.nextSlice("x"); + try s.nextSlice("Te"); + try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); + + // Second page starts with "st" + try s.nextSlice("\r\n"); + try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); + try s.nextSlice("st"); + + var search: PageListSearch = try .init( + alloc, + "Test", + &t.screens.active.pages, + t.screens.active.pages.pages.last.?, + ); + defer search.deinit(); + + // Should not find any matches since we broke with an explicit newline. + try testing.expect(search.next() == null); + try testing.expect(try search.feed()); + 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 new file mode 100644 index 000000000..0ae7f8a1f --- /dev/null +++ b/src/terminal/search/screen.zig @@ -0,0 +1,1335 @@ +const std = @import("std"); +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 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. +/// +/// Unlike our lower-level searchers (like ActiveSearch and PageListSearch), +/// this will cache and store all search results so the caller can re-access +/// them as needed. This structure does this because it is intended to help +/// the caller handle the case where the Screen is changing while the user +/// is searching. +/// +/// An inactive screen can continue to be searched in the background, and when +/// screen state changes, the renderer/caller can access the existing search +/// results without needing to re-search everything. This prevents a particularly +/// nasty UX where going to alt screen (e.g. neovim) and then back would +/// restart the full scrollback search. +pub const ScreenSearch = struct { + /// The screen being searched. + screen: *Screen, + + /// The active area search state + active: ActiveSearch, + + /// The history (scrollback) search state. May be null if there is + /// no history yet. + history: ?HistorySearch, + + /// 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(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 { + /// The actual searcher state. + searcher: PageListSearch, + + /// The pin for the first node that this searcher is searching from. + /// We use this when the active area changes to find the diff between + /// the top of the new active area and the previous start point + /// to determine if we need to search more history. + start_pin: *Pin, + + pub fn deinit(self: *HistorySearch, screen: *Screen) void { + self.searcher.deinit(); + screen.pages.untrackPin(self.start_pin); + } + }; + + /// Search state machine + const State = enum { + /// Currently searching the active area + active, + + /// Currently searching the history area + history, + + /// History search is waiting for more data to be fed before + /// it can progress. + history_feed, + + /// Search is complete given the current terminal state. + complete, + + pub fn isComplete(self: State) bool { + return switch (self) { + .complete => true, + else => false, + }; + } + + 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, + }; + } + }; + + // Initialize a screen search for the given screen and needle. + pub fn init( + alloc: Allocator, + screen: *Screen, + needle_unowned: []const u8, + ) 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, + .active_results = .empty, + .history_results = .empty, + }; + errdefer result.deinit(); + + // Update our initial active area state + try result.reloadActive(); + + return result; + } + + pub fn deinit(self: *ScreenSearch) void { + 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); + } + + fn allocator(self: *ScreenSearch) Allocator { + return self.active.window.alloc; + } + + /// The needle that this search is using. + pub fn needle(self: *const ScreenSearch) []const u8 { + assert(self.active.window.direction == .forward); + return self.active.window.needle; + } + + /// Returns the total number of matches found so far. + pub fn matchesLen(self: *const ScreenSearch) usize { + return self.active_results.items.len + self.history_results.items.len; + } + + /// Returns all matches as an owned slice (caller must free). + /// The matches are ordered from most recent to oldest (e.g. bottom + /// of the screen to top of the screen). + pub fn matches( + self: *ScreenSearch, + alloc: Allocator, + ) Allocator.Error![]FlattenedHighlight { + const active_results = self.active_results.items; + const history_results = self.history_results.items; + const results = try alloc.alloc( + FlattenedHighlight, + active_results.len + history_results.len, + ); + errdefer alloc.free(results); + + // Active does a forward search, so we add the active results then + // reverse them. There are usually not many active results so this + // is fast enough compared to adding them in reverse order. + assert(self.active.window.direction == .forward); + @memcpy( + results[0..active_results.len], + active_results, + ); + std.mem.reverse(FlattenedHighlight, results[0..active_results.len]); + + // History does a backward search, so we can just append them + // after. + @memcpy( + results[active_results.len..], + history_results, + ); + + return results; + } + + /// Search the full screen state. This will block until the search + /// is complete. For performance, it is recommended to use `tick` and + /// `feed` to incrementally make progress on the search instead. + pub fn searchAll(self: *ScreenSearch) Allocator.Error!void { + while (true) { + self.tick() catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.FeedRequired => try self.feed(), + error.SearchComplete => return, + }; + } + } + + pub const TickError = Allocator.Error || error{ + FeedRequired, + SearchComplete, + }; + + /// Make incremental progress on the search without accessing any + /// screen state (so no lock is required). + /// + /// This will return error.FeedRequired if the search cannot make progress + /// without being fed more data. In this case, the caller should call + /// the `feed` function to provide more data to the searcher. + /// + /// This will return error.SearchComplete if the search is fully complete. + /// This is to signal to the caller that it can move to a more efficient + /// sleep/wait state until there is more work to do (e.g. new data to feed). + pub fn tick(self: *ScreenSearch) TickError!void { + switch (self.state) { + .active => try self.tickActive(), + .history => try self.tickHistory(), + .history_feed => return error.FeedRequired, + .complete => return error.SearchComplete, + } + } + + /// 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; + return; + }; + + // Future: we may want to feed multiple pages at once here to + // lower the frequency of lock acquisitions. + 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; + } + + // Depending on our state handle where feed goes + switch (self.state) { + // If we're searching active or history, then feeding doesn't + // change the state. + .active, .history => {}, + + // Feed goes back to searching history. + .history_feed => self.state = .history, + + // If we're complete then the feed call above should always + // return false and we can't reach this. + .complete => unreachable, + } + } + + 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()) |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. + 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. + self.state = .history; + } + + fn tickHistory(self: *ScreenSearch) Allocator.Error!void { + const history: *HistorySearch = if (self.history) |*h| h else { + // No history to search, we're done. + self.state = .complete; + return; + }; + + // 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()) |hl| { + // Ignore selections that are found within the starting + // node since those are covered by the active area search. + if (hl.chunks.items(.node)[0] == history.start_pin.node) continue; + + // Same note as tickActive for error handling. + 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. + self.state = .history_feed; + } + + /// Reload the active area because it has changed. + /// + /// Since it is very fast, this will also do the full active area + /// search again, too. This avoids any complexity around the search + /// state machine. + /// + /// 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 + // cause new pages to move into our history. If there are new + // pages then we need to re-search the pages and add it to + // our history results. + + const history_: ?*HistorySearch = if (self.history) |*h| state: { + // If our start pin became garbage, it means we pruned all + // the way up through it, so we have no history anymore. + // Reset our history state. + 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; + } + + break :state h; + } else null; + + const history = history_ orelse { + // No history search yet, but we now have history. So let's + // initialize. + + var search: PageListSearch = try .init( + alloc, + self.needle(), + list, + history_node, + ); + errdefer search.deinit(); + + const pin = try list.trackPin(.{ .node = history_node }); + errdefer list.untrackPin(pin); + + self.history = .{ + .searcher = search, + .start_pin = pin, + }; + + // We don't need to update any history since we had no history + // before, so we can break out of the whole conditional. + break :history; + }; + + if (history.start_pin.node == history_node) { + // No change in the starting node, we're done. + break :history; + } + + // Do a forward search from our prior node to this one. We + // 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. + var window: SlidingWindow = try .init( + alloc, + .forward, + self.needle(), + ); + defer window.deinit(); + while (true) { + _ = try window.append(history.start_pin.node); + if (history.start_pin.node == history_node) break; + const next = history.start_pin.node.next orelse break; + history.start_pin.node = next; + } + assert(history.start_pin.node == history_node); + + var results: std.ArrayList(FlattenedHighlight) = try .initCapacity( + alloc, + self.history_results.items.len, + ); + errdefer results.deinit(alloc); + 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(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 + // we can move into a better state. + .active => try self.tickActive(), + + // Otherwise, just tick it and move back to whatever state + // we were in. + else => { + const old_state = self.state; + defer self.state = old_state; + 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; + } +}; + +test "simple search" { + 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(); + try testing.expectEqual(2, search.active_results.items.len); + // We don't test history results since there is overlap + + // Get all matches + const matches = try search.matches(alloc); + defer alloc.free(matches); + try testing.expectEqual(2, matches.len); + + { + const sel = matches[0].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).?); + } + { + const sel = matches[1].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 "simple search 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("hello."); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(0, search.active_results.items.len); + + // Get all matches + const matches = try search.matches(alloc); + defer alloc.free(matches); + try testing.expectEqual(1, matches.len); + + { + const sel = matches[0].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 "reload active with history change" { + 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"); + + // Start up our search which will populate our initial active area. + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + { + const matches = try search.matches(alloc); + defer alloc.free(matches); + try testing.expectEqual(1, matches.len); + } + + // Grow into two pages so our history pin will move. + while (list.totalPages() < 2) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + try s.nextSlice("2Fizz"); + + // Active area changed so reload + try search.reloadActive(); + try search.searchAll(); + + // Get all matches + { + const matches = try search.matches(alloc); + defer alloc.free(matches); + try testing.expectEqual(2, matches.len); + { + const sel = matches[1].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).?); + } + { + const sel = matches[0].untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 1, + } }, 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).?); + } + } + + // Reset the screen which will make our pin garbage. + t.fullReset(); + try s.nextSlice("WeFizzing"); + try search.reloadActive(); + try search.searchAll(); + + { + const matches = try search.matches(alloc); + defer alloc.free(matches); + try testing.expectEqual(1, matches.len); + { + const sel = matches[0].untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 0, + } }, 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).?); + } + } +} + +test "active change contents" { + 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("Fuzz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(1, search.active_results.items.len); + + // 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("Bang\r\nFizz\r\nHello!"); + + try search.reloadActive(); + try search.searchAll(); + try testing.expectEqual(1, search.active_results.items.len); + + // Get all matches + const matches = try search.matches(alloc); + defer alloc.free(matches); + try testing.expectEqual(1, matches.len); + + { + const sel = matches[0].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 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 new file mode 100644 index 000000000..3d64042ce --- /dev/null +++ b/src/terminal/search/sliding_window.zig @@ -0,0 +1,1639 @@ +const std = @import("std"); +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 +/// (2) we've accounted for overlaps across pages to fit the needle. +/// +/// The sliding window is first initialized empty. Pages are then appended +/// in the order to search them. The sliding window supports both a forward +/// and reverse order specified via `init`. The pages should be appended +/// in the correct order matching the search direction. +/// +/// All appends grow the window. The window is only pruned when a search +/// is done (positive or negative match) via `next()`. +/// +/// To avoid unnecessary memory growth, the recommended usage is to +/// call `next()` until it returns null and then `append` the next page +/// and repeat the process. This will always maintain the minimum +/// required memory to search for the needle. +/// +/// The caller is responsible for providing the pages and ensuring they're +/// in the proper order. The SlidingWindow itself doesn't own the pages, but +/// it will contain pointers to them in order to return selections. If any +/// pages become invalid, the caller should clear the sliding window and +/// start over. +pub const SlidingWindow = struct { + /// The allocator to use for all the data within this window. We + /// store this rather than passing it around because its already + /// part of multiple elements (eg. Meta's CellMap) and we want to + /// ensure we always use a consistent allocator. Additionally, only + /// a small amount of sliding windows are expected to be in use + /// at any one time so the memory overhead isn't that large. + alloc: Allocator, + + /// The data buffer is a circular buffer of u8 that contains the + /// encoded page text that we can use to search for the needle. + data: DataBuf, + + /// The meta buffer is a circular buffer that contains the metadata + /// about the pages we're searching. This usually isn't that large + /// so callers must iterate through it to find the offset to map + /// 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. + data_offset: usize = 0, + + /// The needle we're searching for. Does own the memory. + needle: []const u8, + + /// The search direction. If the direction is forward then pages should + /// be appended in forward linked list order from the PageList. If the + /// direction is reverse then pages should be appended in reverse order. + /// + /// This is important because in most cases, a reverse search is going + /// to be more desirable to search from the end of the active area + /// backwards so more recent data is found first. Supporting both is + /// trivial though and will let us do more complex optimizations in the + /// future (e.g. starting from the viewport and doing a forward/reverse + /// concurrently from that point). + direction: Direction, + + /// A buffer to store the overlap search data. This is used to search + /// overlaps between pages where the match starts on one page and + /// ends on another. The length is always `needle.len * 2`. + overlap_buf: []u8, + + const Direction = enum { forward, reverse }; + const DataBuf = CircBuf(u8, 0); + 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 { + self.cell_map.deinit(alloc); + } + }; + + pub fn init( + alloc: Allocator, + direction: Direction, + needle_unowned: []const u8, + ) Allocator.Error!SlidingWindow { + var data = try DataBuf.init(alloc, 0); + errdefer data.deinit(alloc); + + var meta = try MetaBuf.init(alloc, 0); + errdefer meta.deinit(alloc); + + const needle = try alloc.dupe(u8, needle_unowned); + errdefer alloc.free(needle); + switch (direction) { + .forward => {}, + .reverse => std.mem.reverse(u8, needle), + } + + const overlap_buf = try alloc.alloc(u8, needle.len * 2); + errdefer alloc.free(overlap_buf); + + return .{ + .alloc = alloc, + .data = data, + .meta = meta, + .chunk_buf = .empty, + .needle = needle, + .direction = direction, + .overlap_buf = overlap_buf, + }; + } + + 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); + while (meta_it.next()) |meta| meta.deinit(self.alloc); + self.meta.deinit(self.alloc); + } + + /// Clear all data but retain allocated capacity. + pub fn clearAndRetainCapacity(self: *SlidingWindow) void { + var meta_it = self.meta.iterator(.forward); + while (meta_it.next()) |meta| meta.deinit(self.alloc); + self.meta.clear(); + self.data.clear(); + self.data_offset = 0; + } + + /// Search the window for the next occurrence of the needle. As + /// the window moves, the window will prune itself while maintaining + /// the invariant that the window is always big enough to contain + /// the needle. + /// + /// 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(); + if (data_len < self.needle.len) return null; + + break :slices self.data.getPtrSlice( + self.data_offset, + data_len - self.data_offset, + ); + }; + + // Search the first slice for the needle. + if (std.ascii.indexOfIgnoreCase(slices[0], self.needle)) |idx| { + return self.highlight( + idx, + self.needle.len, + ); + } + + // Search the overlap buffer for the needle. + if (slices[0].len > 0 and slices[1].len > 0) overlap: { + // Get up to needle.len - 1 bytes from each side (as much as + // we can) and store it in the overlap buffer. + const prefix: []const u8 = prefix: { + const len = @min(slices[0].len, self.needle.len - 1); + const idx = slices[0].len - len; + break :prefix slices[0][idx..]; + }; + const suffix: []const u8 = suffix: { + const len = @min(slices[1].len, self.needle.len - 1); + break :suffix slices[1][0..len]; + }; + const overlap_len = prefix.len + suffix.len; + assert(overlap_len <= self.overlap_buf.len); + @memcpy(self.overlap_buf[0..prefix.len], prefix); + @memcpy(self.overlap_buf[prefix.len..overlap_len], suffix); + + // Search the overlap + 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.highlight( + slices[0].len - prefix.len + idx, + self.needle.len, + ); + } + + // Search the last slice for the needle. + 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. + prune: { + var meta_it = self.meta.iterator(.reverse); + var saved: usize = 0; + while (meta_it.next()) |meta| { + const needed = self.needle.len - 1 - saved; + if (meta.cell_map.items.len >= needed) { + // We save up to this meta. We set our data offset + // to exactly where it needs to be to continue + // searching. + self.data_offset = meta.cell_map.items.len - needed; + break; + } + + saved += meta.cell_map.items.len; + } else { + // If we exited the while loop naturally then we + // never got the amount we needed and so there is + // nothing to prune. + assert(saved < self.needle.len - 1); + break :prune; + } + + const prune_count = self.meta.len() - meta_it.idx; + if (prune_count == 0) { + // This can happen if we need to save up to the first + // meta value to retain our window. + break :prune; + } + + // We can now delete all the metas up to but NOT including + // the meta we found through meta_it. + meta_it = self.meta.iterator(.forward); + var prune_data_len: usize = 0; + for (0..prune_count) |_| { + const meta = meta_it.next().?; + prune_data_len += meta.cell_map.items.len; + meta.deinit(self.alloc); + } + self.meta.deleteOldest(prune_count); + self.data.deleteOldest(prune_data_len); + } + + // Our data offset now moves to needle.len - 1 from the end so + // that we can handle the overlap case. + self.data_offset = self.data.len() - self.needle.len + 1; + + self.assertIntegrity(); + return null; + } + + /// 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 highlight( + self: *SlidingWindow, + start_offset: usize, + len: usize, + ) terminal.highlight.Flattened { + const start = start_offset + self.data_offset; + const end = start + len - 1; + if (comptime std.debug.runtime_safety) { + assert(start < self.data.len()); + assert(start + len <= self.data.len()); + } + + // Clear our previous chunk buffer to store this result + self.chunk_buf.clearRetainingCapacity(); + var result: terminal.highlight.Flattened = .empty; + + // 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, + }, + + /// 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.prune.data + 1; + + // 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. + 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); + } + 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.prune.data > 0); + self.data.deleteOldest(tl.prune.data); + } + + 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); + + 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); + + // 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; + }, + } + + // 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 + /// the sliding window; data isn't pruned until it is consumed + /// via a search (via next()). + /// + /// Returns the number of bytes of content added to the sliding window. + /// The total bytes will be larger since this omits metadata, but it is + /// an accurate measure of the text content size added. + pub fn append( + self: *SlidingWindow, + node: *PageList.List.Node, + ) Allocator.Error!usize { + // Initialize our metadata for the node. + var meta: Meta = .{ + .node = node, + .serial = node.serial, + .cell_map = .empty, + }; + errdefer meta.deinit(self.alloc); + + // This is suboptimal but we need to encode the page once to + // temporary memory, and then copy it into our circular buffer. + // In the future, we should benchmark and see if we can encode + // directly into the circular buffer. + var encoded: std.Io.Writer.Allocating = .init(self.alloc); + defer encoded.deinit(); + + // Encode the page into the buffer. + const formatter: PageFormatter = formatter: { + var formatter: PageFormatter = .init(&meta.node.data, .{ + .emit = .plain, + .unwrap = true, + }); + formatter.point_map = .{ + .alloc = self.alloc, + .map = &meta.cell_map, + }; + break :formatter formatter; + }; + formatter.format(&encoded.writer) catch { + // writer uses anyerror but the only realistic error on + // an ArrayList is out of memory. + return error.OutOfMemory; + }; + assert(meta.cell_map.items.len == encoded.written().len); + + // If the node we're adding isn't soft-wrapped, we add the + // trailing newline. + const row = node.data.getRow(node.data.size.rows - 1); + if (!row.wrap) { + encoded.writer.writeByte('\n') catch return error.OutOfMemory; + try meta.cell_map.append( + self.alloc, + meta.cell_map.getLastOrNull() orelse .{ + .x = 0, + .y = 0, + }, + ); + } + + // Get our written data. If we're doing a reverse search then we + // need to reverse all our encodings. + const written = encoded.written(); + switch (self.direction) { + .forward => {}, + .reverse => { + std.mem.reverse(u8, written); + std.mem.reverse(point.Coordinate, meta.cell_map.items); + }, + } + + // 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. + self.data.appendSliceAssumeCapacity(written); + self.meta.appendAssumeCapacity(meta); + + self.assertIntegrity(); + return written.len; + } + + /// Only for tests! + fn testChangeNeedle(self: *SlidingWindow, new: []const u8) void { + assert(new.len == self.needle.len); + self.alloc.free(self.needle); + self.needle = self.alloc.dupe(u8, new) catch unreachable; + } + + fn assertIntegrity(self: *const SlidingWindow) void { + if (comptime !std.debug.runtime_safety) return; + + // We don't run integrity checks on Valgrind because its soooooo slow, + // Valgrind is our integrity checker, and we run these during unit + // tests (non-Valgrind) anyways so we're verifying anyways. + if (std.valgrind.runningOnValgrind() > 0) return; + + // Integrity check: verify our data matches our metadata exactly. + var meta_it = self.meta.iterator(.forward); + var data_len: usize = 0; + while (meta_it.next()) |m| data_len += m.cell_map.items.len; + assert(data_len == self.data.len()); + + // Integrity check: verify our data offset is within bounds. + assert(self.data.len() == 0 or self.data_offset < self.data.len()); + } +}; + +test "SlidingWindow empty on init" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "boo!"); + defer w.deinit(); + try testing.expectEqual(0, w.data.len()); + try testing.expectEqual(0, w.meta.len()); +} + +test "SlidingWindow single append" { + 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 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); +} + +test "SlidingWindow single append no match" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "nope!"); + 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); + + // No matches + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // Should still keep the page + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages" { + 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 = 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 = 79, + .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 = 10, + .y = 23, + } }, 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); +} + +test "SlidingWindow two pages match across boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "hello, world"); + 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("hell"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("o, world!"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find a match + { + 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 = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We shouldn't prune because we don't have enough space + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow two pages no match across boundary with newline" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "hello, world"); + 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("hell"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\no, world!"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should NOT find a match + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We shouldn't prune because we don't have enough space + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow two pages no match across boundary with newline reverse" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "hello, world"); + 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("hell"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\no, world!"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Add both pages in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should NOT find a match + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow two pages no match prunes first page" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "nope!"); + 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 nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We should've pruned our page because the second page + // has enough text to contain our needle. + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages no match keeps both pages" { + const testing = std.testing; + const alloc = testing.allocator; + + 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!"); + + // Imaginary needle for search. Doesn't match! + var needle_list: std.ArrayList(u8) = .empty; + defer needle_list.deinit(alloc); + try needle_list.appendNTimes(alloc, 'x', first_page_rows * s.pages.cols); + const needle: []const u8 = needle_list.items; + + var w: SlidingWindow = try .init(alloc, .forward, needle); + defer w.deinit(); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // No pruning because both pages are needed to fit needle. + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow single append across circular buffer boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "abc"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // our implementation changes our test will fail. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + w.testChangeNeedle("boo"); + + // Add new page, now wraps + _ = try w.append(node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + 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 = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append match on boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "abcd"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); + + // We need to surgically modify the last row to be soft-wrapped + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + node.data.getRow(node.data.size.rows - 1).wrap = true; + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // our implementation changes our test will fail. + _ = try w.append(node); + _ = try w.append(node); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + w.testChangeNeedle("boo!"); + + // Add new page, now wraps + _ = try w.append(node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "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 = 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).?); + } + { + 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).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append no match reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "nope!"); + 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); + + // No matches + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // Should still keep the page + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "boo!"); + 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 in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should find two matches (in reverse order) + { + 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 = 10, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + { + 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 = 79, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow two pages match across boundary reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "hello, world"); + 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 "hell" + 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("hell"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("o, world!"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Add both pages in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should find a match + { + 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 = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // In reverse mode, the last appended meta (first original page) is large + // enough to contain needle.len - 1 bytes, so pruning occurs + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages no match prunes first page reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "nope!"); + 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 in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should find nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We should've pruned our page because the second page + // has enough text to contain our needle. + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages no match keeps both pages reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + 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!"); + + // Imaginary needle for search. Doesn't match! + var needle_list: std.ArrayList(u8) = .empty; + defer needle_list.deinit(alloc); + try needle_list.appendNTimes(alloc, 'x', first_page_rows * s.pages.cols); + const needle: []const u8 = needle_list.items; + + var w: SlidingWindow = try .init(alloc, .reverse, needle); + defer w.deinit(); + + // Add both pages in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should find nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // No pruning because both pages are needed to fit needle. + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow single append across circular buffer boundary reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "abc"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // our implementation changes our test will fail. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + // testChangeNeedle doesn't reverse, so pass reversed needle for reverse mode + w.testChangeNeedle("oob"); + + // Add new page, now wraps + _ = try w.append(node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + 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 = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append match on boundary reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "abcd"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); + + // We need to surgically modify the last row to be soft-wrapped + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + node.data.getRow(node.data.size.rows - 1).wrap = true; + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // our implementation changes our test will fail. + _ = try w.append(node); + _ = try w.append(node); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + // testChangeNeedle doesn't reverse, so pass reversed needle for reverse mode + w.testChangeNeedle("!oob"); + + // Add new page, now wraps + _ = try w.append(node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, 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 new file mode 100644 index 000000000..76deebcec --- /dev/null +++ b/src/terminal/search/viewport.zig @@ -0,0 +1,384 @@ +const std = @import("std"); +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 SlidingWindow = @import("sliding_window.zig").SlidingWindow; +const Terminal = @import("../Terminal.zig"); + +/// Searches for a substring within the viewport of a PageList. +/// +/// This contains logic to efficiently detect when the viewport changes +/// and only re-search when necessary. +/// +/// The specialization for "viewport" is because the viewport is the +/// only part of the search where the user can actively see the results, +/// usually. In that case, it is more efficient to re-search only the +/// viewport rather than store all the results for the entire screen. +/// +/// Note that this searches all the pages that viewport covers, so +/// this can include extra matches outside the viewport if the data +/// lives in the same page. +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, + ) Allocator.Error!ViewportSearch { + // We just do a forward search since the viewport is usually + // pretty small so search results are instant anyways. This avoids + // 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, + .active_dirty = null, + }; + } + + pub fn deinit(self: *ViewportSearch) void { + if (self.fingerprint) |*fp| fp.deinit(self.window.alloc); + self.window.deinit(); + } + + /// Reset our fingerprint and results so that the next update will + /// always re-search. + pub fn reset(self: *ViewportSearch) void { + if (self.fingerprint) |*fp| fp.deinit(self.window.alloc); + self.fingerprint = null; + self.window.clearAndRetainCapacity(); + } + + /// The needle that this search is using. + pub fn needle(self: *const ViewportSearch) []const u8 { + assert(self.window.direction == .forward); + return self.window.needle; + } + + /// Update the sliding window to reflect the current viewport. This + /// will do nothing if the viewport hasn't changed since the last + /// search. + /// + /// The PageList must be safe to read throughout the lifetime of this + /// function. + /// + /// Returns true if the viewport changed and a re-search is needed. + /// Returns false if the viewport is unchanged. + pub fn update( + self: *ViewportSearch, + list: *PageList, + ) Allocator.Error!bool { + // See if our viewport changed + var fingerprint: Fingerprint = try .init(self.window.alloc, list); + if (self.fingerprint) |*old| { + if (old.eql(fingerprint)) match: { + // 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 (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 + fingerprint.deinit(self.window.alloc); + return false; + } + + old.deinit(self.window.alloc); + self.fingerprint = null; + } + assert(self.fingerprint == null); + self.fingerprint = fingerprint; + errdefer { + fingerprint.deinit(self.window.alloc); + 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(); + + // Add enough overlap to cover needle.len - 1 bytes (if it + // exists) so we can cover the overlap. We only do this for the + // soft-wrapped prior pages. + var node_ = fingerprint.nodes[0].prev; + var added: usize = 0; + while (node_) |node| : (node_ = node.prev) { + // If the last row of this node isn't wrapped we can't overlap. + const row = node.data.getRow(node.data.size.rows - 1); + if (!row.wrap) break; + + // We could be more accurate here and count bytes since the + // last wrap but its complicated and unlikely multiple pages + // wrap so this should be fine. + added += try self.window.append(node); + if (added >= self.window.needle.len - 1) break; + } + + // We can use our fingerprint nodes to initialize our sliding + // window, since we already traversed the viewport once. + for (fingerprint.nodes) |node| { + _ = try self.window.append(node); + } + + // Add any trailing overlap as well. + trailing: { + const end: *PageList.List.Node = fingerprint.nodes[fingerprint.nodes.len - 1]; + if (!end.data.getRow(end.data.size.rows - 1).wrap) break :trailing; + + node_ = end.next; + added = 0; + while (node_) |node| : (node_ = node.next) { + added += try self.window.append(node); + if (added >= self.window.needle.len - 1) break; + + // If this row doesn't wrap, then we can quit + const row = node.data.getRow(node.data.size.rows - 1); + if (!row.wrap) break; + } + } + + return true; + } + + /// 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) ?FlattenedHighlight { + return self.window.next(); + } + + /// Viewport fingerprint so we can detect when the viewport moves. + const Fingerprint = struct { + /// The nodes that make up the viewport. We need to flatten this + /// to a single list because we can't safely traverse the cached values + /// because the page nodes may be invalid. All that is safe is comparing + /// the actual pointer values. + nodes: []const *PageList.List.Node, + + pub fn init(alloc: Allocator, pages: *PageList) Allocator.Error!Fingerprint { + var list: std.ArrayList(*PageList.List.Node) = .empty; + defer list.deinit(alloc); + + // Get our viewport area. Bottom right of a viewport can never + // fail. + const tl = pages.getTopLeft(.viewport); + const br = pages.getBottomRight(.viewport).?; + + var it = tl.pageIterator(.right_down, br); + while (it.next()) |chunk| try list.append(alloc, chunk.node); + return .{ .nodes = try list.toOwnedSlice(alloc) }; + } + + pub fn deinit(self: *Fingerprint, alloc: Allocator) void { + alloc.free(self.nodes); + } + + pub fn eql(self: Fingerprint, other: Fingerprint) bool { + if (self.nodes.len != other.nodes.len) return false; + for (self.nodes, other.nodes) |a, b| { + if (a != b) return false; + } + return true; + } + }; +}; + +test "simple search" { + 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(); + try testing.expect(try search.update(&t.screens.active.pages)); + + // Viewport contains active so update should always re-search. + 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 = 0, + } }, 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).?); + } + { + 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).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(search.next() == null); +} + +test "clear screen and search" { + 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(); + 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"); + 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); +} + +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); +} + +test "history search, no active area" { + 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(); + + // Fill up first page + const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + try s.nextSlice("Fizz\r\n"); + for (1..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); + + // Create second page + try s.nextSlice("\r\n"); + try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); + try s.nextSlice("Buzz\r\nFizz"); + + try t.scrollViewport(.top); + + var search: ViewportSearch = try .init(alloc, "Fizz"); + defer search.deinit(); + try testing.expect(try search.update(&t.screens.active.pages)); + + { + 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).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + try testing.expect(search.next() == null); + + // Viewport doesn't contain active + try testing.expect(!try search.update(&t.screens.active.pages)); + try testing.expect(search.next() == null); +} diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index d589172ad..6fd4f1e79 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -1,26 +1,22 @@ //! SGR (Select Graphic Rendition) attrinvbute parsing and types. const std = @import("std"); -const assert = std.debug.assert; +const build_options = @import("terminal_options"); +const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; +const lib = @import("../lib/main.zig"); const color = @import("color.zig"); const SepList = @import("Parser.zig").Action.CSI.SepList; -/// Attribute type for SGR -pub const Attribute = union(enum) { - pub const Tag = std.meta.FieldEnum(Attribute); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; +/// Attribute type for SGR +pub const Attribute = union(Tag) { /// Unset all attributes unset, /// Unknown attribute, the raw CSI command parameters are here. - unknown: struct { - /// Full is the full SGR input. - full: []const u16, - - /// Partial is the remaining, where we got hung up. - partial: []const u16, - }, + unknown: Unknown, /// Bold the text. bold, @@ -36,7 +32,6 @@ pub const Attribute = union(enum) { /// Underline the text underline: Underline, - reset_underline, underline_color: color.RGB, @"256_underline_color": u8, reset_underline_color, @@ -85,6 +80,67 @@ pub const Attribute = union(enum) { /// Set foreground color as 256-color palette. @"256_fg": u8, + pub const Tag = lib.Enum( + lib_target, + &.{ + "unset", + "unknown", + "bold", + "reset_bold", + "italic", + "reset_italic", + "faint", + "underline", + "underline_color", + "256_underline_color", + "reset_underline_color", + "overline", + "reset_overline", + "blink", + "reset_blink", + "inverse", + "reset_inverse", + "invisible", + "reset_invisible", + "strikethrough", + "reset_strikethrough", + "direct_color_fg", + "direct_color_bg", + "8_bg", + "8_fg", + "reset_fg", + "reset_bg", + "8_bright_bg", + "8_bright_fg", + "256_bg", + "256_fg", + }, + ); + + pub const Unknown = struct { + /// Full is the full SGR input. + full: []const u16, + + /// Partial is the remaining, where we got hung up. + partial: []const u16, + + pub const C = extern struct { + full_ptr: [*]const u16, + full_len: usize, + partial_ptr: [*]const u16, + partial_len: usize, + }; + + pub fn cval(self: Unknown) Unknown.C { + return .{ + .full_ptr = self.full.ptr, + .full_len = self.full.len, + .partial_ptr = self.partial.ptr, + .partial_len = self.partial.len, + }; + } + }; + pub const Underline = enum(u3) { none = 0, single = 1, @@ -92,31 +148,61 @@ pub const Attribute = union(enum) { curly = 3, dotted = 4, dashed = 5, + + pub const C = c_int; + + pub fn cval(self: Underline) Underline.C { + return @intFromEnum(self); + } }; + + /// C ABI functions. + const c_union = lib.TaggedUnion( + lib_target, + @This(), + // Padding size for C ABI compatibility. + // Largest variant is Unknown.C: 2 pointers + 2 usize = 32 bytes on 64-bit. + // We use [8]u64 (64 bytes) to allow room for future expansion while + // maintaining ABI compatibility. + [8]u64, + ); + pub const Value = c_union.Value; + pub const C = c_union.C; + pub const CValue = c_union.CValue; + pub const cval = c_union.cval; }; /// Parser parses the attributes from a list of SGR parameters. pub const Parser = struct { - params: []const u16, + params: []const u16 = &.{}, params_sep: SepList = .initEmpty(), idx: usize = 0, + /// Empty state parser. + pub const empty: Parser = .{}; + /// 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. @@ -124,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, @@ -150,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 }; @@ -190,7 +298,7 @@ pub const Parser = struct { 23 => return .reset_italic, - 24 => return .reset_underline, + 24 => return .{ .underline = .none }, 25 => return .reset_blink, @@ -204,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, @@ -229,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, @@ -253,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, @@ -307,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]), @@ -320,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]), @@ -338,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; }, @@ -347,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 { @@ -376,10 +522,16 @@ 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().?; } +test "sgr: Attribute C compat" { + _ = Attribute.C; +} + test "sgr: Parser" { try testing.expect(testParse(&[_]u16{}) == .unset); try testing.expect(testParse(&[_]u16{0}) == .unset); @@ -474,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); } } @@ -487,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 c85e72f0f..ba6b57d5c 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1,8 +1,11 @@ +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"); +const lib = @import("../lib/main.zig"); const Parser = @import("Parser.zig"); const ansi = @import("ansi.zig"); const charsets = @import("charsets.zig"); @@ -25,23 +28,450 @@ const log = std.log.scoped(.stream); /// do something else. const debug = false; +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; + +/// The possible actions that can be emitted by the Stream +/// function for handling. +pub const Action = union(Key) { + print: Print, + print_repeat: usize, + bell, + backspace, + horizontal_tab: u16, + horizontal_tab_back: u16, + linefeed, + carriage_return, + enquiry, + invoke_charset: InvokeCharset, + cursor_up: CursorMovement, + cursor_down: CursorMovement, + cursor_left: CursorMovement, + cursor_right: CursorMovement, + cursor_col: CursorMovement, + cursor_row: CursorMovement, + cursor_col_relative: CursorMovement, + cursor_row_relative: CursorMovement, + cursor_pos: CursorPos, + cursor_style: ansi.CursorStyle, + erase_display_below: bool, + erase_display_above: bool, + erase_display_complete: bool, + erase_display_scrollback: bool, + erase_display_scroll_complete: bool, + erase_line_right: bool, + erase_line_left: bool, + erase_line_complete: bool, + erase_line_right_unless_pending_wrap: bool, + delete_chars: usize, + erase_chars: usize, + insert_lines: usize, + insert_blanks: usize, + delete_lines: usize, + scroll_up: usize, + scroll_down: usize, + tab_clear_current, + tab_clear_all, + tab_set, + tab_reset, + index, + next_line, + reverse_index, + full_reset, + set_mode: Mode, + reset_mode: Mode, + save_mode: Mode, + restore_mode: Mode, + request_mode: Mode, + request_mode_unknown: RawMode, + top_and_bottom_margin: Margin, + left_and_right_margin: Margin, + left_and_right_margin_ambiguous, + save_cursor, + restore_cursor, + modify_key_format: ansi.ModifyKeyFormat, + mouse_shift_capture: bool, + protected_mode_off, + protected_mode_iso, + protected_mode_dec, + size_report: csi.SizeReportStyle, + title_push: u16, + title_pop: u16, + xtversion, + device_attributes: ansi.DeviceAttributeReq, + device_status: DeviceStatus, + kitty_keyboard_query, + kitty_keyboard_push: KittyKeyboardFlags, + kitty_keyboard_pop: u16, + kitty_keyboard_set: KittyKeyboardFlags, + kitty_keyboard_set_or: KittyKeyboardFlags, + kitty_keyboard_set_not: KittyKeyboardFlags, + dcs_hook: Parser.Action.DCS, + dcs_put: u8, + dcs_unhook, + apc_start, + apc_end, + apc_put: u8, + prompt_end, + end_of_input, + end_hyperlink, + active_status_display: ansi.StatusDisplay, + decaln, + window_title: WindowTitle, + report_pwd: ReportPwd, + show_desktop_notification: ShowDesktopNotification, + progress_report: osc.Command.ProgressReport, + start_hyperlink: StartHyperlink, + clipboard_contents: ClipboardContents, + prompt_start: PromptStart, + prompt_continuation: PromptContinuation, + end_of_command: EndOfCommand, + mouse_shape: MouseShape, + configure_charset: ConfigureCharset, + set_attribute: sgr.Attribute, + kitty_color_report: kitty.color.OSC, + color_operation: ColorOperation, + + pub const Key = lib.Enum( + lib_target, + &.{ + "print", + "print_repeat", + "bell", + "backspace", + "horizontal_tab", + "horizontal_tab_back", + "linefeed", + "carriage_return", + "enquiry", + "invoke_charset", + "cursor_up", + "cursor_down", + "cursor_left", + "cursor_right", + "cursor_col", + "cursor_row", + "cursor_col_relative", + "cursor_row_relative", + "cursor_pos", + "cursor_style", + "erase_display_below", + "erase_display_above", + "erase_display_complete", + "erase_display_scrollback", + "erase_display_scroll_complete", + "erase_line_right", + "erase_line_left", + "erase_line_complete", + "erase_line_right_unless_pending_wrap", + "delete_chars", + "erase_chars", + "insert_lines", + "insert_blanks", + "delete_lines", + "scroll_up", + "scroll_down", + "tab_clear_current", + "tab_clear_all", + "tab_set", + "tab_reset", + "index", + "next_line", + "reverse_index", + "full_reset", + "set_mode", + "reset_mode", + "save_mode", + "restore_mode", + "request_mode", + "request_mode_unknown", + "top_and_bottom_margin", + "left_and_right_margin", + "left_and_right_margin_ambiguous", + "save_cursor", + "restore_cursor", + "modify_key_format", + "mouse_shift_capture", + "protected_mode_off", + "protected_mode_iso", + "protected_mode_dec", + "size_report", + "title_push", + "title_pop", + "xtversion", + "device_attributes", + "device_status", + "kitty_keyboard_query", + "kitty_keyboard_push", + "kitty_keyboard_pop", + "kitty_keyboard_set", + "kitty_keyboard_set_or", + "kitty_keyboard_set_not", + "dcs_hook", + "dcs_put", + "dcs_unhook", + "apc_start", + "apc_end", + "apc_put", + "prompt_end", + "end_of_input", + "end_hyperlink", + "active_status_display", + "decaln", + "window_title", + "report_pwd", + "show_desktop_notification", + "progress_report", + "start_hyperlink", + "clipboard_contents", + "prompt_start", + "prompt_continuation", + "end_of_command", + "mouse_shape", + "configure_charset", + "set_attribute", + "kitty_color_report", + "color_operation", + }, + ); + + /// C ABI functions. + const c_union = lib.TaggedUnion( + lib_target, + @This(), + // TODO: Before shipping an ABI-compatible libghostty, verify this. + // This was just arbitrarily chosen for now. + [16]u64, + ); + pub const Tag = c_union.Tag; + pub const Value = c_union.Value; + pub const C = c_union.C; + pub const CValue = c_union.CValue; + pub const cval = c_union.cval; + + /// Field types + pub const Print = struct { + cp: u21, + + pub const C = extern struct { + cp: u32, + }; + + pub fn cval(self: Print) Print.C { + return .{ .cp = @intCast(self.cp) }; + } + }; + + pub const InvokeCharset = lib.Struct(lib_target, struct { + bank: charsets.ActiveSlot, + charset: charsets.Slots, + locking: bool, + }); + + pub const CursorMovement = extern struct { + /// The value of the cursor movement. Depending on the tag of this + /// union this may be an absolute value or it may be a relative + /// value. For example, `cursor_up` is relative, but `cursor_row` + /// is absolute. + value: u16, + }; + + pub const CursorPos = extern struct { + row: u16, + col: u16, + }; + + pub const DeviceStatus = struct { + request: device_status.Request, + + pub const C = u16; + + pub fn cval(self: DeviceStatus) DeviceStatus.C { + return @bitCast(self.request); + } + }; + + pub const Mode = struct { + mode: modes.Mode, + + pub const C = u16; + + pub fn cval(self: Mode) Mode.C { + return @bitCast(self.mode); + } + }; + + pub const RawMode = extern struct { + mode: u16, + ansi: bool, + }; + + pub const Margin = extern struct { + top_left: u16, + bottom_right: u16, + }; + + pub const KittyKeyboardFlags = struct { + flags: kitty.KeyFlags, + + pub const C = u8; + + pub fn cval(self: KittyKeyboardFlags) KittyKeyboardFlags.C { + return @intCast(self.flags.int()); + } + }; + + pub const WindowTitle = struct { + title: []const u8, + + pub const C = lib.String; + + pub fn cval(self: WindowTitle) WindowTitle.C { + return .init(self.title); + } + }; + + pub const ReportPwd = struct { + url: []const u8, + + pub const C = lib.String; + + pub fn cval(self: ReportPwd) ReportPwd.C { + return .init(self.url); + } + }; + + pub const ShowDesktopNotification = struct { + title: []const u8, + body: []const u8, + + pub const C = extern struct { + title: lib.String, + body: lib.String, + }; + + pub fn cval(self: ShowDesktopNotification) ShowDesktopNotification.C { + return .{ + .title = .init(self.title), + .body = .init(self.body), + }; + } + }; + + pub const StartHyperlink = struct { + uri: []const u8, + id: ?[]const u8, + + pub const C = extern struct { + uri: lib.String, + id: lib.String, + }; + + pub fn cval(self: StartHyperlink) StartHyperlink.C { + return .{ + .uri = .init(self.uri), + .id = .init(self.id orelse ""), + }; + } + }; + + pub const ClipboardContents = struct { + kind: u8, + data: []const u8, + + pub const C = extern struct { + kind: u8, + data: lib.String, + }; + + pub fn cval(self: ClipboardContents) ClipboardContents.C { + return .{ + .kind = self.kind, + .data = .init(self.data), + }; + } + }; + + pub const PromptStart = struct { + aid: ?[]const u8, + redraw: bool, + + pub const C = extern struct { + aid: lib.String, + redraw: bool, + }; + + pub fn cval(self: PromptStart) PromptStart.C { + return .{ + .aid = .init(self.aid orelse ""), + .redraw = self.redraw, + }; + } + }; + + pub const PromptContinuation = struct { + aid: ?[]const u8, + + pub const C = lib.String; + + pub fn cval(self: PromptContinuation) PromptContinuation.C { + return .init(self.aid orelse ""); + } + }; + + pub const EndOfCommand = struct { + exit_code: ?u8, + + pub const C = extern struct { + exit_code: i16, + }; + + pub fn cval(self: EndOfCommand) EndOfCommand.C { + return .{ + .exit_code = if (self.exit_code) |code| @intCast(code) else -1, + }; + } + }; + + pub const ConfigureCharset = lib.Struct(lib_target, struct { + slot: charsets.Slots, + charset: charsets.Charset, + }); + + pub const ColorOperation = struct { + op: osc.color.Operation, + requests: osc.color.List, + terminator: osc.Terminator, + + pub const C = void; + + pub fn cval(_: ColorOperation) ColorOperation.C { + return {}; + } + }; +}; + /// Returns a type that can process a stream of tty control characters. -/// This will call various callback functions on type T. Type T only has to -/// implement the callbacks it cares about; any unimplemented callbacks will -/// logged at runtime. +/// This will call the `vt` function on type T with the following signature: /// -/// To figure out what callbacks exist, search the source for "hasDecl". This -/// isn't ideal but for now that's the best approach. +/// fn(comptime action: Action.Key, value: Action.Value(action)) !void /// -/// This is implemented this way because we purposely do NOT want dynamic -/// dispatch for performance reasons. The way this is implemented forces -/// comptime resolution for all function calls. +/// The handler type T can choose to react to whatever actions it cares +/// about in its pursuit of implementing a terminal emulator or other +/// functionality. +/// +/// The Handler type must also have a `deinit` function. +/// +/// The "comptime" key is on purpose (vs. a standard Zig tagged union) +/// because it allows the compiler to optimize away unimplemented actions. +/// e.g. you don't need to pay a conditional branching cost on every single +/// action because the Zig compiler codegens separate code paths for every +/// single action at comptime. pub fn Stream(comptime Handler: type) type { return struct { const Self = @This(); - // We use T with @hasDecl so it needs to be a struct. Unwrap the - // pointer if we were given one. + pub const Action = streampkg.Action; + const T = switch (@typeInfo(Handler)) { .pointer => |p| p.child, else => Handler, @@ -51,6 +481,19 @@ pub fn Stream(comptime Handler: type) type { parser: Parser, utf8decoder: UTF8Decoder, + /// Initialize an allocation-free stream. This will preallocate various + /// sizes as necessary and anything over that will be dropped. If you + /// want to support more dynamic behavior use initAlloc instead. + /// + /// As a concrete example of something that requires heap allocation, + /// consider OSC 52 (clipboard operations) which can be arbitrarily + /// large. + /// + /// If you want to limit allocation size, use an allocator with + /// a size limit with initAlloc. + /// + /// This takes ownership of the handler and will call deinit + /// when the stream is deinitialized. pub fn init(h: Handler) Self { return .{ .handler = h, @@ -59,8 +502,16 @@ pub fn Stream(comptime Handler: type) type { }; } + /// Initialize the stream that supports heap allocation as necessary. + pub fn initAlloc(alloc: Allocator, h: Handler) Self { + var self: Self = .init(h); + self.parser.osc_parser.alloc = alloc; + return self; + } + pub fn deinit(self: *Self) void { self.parser.deinit(); + self.handler.deinit(); } /// Process a string of characters. @@ -194,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. @@ -209,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)); @@ -225,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; @@ -240,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), @@ -306,125 +770,119 @@ pub fn Stream(comptime Handler: type) type { } switch (action) { - .print => |p| if (@hasDecl(T, "print")) try self.handler.print(p), + .print => |p| try self.print(p), .execute => |code| try self.execute(code), .csi_dispatch => |csi_action| try self.csiDispatch(csi_action), .esc_dispatch => |esc| try self.escDispatch(esc), .osc_dispatch => |cmd| try self.oscDispatch(cmd), - .dcs_hook => |dcs| if (@hasDecl(T, "dcsHook")) { - try self.handler.dcsHook(dcs); - } else log.warn("unimplemented DCS hook", .{}), - .dcs_put => |code| if (@hasDecl(T, "dcsPut")) { - try self.handler.dcsPut(code); - } else log.warn("unimplemented DCS put: {x}", .{code}), - .dcs_unhook => if (@hasDecl(T, "dcsUnhook")) { - try self.handler.dcsUnhook(); - } else log.warn("unimplemented DCS unhook", .{}), - .apc_start => if (@hasDecl(T, "apcStart")) { - try self.handler.apcStart(); - } else log.warn("unimplemented APC start", .{}), - .apc_put => |code| if (@hasDecl(T, "apcPut")) { - try self.handler.apcPut(code); - } else log.warn("unimplemented APC put: {x}", .{code}), - .apc_end => if (@hasDecl(T, "apcEnd")) { - try self.handler.apcEnd(); - } else log.warn("unimplemented APC end", .{}), + .dcs_hook => |dcs| try self.handler.vt(.dcs_hook, dcs), + .dcs_put => |code| try self.handler.vt(.dcs_put, code), + .dcs_unhook => try self.handler.vt(.dcs_unhook, {}), + .apc_start => try self.handler.vt(.apc_start, {}), + .apc_put => |code| try self.handler.vt(.apc_put, code), + .apc_end => try self.handler.vt(.apc_end, {}), } } } pub inline fn print(self: *Self, c: u21) !void { - if (@hasDecl(T, "print")) { - try self.handler.print(c); - } + try self.handler.vt(.print, .{ .cp = c }); } 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) { // We ignore SOH/STX: https://github.com/microsoft/terminal/issues/10786 .NUL, .SOH, .STX => {}, - .ENQ => if (@hasDecl(T, "enquiry")) - try self.handler.enquiry() - else - log.warn("unimplemented execute: {x}", .{c}), - - .BEL => if (@hasDecl(T, "bell")) - try self.handler.bell() - else - log.warn("unimplemented execute: {x}", .{c}), - - .BS => if (@hasDecl(T, "backspace")) - try self.handler.backspace() - else - log.warn("unimplemented execute: {x}", .{c}), - - .HT => if (@hasDecl(T, "horizontalTab")) - try self.handler.horizontalTab(1) - else - log.warn("unimplemented execute: {x}", .{c}), - - .LF, .VT, .FF => if (@hasDecl(T, "linefeed")) - try self.handler.linefeed() - else - log.warn("unimplemented execute: {x}", .{c}), - - .CR => if (@hasDecl(T, "carriageReturn")) - try self.handler.carriageReturn() - else - log.warn("unimplemented execute: {x}", .{c}), - - .SO => if (@hasDecl(T, "invokeCharset")) - try self.handler.invokeCharset(.GL, .G1, false) - else - log.warn("unimplemented invokeCharset: {x}", .{c}), - - .SI => if (@hasDecl(T, "invokeCharset")) - try self.handler.invokeCharset(.GL, .G0, false) - else - log.warn("unimplemented invokeCharset: {x}", .{c}), + .ENQ => try self.handler.vt(.enquiry, {}), + .BEL => try self.handler.vt(.bell, {}), + .BS => try self.handler.vt(.backspace, {}), + .HT => try self.handler.vt(.horizontal_tab, 1), + .LF, .VT, .FF => try self.handler.vt(.linefeed, {}), + .CR => try self.handler.vt(.carriage_return, {}), + .SO => try self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G1, .locking = false }), + .SI => try self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G0, .locking = false }), else => log.warn("invalid C0 character, ignoring: 0x{x}", .{c}), } } 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 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( - 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; + }, }, - }, - false, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), - 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 'B' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( - switch (input.params.len) { + 0 => try self.handler.vt(.cursor_down, .{ + .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid cursor down command: {f}", .{input}); return; }, }, - false, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI B with intermediates: {s}", @@ -433,36 +891,41 @@ pub fn Stream(comptime Handler: type) type { }, // CUF - Cursor Right - 'C' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight( - 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("unimplemented CSI callback: {f}", .{input}), + }), - 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 'D', 'j' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft( - switch (input.params.len) { + 0 => try self.handler.vt(.cursor_left, .{ + .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { + @branchHint(.unlikely); log.warn("invalid cursor left command: {f}", .{input}); return; }, }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI D with intermediates: {s}", @@ -472,17 +935,20 @@ pub fn Stream(comptime Handler: type) type { // CNL - Cursor Next Line 'E' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor up command: {f}", .{input}); - return; + 0 => { + try self.handler.vt(.cursor_down, .{ + .value = switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + @branchHint(.unlikely); + log.warn("invalid cursor up command: {f}", .{input}); + return; + }, }, - }, - true, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }); + try self.handler.vt(.carriage_return, {}); + }, else => log.warn( "ignoring unimplemented CSI E with intermediates: {s}", @@ -492,17 +958,20 @@ pub fn Stream(comptime Handler: type) type { // CPL - Cursor Previous Line 'F' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor down command: {f}", .{input}); - return; + 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 down command: {f}", .{input}); + return; + }, }, - }, - true, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }); + try self.handler.vt(.carriage_return, {}); + }, else => log.warn( "ignoring unimplemented CSI F with intermediates: {s}", @@ -513,11 +982,17 @@ pub fn Stream(comptime Handler: type) type { // HPA - Cursor Horizontal Position Absolute // TODO: test 'G', '`' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) { - 0 => try self.handler.setCursorCol(1), - 1 => try self.handler.setCursorCol(input.params[0]), - else => log.warn("invalid HPA command: {f}", .{input}), - } else log.warn("unimplemented CSI callback: {f}", .{input}), + 0 => try self.handler.vt(.cursor_col, .{ + .value = switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + @branchHint(.unlikely); + log.warn("invalid HPA command: {f}", .{input}); + return; + }, + }, + }), else => log.warn( "ignoring unimplemented CSI G with intermediates: {s}", @@ -527,32 +1002,40 @@ pub fn Stream(comptime Handler: type) type { // CUP - Set Cursor Position. // TODO: test - 'H', 'f' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) { - 0 => try self.handler.setCursorPos(1, 1), - 1 => try self.handler.setCursorPos(input.params[0], 1), - 2 => try self.handler.setCursorPos(input.params[0], input.params[1]), - else => log.warn("invalid CUP command: {f}", .{input}), - } else log.warn("unimplemented CSI callback: {f}", .{input}), + '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 'I' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid horizontal tab command: {f}", .{input}); - return; - }, + 0 => try self.handler.vt(.horizontal_tab, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid horizontal tab command: {f}", .{input}); + return; }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI I with intermediates: {s}", @@ -561,7 +1044,7 @@ pub fn Stream(comptime Handler: type) type { }, // Erase Display - 'J' => if (@hasDecl(T, "eraseDisplay")) { + 'J' => { const protected_: ?bool = switch (input.intermediates.len) { 0 => false, 1 => if (input.intermediates[0] == '?') true else null, @@ -584,11 +1067,18 @@ pub fn Stream(comptime Handler: type) type { return; }; - try self.handler.eraseDisplay(mode, protected); - } else log.warn("unimplemented CSI callback: {f}", .{input}), + switch (mode) { + .below => try self.handler.vt(.erase_display_below, protected), + .above => try self.handler.vt(.erase_display_above, protected), + .complete => try self.handler.vt(.erase_display_complete, protected), + .scrollback => try self.handler.vt(.erase_display_scrollback, protected), + .scroll_complete => try self.handler.vt(.erase_display_scroll_complete, protected), + } + }, // Erase Line - 'K' => if (@hasDecl(T, "eraseLine")) { + 'K' => { + @branchHint(.likely); const protected_: ?bool = switch (input.intermediates.len) { 0 => false, 1 => if (input.intermediates[0] == '?') true else null, @@ -596,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; }; @@ -607,21 +1098,34 @@ pub fn Stream(comptime Handler: type) type { }; const mode = mode_ orelse { + @branchHint(.unlikely); log.warn("invalid erase line command: {f}", .{input}); return; }; - try self.handler.eraseLine(mode, protected); - } else log.warn("unimplemented CSI callback: {f}", .{input}), + switch (mode) { + .right => try self.handler.vt(.erase_line_right, protected), + .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), + _ => { + @branchHint(.unlikely); + log.warn("invalid erase line mode: {}", .{mode}); + }, + } + }, // IL - Insert Lines // TODO: test 'L' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "insertLines")) switch (input.params.len) { - 0 => try self.handler.insertLines(1), - 1 => try self.handler.insertLines(input.params[0]), - else => log.warn("invalid IL command: {f}", .{input}), - } else log.warn("unimplemented CSI callback: {f}", .{input}), + 0 => try self.handler.vt(.insert_lines, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid IL command: {f}", .{input}); + return; + }, + }), else => log.warn( "ignoring unimplemented CSI L with intermediates: {s}", @@ -632,11 +1136,14 @@ pub fn Stream(comptime Handler: type) type { // DL - Delete Lines // TODO: test 'M' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "deleteLines")) switch (input.params.len) { - 0 => try self.handler.deleteLines(1), - 1 => try self.handler.deleteLines(input.params[0]), - else => log.warn("invalid DL command: {f}", .{input}), - } else log.warn("unimplemented CSI callback: {f}", .{input}), + 0 => try self.handler.vt(.delete_lines, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid DL command: {f}", .{input}); + return; + }, + }), else => log.warn( "ignoring unimplemented CSI M with intermediates: {s}", @@ -646,16 +1153,14 @@ pub fn Stream(comptime Handler: type) type { // Delete Character (DCH) 'P' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid delete characters command: {f}", .{input}); - return; - }, + 0 => try self.handler.vt(.delete_chars, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid delete characters command: {f}", .{input}); + return; }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI P with intermediates: {s}", @@ -666,16 +1171,14 @@ pub fn Stream(comptime Handler: type) type { // Scroll Up (SD) 'S' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "scrollUp")) try self.handler.scrollUp( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid scroll up command: {f}", .{input}); - return; - }, + 0 => try self.handler.vt(.scroll_up, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid scroll up command: {f}", .{input}); + return; }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI S with intermediates: {s}", @@ -685,16 +1188,14 @@ pub fn Stream(comptime Handler: type) type { // Scroll Down (SD) 'T' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid scroll down command: {f}", .{input}); - return; - }, + 0 => try self.handler.vt(.scroll_down, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid scroll down command: {f}", .{input}); + return; }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI T with intermediates: {s}", @@ -708,11 +1209,7 @@ pub fn Stream(comptime Handler: type) type { if (input.params.len == 0 or (input.params.len == 1 and input.params[0] == 0)) { - if (@hasDecl(T, "tabSet")) - try self.handler.tabSet() - else - log.warn("unimplemented tab set callback: {f}", .{input}); - + try self.handler.vt(.tab_set, {}); return; } @@ -722,15 +1219,9 @@ pub fn Stream(comptime Handler: type) type { 1 => switch (input.params[0]) { 0 => unreachable, - 2 => if (@hasDecl(T, "tabClear")) - try self.handler.tabClear(.current) - else - log.warn("unimplemented tab clear callback: {f}", .{input}), + 2 => try self.handler.vt(.tab_clear_current, {}), - 5 => if (@hasDecl(T, "tabClear")) - try self.handler.tabClear(.all) - else - log.warn("unimplemented tab clear callback: {f}", .{input}), + 5 => try self.handler.vt(.tab_clear_all, {}), else => {}, }, @@ -743,10 +1234,7 @@ pub fn Stream(comptime Handler: type) type { }, 1 => if (input.intermediates[0] == '?' and input.params[0] == 5) { - if (@hasDecl(T, "tabReset")) - try self.handler.tabReset() - else - log.warn("unimplemented tab reset callback: {f}", .{input}); + try self.handler.vt(.tab_reset, {}); } else log.warn("invalid cursor tabulation control: {f}", .{input}), else => log.warn( @@ -756,36 +1244,36 @@ pub fn Stream(comptime Handler: type) type { }, // Erase Characters (ECH) - 'X' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars( - switch (input.params.len) { + '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("unimplemented CSI callback: {f}", .{input}), + }), - 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 'Z' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid horizontal tab back command: {f}", .{input}); - return; - }, + 0 => try self.handler.vt(.horizontal_tab_back, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid horizontal tab back command: {f}", .{input}); + return; }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI Z with intermediates: {s}", @@ -795,8 +1283,8 @@ pub fn Stream(comptime Handler: type) type { // HPR - Cursor Horizontal Position Relative 'a' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative( - switch (input.params.len) { + 0 => try self.handler.vt(.cursor_col_relative, .{ + .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -804,7 +1292,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI a with intermediates: {s}", @@ -814,16 +1302,14 @@ pub fn Stream(comptime Handler: type) type { // Repeat Previous Char (REP) 'b' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid print repeat command: {f}", .{input}); - return; - }, + 0 => try self.handler.vt(.print_repeat, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid print repeat command: {f}", .{input}); + return; }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI b with intermediates: {s}", @@ -832,27 +1318,29 @@ pub fn Stream(comptime Handler: type) type { }, // c - Device Attributes (DA1) - 'c' => if (@hasDecl(T, "deviceAttributes")) { - const req: ansi.DeviceAttributeReq = switch (input.intermediates.len) { - 0 => ansi.DeviceAttributeReq.primary, + 'c' => { + const req: ?ansi.DeviceAttributeReq = switch (input.intermediates.len) { + 0 => .primary, 1 => switch (input.intermediates[0]) { - '>' => ansi.DeviceAttributeReq.secondary, - '=' => ansi.DeviceAttributeReq.tertiary, + '>' => .secondary, + '=' => .tertiary, else => null, }, - else => @as(?ansi.DeviceAttributeReq, null), - } orelse { - log.warn("invalid device attributes command: {f}", .{input}); - return; + else => null, }; - try self.handler.deviceAttributes(req, input.params); - } else log.warn("unimplemented CSI callback: {f}", .{input}), + if (req) |r| { + try self.handler.vt(.device_attributes, r); + } else { + log.warn("invalid device attributes command: {f}", .{input}); + return; + } + }, // VPA - Cursor Vertical Position Absolute 'd' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow( - switch (input.params.len) { + 0 => try self.handler.vt(.cursor_row, .{ + .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -860,7 +1348,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI d with intermediates: {s}", @@ -870,8 +1358,8 @@ pub fn Stream(comptime Handler: type) type { // VPR - Cursor Vertical Position Relative 'e' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative( - switch (input.params.len) { + 0 => try self.handler.vt(.cursor_row_relative, .{ + .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -879,7 +1367,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI e with intermediates: {s}", @@ -890,15 +1378,20 @@ pub fn Stream(comptime Handler: type) type { // TBC - Tab Clear // TODO: test 'g' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "tabClear")) try self.handler.tabClear( - switch (input.params.len) { + 0 => { + const mode: csi.TabClear = switch (input.params.len) { 1 => @enumFromInt(input.params[0]), else => { log.warn("invalid tab clear command: {f}", .{input}); return; }, - }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }; + switch (mode) { + .current => try self.handler.vt(.tab_clear_current, {}), + .all => try self.handler.vt(.tab_clear_all, {}), + _ => log.warn("unknown tab clear mode: {}", .{mode}), + } + }, else => log.warn( "ignoring unimplemented CSI g with intermediates: {s}", @@ -907,7 +1400,8 @@ pub fn Stream(comptime Handler: type) type { }, // SM - Set Mode - 'h' => if (@hasDecl(T, "setMode")) mode: { + 'h' => mode: { + @branchHint(.likely); const ansi_mode = ansi: { if (input.intermediates.len == 0) break :ansi true; if (input.intermediates.len == 1 and @@ -919,15 +1413,16 @@ pub fn Stream(comptime Handler: type) type { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { - try self.handler.setMode(mode, true); + try self.handler.vt(.set_mode, .{ .mode = mode }); } else { log.warn("unimplemented mode: {}", .{mode_int}); } } - } else log.warn("unimplemented CSI callback: {f}", .{input}), + }, // RM - Reset Mode - 'l' => if (@hasDecl(T, "setMode")) mode: { + 'l' => mode: { + @branchHint(.likely); const ansi_mode = ansi: { if (input.intermediates.len == 0) break :ansi true; if (input.intermediates.len == 1 and @@ -939,87 +1434,94 @@ pub fn Stream(comptime Handler: type) type { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { - try self.handler.setMode(mode, false); + try self.handler.vt(.reset_mode, .{ .mode = mode }); } else { log.warn("unimplemented mode: {}", .{mode_int}); } } - } else log.warn("unimplemented CSI callback: {f}", .{input}), + }, // SGR - Select Graphic Rendition - 'm' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setAttribute")) { - // 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.setAttribute(attr); - } - } else log.warn("unimplemented CSI callback: {f}", .{input}), - - 1 => switch (input.intermediates[0]) { - '>' => if (@hasDecl(T, "setModifyKeyFormat")) blk: { - if (input.params.len == 0) { - // Reset - try self.handler.setModifyKeyFormat(.{ .legacy = {} }); - 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, + }; + while (p.next()) |attr| { + // log.info("SGR attribute: {}", .{attr}); + try self.handler.vt(.set_attribute, attr); } + }, - var format: ansi.ModifyKeyFormat = switch (input.params[0]) { - 0 => .{ .legacy = {} }, - 1 => .{ .cursor_keys = {} }, - 2 => .{ .function_keys = {} }, - 4 => .{ .other_keys = .none }, - else => { + 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 => { + @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) { - 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 => |*v| switch (input.params[1]) { - 2 => v.* = .numeric, - else => v.* = .none, - }, } - } - try self.handler.setModifyKeyFormat(format); - } else log.warn("unimplemented setModifyKeyFormat: {f}", .{input}), + if (input.params.len == 2) { + switch (format) { + // We don't support any of the subparams yet for these. + .legacy => {}, + .cursor_keys => {}, + .function_keys => {}, - else => log.warn( - "unknown CSI m with intermediate: {}", - .{input.intermediates[0]}, - ), - }, + // 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 => {}, + } + } - 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}, - ); - }, + 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 @@ -1028,11 +1530,6 @@ pub fn Stream(comptime Handler: type) type { if (input.intermediates.len == 0 or input.intermediates[0] == '?') { - if (!@hasDecl(T, "deviceStatusReport")) { - log.warn("unimplemented CSI callback: {f}", .{input}); - return; - } - if (input.params.len != 1) { log.warn("invalid device status report command: {f}", .{input}); return; @@ -1052,7 +1549,7 @@ pub fn Stream(comptime Handler: type) type { return; }; - try self.handler.deviceStatusReport(req); + try self.handler.vt(.device_status, .{ .request = req }); return; } @@ -1061,13 +1558,13 @@ pub fn Stream(comptime Handler: type) type { 0 => unreachable, // handled above 1 => switch (input.intermediates[0]) { - '>' => if (@hasDecl(T, "setModifyKeyFormat")) { + '>' => { // This isn't strictly correct. CSI > n has parameters that // control what exactly is being disabled. However, we // only support reverting back to modify other keys in // numeric except format. - try self.handler.setModifyKeyFormat(.{ .other_keys = .numeric_except }); - } else log.warn("unimplemented setModifyKeyFormat: {f}", .{input}), + try self.handler.vt(.modify_key_format, .other_keys_numeric_except); + }, else => log.warn( "unknown CSI n with intermediate: {}", @@ -1105,9 +1602,16 @@ pub fn Stream(comptime Handler: type) type { break :decrqm; } - if (@hasDecl(T, "requestMode")) { - try self.handler.requestMode(input.params[0], ansi_mode); - } else log.warn("unimplemented DECRQM callback: {f}", .{input}); + const mode_raw = input.params[0]; + const mode = modes.modeFromInt(mode_raw, ansi_mode); + if (mode) |m| { + try self.handler.vt(.request_mode, .{ .mode = m }); + } else { + try self.handler.vt(.request_mode_unknown, .{ + .mode = mode_raw, + .ansi = ansi_mode, + }); + } }, else => log.warn( @@ -1121,44 +1625,55 @@ pub fn Stream(comptime Handler: type) type { // DECSCUSR - Select Cursor Style // TODO: test ' ' => { - if (@hasDecl(T, "setCursorStyle")) try self.handler.setCursorStyle( - switch (input.params.len) { - 0 => ansi.CursorStyle.default, - 1 => @enumFromInt(input.params[0]), + const style: ansi.CursorStyle = switch (input.params.len) { + 0 => .default, + 1 => switch (input.params[0]) { + 0 => .default, + 1 => .blinking_block, + 2 => .steady_block, + 3 => .blinking_underline, + 4 => .steady_underline, + 5 => .blinking_bar, + 6 => .steady_bar, else => { - log.warn("invalid set curor style command: {f}", .{input}); + log.warn("invalid cursor style value: {}", .{input.params[0]}); return; }, }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}); + else => { + log.warn("invalid set curor style command: {f}", .{input}); + return; + }, + }; + try self.handler.vt(.cursor_style, style); }, // DECSCA '"' => { - if (@hasDecl(T, "setProtectedMode")) { - const mode_: ?ansi.ProtectedMode = switch (input.params.len) { + const mode_: ?ansi.ProtectedMode = switch (input.params.len) { + else => null, + 0 => .off, + 1 => switch (input.params[0]) { + 0, 2 => .off, + 1 => .dec, else => null, - 0 => .off, - 1 => switch (input.params[0]) { - 0, 2 => .off, - 1 => .dec, - else => null, - }, - }; + }, + }; - const mode = mode_ orelse { - log.warn("invalid set protected mode command: {f}", .{input}); - return; - }; + const mode = mode_ orelse { + log.warn("invalid set protected mode command: {f}", .{input}); + return; + }; - try self.handler.setProtectedMode(mode); - } else log.warn("unimplemented CSI callback: {f}", .{input}); + switch (mode) { + .off => try self.handler.vt(.protected_mode_off, {}), + .iso => try self.handler.vt(.protected_mode_iso, {}), + .dec => try self.handler.vt(.protected_mode_dec, {}), + } }, // XTVERSION - '>' => { - if (@hasDecl(T, "reportXtversion")) try self.handler.reportXtversion(); - }, + '>' => try self.handler.vt(.xtversion, {}), else => { log.warn( "ignoring unimplemented CSI q with intermediates: {s}", @@ -1173,70 +1688,66 @@ pub fn Stream(comptime Handler: type) type { ), }, - 'r' => switch (input.intermediates.len) { - // DECSTBM - Set Top and Bottom Margins - 0 => if (@hasDecl(T, "setTopAndBottomMargin")) { - switch (input.params.len) { - 0 => try self.handler.setTopAndBottomMargin(0, 0), - 1 => try self.handler.setTopAndBottomMargin(input.params[0], 0), - 2 => try self.handler.setTopAndBottomMargin(input.params[0], input.params[1]), - else => log.warn("invalid DECSTBM command: {f}", .{input}), - } - } else log.warn( - "unimplemented CSI callback: {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 - '?' => if (@hasDecl(T, "restoreMode")) { - for (input.params) |mode_int| { - if (modes.modeFromInt(mode_int, false)) |mode| { - try self.handler.restoreMode(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) { // DECSLRM - 0 => if (@hasDecl(T, "setLeftAndRightMargin")) { - switch (input.params.len) { - // CSI S is ambiguous with zero params so we defer - // to our handler to do the proper logic. If mode 69 - // is set, then we should invoke DECSLRM, otherwise - // we should invoke SC. - 0 => try self.handler.setLeftAndRightMarginAmbiguous(), - 1 => try self.handler.setLeftAndRightMargin(input.params[0], 0), - 2 => try self.handler.setLeftAndRightMargin(input.params[0], input.params[1]), - else => log.warn("invalid DECSLRM command: {f}", .{input}), - } - } else log.warn( - "unimplemented CSI callback: {f}", - .{input}, - ), + 0 => switch (input.params.len) { + // CSI S is ambiguous with zero params so we defer + // to our handler to do the proper logic. If mode 69 + // is set, then we should invoke DECSLRM, otherwise + // we should invoke SC. + 0 => try self.handler.vt(.left_and_right_margin_ambiguous, {}), + 1 => try self.handler.vt(.left_and_right_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), + 2 => try self.handler.vt(.left_and_right_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), + else => log.warn("invalid DECSLRM command: {f}", .{input}), + }, 1 => switch (input.intermediates[0]) { - '?' => if (@hasDecl(T, "saveMode")) { + '?' => { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, false)) |mode| { - try self.handler.saveMode(mode); + try self.handler.vt(.save_mode, .{ .mode = mode }); } else { log.warn( "unimplemented save mode: {}", @@ -1247,7 +1758,7 @@ pub fn Stream(comptime Handler: type) type { }, // XTSHIFTESCAPE - '>' => if (@hasDecl(T, "setMouseShiftCapture")) capture: { + '>' => capture: { const capture = switch (input.params.len) { 0 => false, 1 => switch (input.params[0]) { @@ -1264,11 +1775,8 @@ pub fn Stream(comptime Handler: type) type { }, }; - try self.handler.setMouseShiftCapture(capture); - } else log.warn( - "unimplemented CSI callback: {f}", - .{input}, - ), + try self.handler.vt(.mouse_shift_capture, capture); + }, else => log.warn( "unknown CSI s with intermediate: {f}", @@ -1289,48 +1797,28 @@ pub fn Stream(comptime Handler: type) type { switch (input.params[0]) { 14 => if (input.params.len == 1) { // report the text area size in pixels - if (@hasDecl(T, "sendSizeReport")) { - self.handler.sendSizeReport(.csi_14_t); - } else log.warn( - "ignoring unimplemented CSI 14 t", - .{}, - ); + try self.handler.vt(.size_report, .csi_14_t); } else log.warn( "ignoring CSI 14 t with extra parameters: {f}", .{input}, ), 16 => if (input.params.len == 1) { // report cell size in pixels - if (@hasDecl(T, "sendSizeReport")) { - self.handler.sendSizeReport(.csi_16_t); - } else log.warn( - "ignoring unimplemented CSI 16 t", - .{}, - ); + try self.handler.vt(.size_report, .csi_16_t); } else log.warn( "ignoring CSI 16 t with extra parameters: {f}", .{input}, ), 18 => if (input.params.len == 1) { // report screen size in characters - if (@hasDecl(T, "sendSizeReport")) { - self.handler.sendSizeReport(.csi_18_t); - } else log.warn( - "ignoring unimplemented CSI 18 t", - .{}, - ); + try self.handler.vt(.size_report, .csi_18_t); } else log.warn( "ignoring CSI 18 t with extra parameters: {f}", .{input}, ), 21 => if (input.params.len == 1) { // report window title - if (@hasDecl(T, "sendSizeReport")) { - self.handler.sendSizeReport(.csi_21_t); - } else log.warn( - "ignoring unimplemented CSI 21 t", - .{}, - ); + try self.handler.vt(.size_report, .csi_21_t); } else log.warn( "ignoring CSI 21 t with extra parameters: {f}", .{input}, @@ -1342,22 +1830,15 @@ pub fn Stream(comptime Handler: type) type { input.params[1] == 2)) { // push/pop title - if (@hasDecl(T, "pushPopTitle")) { - self.handler.pushPopTitle(.{ - .op = switch (number) { - 22 => .push, - 23 => .pop, - else => @compileError("unreachable"), - }, - .index = if (input.params.len == 3) - input.params[2] - else - 0, - }); - } else log.warn( - "ignoring unimplemented CSI 22/23 t", - .{}, - ); + const index: u16 = if (input.params.len == 3) + input.params[2] + else + 0; + switch (number) { + 22 => try self.handler.vt(.title_push, index), + 23 => try self.handler.vt(.title_pop, index), + else => @compileError("unreachable"), + } } else log.warn( "ignoring CSI 22/23 t with extra parameters: {f}", .{input}, @@ -1379,18 +1860,13 @@ pub fn Stream(comptime Handler: type) type { }, 'u' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "restoreCursor")) - try self.handler.restoreCursor() - else - log.warn("unimplemented CSI callback: {f}", .{input}), + 0 => try self.handler.vt(.restore_cursor, {}), // Kitty keyboard protocol 1 => switch (input.intermediates[0]) { - '?' => if (@hasDecl(T, "queryKittyKeyboard")) { - try self.handler.queryKittyKeyboard(); - }, + '?' => try self.handler.vt(.kitty_keyboard_query, {}), - '>' => if (@hasDecl(T, "pushKittyKeyboard")) push: { + '>' => push: { const flags: u5 = if (input.params.len == 1) std.math.cast(u5, input.params[0]) orelse { log.warn("invalid pushKittyKeyboard command: {f}", .{input}); @@ -1399,19 +1875,19 @@ pub fn Stream(comptime Handler: type) type { else 0; - try self.handler.pushKittyKeyboard(@bitCast(flags)); + try self.handler.vt(.kitty_keyboard_push, .{ .flags = @as(kitty.KeyFlags, @bitCast(flags)) }); }, - '<' => if (@hasDecl(T, "popKittyKeyboard")) { + '<' => { const number: u16 = if (input.params.len == 1) input.params[0] else 1; - try self.handler.popKittyKeyboard(number); + try self.handler.vt(.kitty_keyboard_pop, number); }, - '=' => if (@hasDecl(T, "setKittyKeyboard")) set: { + '=' => set: { const flags: u5 = if (input.params.len >= 1) std.math.cast(u5, input.params[0]) orelse { log.warn("invalid setKittyKeyboard command: {f}", .{input}); @@ -1425,20 +1901,23 @@ pub fn Stream(comptime Handler: type) type { else 1; - const mode: kitty.KeySetMode = switch (number) { - 1 => .set, - 2 => .@"or", - 3 => .not, + const action_tag: streampkg.Action.Tag = switch (number) { + 1 => .kitty_keyboard_set, + 2 => .kitty_keyboard_set_or, + 3 => .kitty_keyboard_set_not, else => { log.warn("invalid setKittyKeyboard command: {f}", .{input}); break :set; }, }; - try self.handler.setKittyKeyboard( - mode, - @bitCast(flags), - ); + const kitty_flags: streampkg.Action.KittyKeyboardFlags = .{ .flags = @as(kitty.KeyFlags, @bitCast(flags)) }; + switch (action_tag) { + .kitty_keyboard_set => try self.handler.vt(.kitty_keyboard_set, kitty_flags), + .kitty_keyboard_set_or => try self.handler.vt(.kitty_keyboard_set_or, kitty_flags), + .kitty_keyboard_set_not => try self.handler.vt(.kitty_keyboard_set_not, kitty_flags), + else => unreachable, + } }, else => log.warn( @@ -1455,11 +1934,15 @@ pub fn Stream(comptime Handler: type) type { // ICH - Insert Blanks '@' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "insertBlanks")) switch (input.params.len) { - 0 => try self.handler.insertBlanks(1), - 1 => try self.handler.insertBlanks(input.params[0]), - else => log.warn("invalid ICH command: {f}", .{input}), - } else log.warn("unimplemented CSI callback: {f}", .{input}), + 0 => try self.handler.vt(.insert_blanks, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + @branchHint(.unlikely); + log.warn("invalid ICH command: {f}", .{input}); + return; + }, + }), else => log.warn( "ignoring unimplemented CSI @: {f}", @@ -1468,154 +1951,155 @@ pub fn Stream(comptime Handler: type) type { }, // DECSASD - Select Active Status Display - '}' => { - const success = decsasd: { - // Verify we're getting a DECSASD command - if (input.intermediates.len != 1 or input.intermediates[0] != '$') - break :decsasd false; - if (input.params.len != 1) - break :decsasd false; - if (!@hasDecl(T, "setActiveStatusDisplay")) - break :decsasd false; + '}' => decsasd: { + // Verify we're getting a DECSASD command + if (input.intermediates.len != 1 or input.intermediates[0] != '$') { + log.warn("unimplemented CSI callback: {f}", .{input}); + break :decsasd; + } + if (input.params.len != 1) { + log.warn("unimplemented CSI callback: {f}", .{input}); + break :decsasd; + } - const display = std.meta.intToEnum( - ansi.StatusDisplay, - input.params[0], - ) catch break :decsasd false; - - try self.handler.setActiveStatusDisplay(display); - break :decsasd true; + const display: ansi.StatusDisplay = switch (input.params[0]) { + 0 => .main, + 1 => .status_line, + else => { + log.warn("unimplemented CSI callback: {f}", .{input}); + break :decsasd; + }, }; - if (!success) log.warn("unimplemented CSI callback: {f}", .{input}); + try self.handler.vt(.active_status_display, display); }, - else => if (@hasDecl(T, "csiUnimplemented")) - try self.handler.csiUnimplemented(input) - else - log.warn("unimplemented CSI action: {f}", .{input}), + else => log.warn("unimplemented CSI action: {f}", .{input}), } } 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| { - if (@hasDecl(T, "changeWindowTitle")) { - if (!std.unicode.utf8ValidateSlice(title)) { - log.warn("change title request: invalid utf-8, ignoring request", .{}); - return; - } - - try self.handler.changeWindowTitle(title); + @branchHint(.likely); + if (!std.unicode.utf8ValidateSlice(title)) { + @branchHint(.unlikely); + log.warn("change title request: invalid utf-8, ignoring request", .{}); return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + } + + try self.handler.vt(.window_title, .{ .title = title }); }, .change_window_icon => |icon| { + @branchHint(.likely); log.info("OSC 1 (change icon) received and ignored icon={s}", .{icon}); }, .clipboard_contents => |clip| { - if (@hasDecl(T, "clipboardContents")) { - try self.handler.clipboardContents(clip.kind, clip.data); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.clipboard_contents, .{ + .kind = clip.kind, + .data = clip.data, + }); }, .prompt_start => |v| { - if (@hasDecl(T, "promptStart")) { - switch (v.kind) { - .primary, .right => try self.handler.promptStart(v.aid, v.redraw), - .continuation, .secondary => try self.handler.promptContinuation(v.aid), - } - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + @branchHint(.likely); + switch (v.kind) { + .primary, .right => try self.handler.vt(.prompt_start, .{ + .aid = v.aid, + .redraw = v.redraw, + }), + .continuation, .secondary => try self.handler.vt(.prompt_continuation, .{ + .aid = v.aid, + }), + } }, .prompt_end => { - if (@hasDecl(T, "promptEnd")) { - try self.handler.promptEnd(); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + @branchHint(.likely); + try self.handler.vt(.prompt_end, {}); }, - .end_of_input => { - if (@hasDecl(T, "endOfInput")) { - try self.handler.endOfInput(); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, + .end_of_input => try self.handler.vt(.end_of_input, {}), .end_of_command => |end| { - if (@hasDecl(T, "endOfCommand")) { - try self.handler.endOfCommand(end.exit_code); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.end_of_command, .{ .exit_code = end.exit_code }); }, .report_pwd => |v| { - if (@hasDecl(T, "reportPwd")) { - try self.handler.reportPwd(v.value); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + @branchHint(.likely); + try self.handler.vt(.report_pwd, .{ .url = v.value }); }, .mouse_shape => |v| { - if (@hasDecl(T, "setMouseShape")) { - const shape = MouseShape.fromString(v.value) orelse { - log.warn("unknown cursor shape: {s}", .{v.value}); - return; - }; - - try self.handler.setMouseShape(shape); + const shape = MouseShape.fromString(v.value) orelse { + @branchHint(.unlikely); + log.warn("unknown cursor shape: {s}", .{v.value}); return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }; + + try self.handler.vt(.mouse_shape, shape); }, .color_operation => |v| { - if (@hasDecl(T, "handleColorOperation")) { - try self.handler.handleColorOperation( - v.op, - &v.requests, - v.terminator, - ); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + @branchHint(.likely); + try self.handler.vt(.color_operation, .{ + .op = v.op, + .requests = v.requests, + .terminator = v.terminator, + }); }, .kitty_color_protocol => |v| { - if (@hasDecl(T, "sendKittyColorReport")) { - try self.handler.sendKittyColorReport(v); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.kitty_color_report, v); }, .show_desktop_notification => |v| { - if (@hasDecl(T, "showDesktopNotification")) { - try self.handler.showDesktopNotification(v.title, v.body); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.show_desktop_notification, .{ + .title = v.title, + .body = v.body, + }); }, .hyperlink_start => |v| { - if (@hasDecl(T, "startHyperlink")) { - try self.handler.startHyperlink(v.uri, v.id); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + @branchHint(.likely); + try self.handler.vt(.start_hyperlink, .{ + .uri = v.uri, + .id = v.id, + }); }, .hyperlink_end => { - if (@hasDecl(T, "endHyperlink")) { - try self.handler.endHyperlink(); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + @branchHint(.likely); + try self.handler.vt(.end_hyperlink, {}); }, .conemu_progress_report => |v| { - if (@hasDecl(T, "handleProgressReport")) { - try self.handler.handleProgressReport(v); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.progress_report, v); }, .conemu_sleep, @@ -1628,18 +2112,12 @@ 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", .{}); }, } - - // Fall through for when we don't have a handler. - if (@hasDecl(T, "oscUnimplemented")) { - try self.handler.oscUnimplemented(cmd); - } else { - log.warn("unimplemented OSC command: {s}", .{@tagName(cmd)}); - } } inline fn configureCharset( @@ -1660,19 +2138,15 @@ pub fn Stream(comptime Handler: type) type { '*' => .G2, '+' => .G3, else => { + @branchHint(.unlikely); log.warn("invalid charset intermediate: {any}", .{intermediates}); return; }, }; - if (@hasDecl(T, "configureCharset")) { - try self.handler.configureCharset(slot, set); - return; - } - - log.warn("unimplemented configureCharset callback slot={} set={}", .{ - slot, - set, + try self.handler.vt(.configure_charset, .{ + .slot = slot, + .charset = set, }); } @@ -1680,35 +2154,69 @@ 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' => if (@hasDecl(T, "saveCursor")) switch (action.intermediates.len) { - 0 => try self.handler.saveCursor(), - else => { - log.warn("invalid command: {f}", .{action}); - return; - }, - } else log.warn("unimplemented ESC callback: {f}", .{action}), + '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 => if (@hasDecl(T, "restoreCursor")) { - try self.handler.restoreCursor(); + 0 => { + try self.handler.vt(.restore_cursor, {}); break :blk {}; - } else log.warn("unimplemented restore cursor callback: {f}", .{action}), + }, 1 => switch (action.intermediates[0]) { // DECALN - Fill Screen with E - '#' => if (@hasDecl(T, "decaln")) { - try self.handler.decaln(); + '#' => { + try self.handler.vt(.decaln, {}); break :blk {}; - } else log.warn("unimplemented ESC callback: {f}", .{action}), + }, else => {}, }, @@ -1720,157 +2228,221 @@ pub fn Stream(comptime Handler: type) type { }, // IND - Index - 'D' => if (@hasDecl(T, "index")) switch (action.intermediates.len) { - 0 => try self.handler.index(), + 'D' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.index, {}), else => { + @branchHint(.unlikely); log.warn("invalid index command: {f}", .{action}); return; }, - } else log.warn("unimplemented ESC callback: {f}", .{action}), + }, // NEL - Next Line - 'E' => if (@hasDecl(T, "nextLine")) switch (action.intermediates.len) { - 0 => try self.handler.nextLine(), + 'E' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.next_line, {}), else => { + @branchHint(.unlikely); log.warn("invalid next line command: {f}", .{action}); return; }, - } else log.warn("unimplemented ESC callback: {f}", .{action}), + }, // HTS - Horizontal Tab Set - 'H' => if (@hasDecl(T, "tabSet")) switch (action.intermediates.len) { - 0 => try self.handler.tabSet(), + 'H' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.tab_set, {}), else => { + @branchHint(.unlikely); log.warn("invalid tab set command: {f}", .{action}); return; }, - } else log.warn("unimplemented tab set callback: {f}", .{action}), + }, // RI - Reverse Index - 'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) { - 0 => try self.handler.reverseIndex(), - else => { - log.warn("invalid reverse index command: {f}", .{action}); - return; - }, - } else log.warn("unimplemented ESC callback: {f}", .{action}), + '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 - 'N' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GL, .G2, true), + 'N' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GL, + .charset = .G2, + .locking = true, + }), else => { + @branchHint(.unlikely); log.warn("invalid single shift 2 command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // SS3 - Single Shift 3 - 'O' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GL, .G3, true), + 'O' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GL, + .charset = .G3, + .locking = true, + }), else => { + @branchHint(.unlikely); log.warn("invalid single shift 3 command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // SPA - Start of Guarded Area - 'V' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) { - try self.handler.setProtectedMode(ansi.ProtectedMode.iso); - } else log.warn("unimplemented ESC callback: {f}", .{action}), + 'V' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.protected_mode_iso, {}), + else => log.warn("unimplemented ESC callback: {f}", .{action}), + }, // EPA - End of Guarded Area - 'W' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) { - try self.handler.setProtectedMode(ansi.ProtectedMode.off); - } else log.warn("unimplemented ESC callback: {f}", .{action}), + 'W' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.protected_mode_off, {}), + else => log.warn("unimplemented ESC callback: {f}", .{action}), + }, // DECID - 'Z' => if (@hasDecl(T, "deviceAttributes") and action.intermediates.len == 0) { - try self.handler.deviceAttributes(.primary, &.{}); + 'Z' => if (action.intermediates.len == 0) { + try self.handler.vt(.device_attributes, .primary); } else log.warn("unimplemented ESC callback: {f}", .{action}), // RIS - Full Reset - 'c' => if (@hasDecl(T, "fullReset")) switch (action.intermediates.len) { - 0 => try self.handler.fullReset(), + 'c' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.full_reset, {}), else => { log.warn("invalid full reset command: {f}", .{action}); return; }, - } else log.warn("unimplemented ESC callback: {f}", .{action}), + }, // LS2 - Locking Shift 2 - 'n' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GL, .G2, false), + 'n' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GL, + .charset = .G2, + .locking = false, + }), else => { + @branchHint(.unlikely); log.warn("invalid single shift 2 command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // LS3 - Locking Shift 3 - 'o' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GL, .G3, false), + 'o' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GL, + .charset = .G3, + .locking = false, + }), else => { + @branchHint(.unlikely); log.warn("invalid single shift 3 command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // LS1R - Locking Shift 1 Right - '~' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GR, .G1, false), + '~' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GR, + .charset = .G1, + .locking = false, + }), else => { + @branchHint(.unlikely); log.warn("invalid locking shift 1 right command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // LS2R - Locking Shift 2 Right - '}' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GR, .G2, false), + '}' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GR, + .charset = .G2, + .locking = false, + }), else => { + @branchHint(.unlikely); log.warn("invalid locking shift 2 right command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // LS3R - Locking Shift 3 Right - '|' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GR, .G3, false), + '|' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GR, + .charset = .G3, + .locking = false, + }), else => { + @branchHint(.unlikely); log.warn("invalid locking shift 3 right command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // Set application keypad mode - '=' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) { - try self.handler.setMode(.keypad_keys, true); - } 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 - '>' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) { - try self.handler.setMode(.keypad_keys, false); - } else log.warn("unimplemented setMode: {f}", .{action}), - - else => if (@hasDecl(T, "escUnimplemented")) - try self.handler.escUnimplemented(action) - else - log.warn("unimplemented ESC action: {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}), } } }; } +test Action { + // Forces the C type to be reified when the target is C, ensuring + // all our types are C ABI compatible. + _ = Action.C; +} + test "stream: print" { const H = struct { c: ?u21 = 0, - pub fn print(self: *@This(), c: u21) !void { - self.c = c; + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) !void { + switch (action) { + .print => self.c = value.cp, + else => {}, + } } }; @@ -1883,8 +2455,15 @@ test "simd: print invalid utf-8" { const H = struct { c: ?u21 = 0, - pub fn print(self: *@This(), c: u21) !void { - self.c = c; + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) !void { + switch (action) { + .print => self.c = value.cp, + else => {}, + } } }; @@ -1897,8 +2476,15 @@ test "simd: complete incomplete utf-8" { const H = struct { c: ?u21 = null, - pub fn print(self: *@This(), c: u21) !void { - self.c = c; + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) !void { + switch (action) { + .print => self.c = value.cp, + else => {}, + } } }; @@ -1915,8 +2501,15 @@ test "stream: cursor right (CUF)" { const H = struct { amount: u16 = 0, - pub fn setCursorRight(self: *@This(), v: u16) !void { - self.amount = v; + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) !void { + switch (action) { + .cursor_right => self.amount = value.value, + else => {}, + } } }; @@ -1939,9 +2532,17 @@ test "stream: cursor right (CUF)" { test "stream: dec set mode (SM) and reset mode (RM)" { const H = struct { mode: modes.Mode = @as(modes.Mode, @enumFromInt(1)), - pub fn setMode(self: *@This(), mode: modes.Mode, v: bool) !void { - self.mode = @as(modes.Mode, @enumFromInt(1)); - if (v) self.mode = mode; + + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) !void { + switch (action) { + .set_mode => self.mode = value.mode, + .reset_mode => self.mode = @as(modes.Mode, @enumFromInt(1)), + else => {}, + } } }; @@ -1961,9 +2562,16 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { const H = struct { mode: ?modes.Mode = null, - pub fn setMode(self: *@This(), mode: modes.Mode, v: bool) !void { - self.mode = null; - if (v) self.mode = mode; + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) !void { + switch (action) { + .set_mode => self.mode = value.mode, + .reset_mode => self.mode = null, + else => {}, + } } }; @@ -1987,6 +2595,15 @@ test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { self.mode = null; if (v) self.mode = mode; } + + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) !void { + _ = self; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2002,10 +2619,16 @@ test "stream: restore mode" { const Self = @This(); called: bool = false, - pub fn setTopAndBottomMargin(self: *Self, t: u16, b: u16) !void { - _ = t; - _ = b; - self.called = true; + pub fn vt( + self: *Self, + comptime action: Stream(Self).Action.Tag, + value: Stream(Self).Action.Value(action), + ) !void { + _ = value; + switch (action) { + .top_and_bottom_margin => self.called = true, + else => {}, + } } }; @@ -2019,8 +2642,15 @@ test "stream: pop kitty keyboard with no params defaults to 1" { const Self = @This(); n: u16 = 0, - pub fn popKittyKeyboard(self: *Self, n: u16) !void { - self.n = n; + pub fn vt( + self: *Self, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), + ) !void { + switch (action) { + .kitty_keyboard_pop => self.n = value, + else => {}, + } } }; @@ -2034,8 +2664,18 @@ test "stream: DECSCA" { const Self = @This(); v: ?ansi.ProtectedMode = null, - pub fn setProtectedMode(self: *Self, v: ansi.ProtectedMode) !void { - self.v = v; + pub fn vt( + self: *Self, + comptime action: Stream(Self).Action.Tag, + value: Stream(Self).Action.Value(action), + ) !void { + _ = value; + switch (action) { + .protected_mode_off => self.v = .off, + .protected_mode_iso => self.v = .iso, + .protected_mode_dec => self.v = .dec, + else => {}, + } } }; @@ -2064,13 +2704,34 @@ test "stream: DECED, DECSED" { mode: ?csi.EraseDisplay = null, protected: ?bool = null, - pub fn eraseDisplay( + pub fn vt( self: *Self, - mode: csi.EraseDisplay, - protected: bool, + comptime action: anytype, + value: anytype, ) !void { - self.mode = mode; - self.protected = protected; + switch (action) { + .erase_display_below => { + self.mode = .below; + self.protected = value; + }, + .erase_display_above => { + self.mode = .above; + self.protected = value; + }, + .erase_display_complete => { + self.mode = .complete; + self.protected = value; + }, + .erase_display_scrollback => { + self.mode = .scrollback; + self.protected = value; + }, + .erase_display_scroll_complete => { + self.mode = .scroll_complete; + self.protected = value; + }, + else => {}, + } } }; @@ -2140,13 +2801,30 @@ test "stream: DECEL, DECSEL" { mode: ?csi.EraseLine = null, protected: ?bool = null, - pub fn eraseLine( + pub fn vt( self: *Self, - mode: csi.EraseLine, - protected: bool, + comptime action: anytype, + value: anytype, ) !void { - self.mode = mode; - self.protected = protected; + switch (action) { + .erase_line_right => { + self.mode = .right; + self.protected = value; + }, + .erase_line_left => { + self.mode = .left; + self.protected = value; + }, + .erase_line_complete => { + self.mode = .complete; + self.protected = value; + }, + .erase_line_right_unless_pending_wrap => { + self.mode = .right_unless_pending_wrap; + self.protected = value; + }, + else => {}, + } } }; @@ -2204,8 +2882,15 @@ test "stream: DECSCUSR" { const H = struct { style: ?ansi.CursorStyle = null, - pub fn setCursorStyle(self: *@This(), style: ansi.CursorStyle) !void { - self.style = style; + pub fn vt( + self: *@This(), + comptime action: Stream(@This()).Action.Tag, + value: Stream(@This()).Action.Value(action), + ) !void { + switch (action) { + .cursor_style => self.style = value, + else => {}, + } } }; @@ -2225,8 +2910,15 @@ test "stream: DECSCUSR without space" { const H = struct { style: ?ansi.CursorStyle = null, - pub fn setCursorStyle(self: *@This(), style: ansi.CursorStyle) !void { - self.style = style; + pub fn vt( + self: *@This(), + comptime action: Stream(@This()).Action.Tag, + value: Stream(@This()).Action.Value(action), + ) !void { + switch (action) { + .cursor_style => self.style = value, + else => {}, + } } }; @@ -2242,8 +2934,15 @@ test "stream: XTSHIFTESCAPE" { const H = struct { escape: ?bool = null, - pub fn setMouseShiftCapture(self: *@This(), v: bool) !void { - self.escape = v; + pub fn vt( + self: *@This(), + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), + ) !void { + switch (action) { + .mouse_shift_capture => self.escape = value, + else => {}, + } } }; @@ -2269,10 +2968,16 @@ test "stream: change window title with invalid utf-8" { const H = struct { seen: bool = false, - pub fn changeWindowTitle(self: *@This(), title: []const u8) !void { - _ = title; - - self.seen = true; + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = value; + switch (action) { + .window_title => self.seen = true, + else => {}, + } } }; @@ -2294,9 +2999,16 @@ test "stream: insert characters" { const Self = @This(); called: bool = false, - pub fn insertBlanks(self: *Self, v: u16) !void { - _ = v; - self.called = true; + pub fn vt( + self: *Self, + comptime action: anytype, + value: anytype, + ) !void { + _ = value; + switch (action) { + .insert_blanks => self.called = true, + else => {}, + } } }; @@ -2314,15 +3026,17 @@ test "stream: SCOSC" { const Self = @This(); called: bool = false, - pub fn setLeftAndRightMargin(self: *Self, left: u16, right: u16) !void { - _ = self; - _ = left; - _ = right; - @panic("bad"); - } - - pub fn setLeftAndRightMarginAmbiguous(self: *Self) !void { - self.called = true; + pub fn vt( + self: *Self, + comptime action: Stream(Self).Action.Tag, + value: Stream(Self).Action.Value(action), + ) !void { + _ = value; + switch (action) { + .left_and_right_margin => @panic("bad"), + .left_and_right_margin_ambiguous => self.called = true, + else => {}, + } } }; @@ -2336,8 +3050,16 @@ test "stream: SCORC" { const Self = @This(); called: bool = false, - pub fn restoreCursor(self: *Self) !void { - self.called = true; + pub fn vt( + self: *Self, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), + ) !void { + _ = value; + switch (action) { + .restore_cursor => self.called = true, + else => {}, + } } }; @@ -2348,10 +3070,17 @@ test "stream: SCORC" { test "stream: too many csi params" { const H = struct { - pub fn setCursorRight(self: *@This(), v: u16) !void { - _ = v; + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { _ = self; - unreachable; + _ = value; + switch (action) { + .cursor_right => unreachable, + else => {}, + } } }; @@ -2361,9 +3090,14 @@ test "stream: too many csi params" { test "stream: csi param too long" { const H = struct { - pub fn setCursorRight(self: *@This(), v: u16) !void { - _ = v; + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { _ = self; + _ = action; + _ = value; } }; @@ -2375,8 +3109,15 @@ test "stream: send report with CSI t" { const H = struct { style: ?csi.SizeReportStyle = null, - pub fn sendSizeReport(self: *@This(), style: csi.SizeReportStyle) void { - self.style = style; + pub fn vt( + self: *@This(), + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), + ) !void { + switch (action) { + .size_report => self.style = value, + else => {}, + } } }; @@ -2402,6 +3143,16 @@ test "stream: invalid CSI t" { pub fn sendSizeReport(self: *@This(), style: csi.SizeReportStyle) void { self.style = style; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2412,207 +3163,261 @@ test "stream: invalid CSI t" { test "stream: CSI t push title" { const H = struct { - op: ?csi.TitlePushPop = null, + index: ?u16 = null, - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; + pub fn vt( + self: *@This(), + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), + ) !void { + switch (action) { + .title_push => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[22;0t"); - try testing.expectEqual(csi.TitlePushPop{ - .op = .push, - .index = 0, - }, s.handler.op.?); + try testing.expectEqual(@as(u16, 0), s.handler.index.?); } test "stream: CSI t push title with explicit window" { const H = struct { - op: ?csi.TitlePushPop = null, + index: ?u16 = null, - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; + pub fn vt( + self: *@This(), + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), + ) !void { + switch (action) { + .title_push => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[22;2t"); - try testing.expectEqual(csi.TitlePushPop{ - .op = .push, - .index = 0, - }, s.handler.op.?); + try testing.expectEqual(@as(u16, 0), s.handler.index.?); } test "stream: CSI t push title with explicit icon" { const H = struct { - op: ?csi.TitlePushPop = null, + index: ?u16 = null, - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; + pub fn vt( + self: *@This(), + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), + ) !void { + switch (action) { + .title_push => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[22;1t"); - try testing.expectEqual(null, s.handler.op); + try testing.expectEqual(null, s.handler.index); } test "stream: CSI t push title with index" { const H = struct { - op: ?csi.TitlePushPop = null, + index: ?u16 = null, - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; + pub fn vt( + self: *@This(), + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), + ) !void { + switch (action) { + .title_push => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[22;0;5t"); - try testing.expectEqual(csi.TitlePushPop{ - .op = .push, - .index = 5, - }, s.handler.op.?); + try testing.expectEqual(@as(u16, 5), s.handler.index.?); } test "stream: CSI t pop title" { const H = struct { - op: ?csi.TitlePushPop = null, + index: ?u16 = null, - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; + pub fn vt( + self: *@This(), + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), + ) !void { + switch (action) { + .title_pop => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[23;0t"); - try testing.expectEqual(csi.TitlePushPop{ - .op = .pop, - .index = 0, - }, s.handler.op.?); + try testing.expectEqual(@as(u16, 0), s.handler.index.?); } test "stream: CSI t pop title with explicit window" { const H = struct { - op: ?csi.TitlePushPop = null, + index: ?u16 = null, - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; + pub fn vt( + self: *@This(), + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), + ) !void { + switch (action) { + .title_pop => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[23;2t"); - try testing.expectEqual(csi.TitlePushPop{ - .op = .pop, - .index = 0, - }, s.handler.op.?); + try testing.expectEqual(@as(u16, 0), s.handler.index.?); } test "stream: CSI t pop title with explicit icon" { const H = struct { - op: ?csi.TitlePushPop = null, + index: ?u16 = null, - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; + pub fn vt( + self: *@This(), + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), + ) !void { + switch (action) { + .title_pop => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[23;1t"); - try testing.expectEqual(null, s.handler.op); + try testing.expectEqual(null, s.handler.index); } test "stream: CSI t pop title with index" { const H = struct { - op: ?csi.TitlePushPop = null, + index: ?u16 = null, - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; + pub fn vt( + self: *@This(), + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), + ) !void { + switch (action) { + .title_pop => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[23;0;5t"); - try testing.expectEqual(csi.TitlePushPop{ - .op = .pop, - .index = 5, - }, s.handler.op.?); + try testing.expectEqual(@as(u16, 5), s.handler.index.?); } test "stream CSI W clear tab stops" { const H = struct { - op: ?csi.TabClear = null, + action: ?Action.Key = null, - pub fn tabClear(self: *@This(), op: csi.TabClear) !void { - self.op = op; + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = value; + self.action = action; } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[2W"); - try testing.expectEqual(csi.TabClear.current, s.handler.op.?); + try testing.expectEqual(Action.Key.tab_clear_current, s.handler.action.?); try s.nextSlice("\x1b[5W"); - try testing.expectEqual(csi.TabClear.all, s.handler.op.?); + try testing.expectEqual(Action.Key.tab_clear_all, s.handler.action.?); } test "stream CSI W tab set" { const H = struct { - called: bool = false, + action: ?Action.Key = null, - pub fn tabSet(self: *@This()) !void { - self.called = true; + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = value; + self.action = action; } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[W"); - try testing.expect(s.handler.called); + try testing.expectEqual(Action.Key.tab_set, s.handler.action.?); - s.handler.called = false; + s.handler.action = null; try s.nextSlice("\x1b[0W"); - try testing.expect(s.handler.called); + try testing.expectEqual(Action.Key.tab_set, s.handler.action.?); - s.handler.called = false; + s.handler.action = null; try s.nextSlice("\x1b[>W"); - try testing.expect(!s.handler.called); + try testing.expect(s.handler.action == null); - s.handler.called = false; + s.handler.action = null; try s.nextSlice("\x1b[99W"); - try testing.expect(!s.handler.called); + try testing.expect(s.handler.action == null); } test "stream CSI ? W reset tab stops" { const H = struct { - reset: bool = false, + action: ?Action.Key = null, - pub fn tabReset(self: *@This()) !void { - self.reset = true; + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = value; + self.action = action; } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[?2W"); - try testing.expect(!s.handler.reset); + try testing.expect(s.handler.action == null); try s.nextSlice("\x1b[?5W"); - try testing.expect(s.handler.reset); + try testing.expectEqual(Action.Key.tab_reset, s.handler.action.?); // Invalid and ignored by the handler + s.handler.action = null; try s.nextSlice("\x1b[?1;2;3W"); - try testing.expect(s.handler.reset); + try testing.expect(s.handler.action == null); } test "stream: SGR with 17+ parameters for underline color" { @@ -2620,9 +3425,18 @@ test "stream: SGR with 17+ parameters for underline color" { attrs: ?sgr.Attribute = null, called: bool = false, - pub fn setAttribute(self: *@This(), attr: sgr.Attribute) !void { - self.attrs = attr; - self.called = true; + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + switch (action) { + .set_attribute => { + self.attrs = value; + self.called = true; + }, + else => {}, + } } }; diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig new file mode 100644 index 000000000..c33dba1bb --- /dev/null +++ b/src/terminal/stream_readonly.zig @@ -0,0 +1,878 @@ +const std = @import("std"); +const testing = std.testing; +const stream = @import("stream.zig"); +const Action = stream.Action; +const Screen = @import("Screen.zig"); +const modes = @import("modes.zig"); +const osc_color = @import("osc/color.zig"); +const kitty_color = @import("kitty/color.zig"); +const Terminal = @import("Terminal.zig"); + +/// This is a Stream implementation that processes actions against +/// a Terminal and updates the Terminal state. It is called "readonly" because +/// it only processes actions that modify terminal state, while ignoring +/// any actions that require a response (like queries). +/// +/// If you're implementing a terminal emulator that only needs to render +/// output and doesn't need to respond (since it maybe isn't running the +/// actual program), this is the stream type to use. For example, this is +/// ideal for replay tooling, CI logs, PaaS builder output, etc. +pub const Stream = stream.Stream(Handler); + +/// See Stream, which is just the stream wrapper around this. +/// +/// This isn't attached directly to Terminal because there is additional +/// state and options we plan to add in the future, such as APC/DCS which +/// don't make sense to me to add to the Terminal directly. Instead, you +/// can call `vtHandler` on Terminal to initialize this handler. +pub const Handler = struct { + /// The terminal state to modify. + terminal: *Terminal, + + pub fn init(terminal: *Terminal) Handler { + return .{ + .terminal = terminal, + }; + } + + pub fn deinit(self: *Handler) void { + // Currently does nothing but may in the future so callers should + // call this. + _ = self; + } + + pub fn vt( + self: *Handler, + comptime action: Action.Tag, + value: Action.Value(action), + ) !void { + switch (action) { + .print => try self.terminal.print(value.cp), + .print_repeat => try self.terminal.printRepeat(value), + .backspace => self.terminal.backspace(), + .carriage_return => self.terminal.carriageReturn(), + .linefeed => try self.terminal.linefeed(), + .index => try self.terminal.index(), + .next_line => { + try self.terminal.index(); + self.terminal.carriageReturn(); + }, + .reverse_index => self.terminal.reverseIndex(), + .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_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( + self.terminal.screens.active.cursor.y + 1, + self.terminal.screens.active.cursor.x + 1 +| value.value, + ), + .cursor_row_relative => self.terminal.setCursorPos( + self.terminal.screens.active.cursor.y + 1 +| value.value, + self.terminal.screens.active.cursor.x + 1, + ), + .cursor_style => { + const blink = switch (value) { + .default, .steady_block, .steady_bar, .steady_underline => false, + .blinking_block, .blinking_bar, .blinking_underline => true, + }; + const style: Screen.CursorStyle = switch (value) { + .default, .blinking_block, .steady_block => .block, + .blinking_bar, .steady_bar => .bar, + .blinking_underline, .steady_underline => .underline, + }; + self.terminal.modes.set(.cursor_blinking, blink); + self.terminal.screens.active.cursor.cursor_style = style; + }, + .erase_display_below => self.terminal.eraseDisplay(.below, value), + .erase_display_above => self.terminal.eraseDisplay(.above, value), + .erase_display_complete => self.terminal.eraseDisplay(.complete, value), + .erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value), + .erase_display_scroll_complete => self.terminal.eraseDisplay(.scroll_complete, value), + .erase_line_right => self.terminal.eraseLine(.right, value), + .erase_line_left => self.terminal.eraseLine(.left, value), + .erase_line_complete => self.terminal.eraseLine(.complete, value), + .erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value), + .delete_chars => self.terminal.deleteChars(value), + .erase_chars => self.terminal.eraseChars(value), + .insert_lines => self.terminal.insertLines(value), + .insert_blanks => self.terminal.insertBlanks(value), + .delete_lines => self.terminal.deleteLines(value), + .scroll_up => try self.terminal.scrollUp(value), + .scroll_down => self.terminal.scrollDown(value), + .horizontal_tab => try self.horizontalTab(value), + .horizontal_tab_back => try self.horizontalTabBack(value), + .tab_clear_current => self.terminal.tabClear(.current), + .tab_clear_all => self.terminal.tabClear(.all), + .tab_set => self.terminal.tabSet(), + .tab_reset => self.terminal.tabReset(), + .set_mode => try self.setMode(value.mode, true), + .reset_mode => try self.setMode(value.mode, false), + .save_mode => self.terminal.modes.save(value.mode), + .restore_mode => { + const v = self.terminal.modes.restore(value.mode); + try self.setMode(value.mode, v); + }, + .top_and_bottom_margin => self.terminal.setTopAndBottomMargin(value.top_left, value.bottom_right), + .left_and_right_margin => self.terminal.setLeftAndRightMargin(value.top_left, value.bottom_right), + .left_and_right_margin_ambiguous => { + if (self.terminal.modes.get(.enable_left_and_right_margin)) { + self.terminal.setLeftAndRightMargin(0, 0); + } else { + self.terminal.saveCursor(); + } + }, + .save_cursor => self.terminal.saveCursor(), + .restore_cursor => try self.terminal.restoreCursor(), + .invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking), + .configure_charset => self.terminal.configureCharset(value.slot, value.charset), + .set_attribute => switch (value) { + .unknown => {}, + else => self.terminal.setAttribute(value) catch {}, + }, + .protected_mode_off => self.terminal.setProtectedMode(.off), + .protected_mode_iso => self.terminal.setProtectedMode(.iso), + .protected_mode_dec => self.terminal.setProtectedMode(.dec), + .mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false, + .kitty_keyboard_push => self.terminal.screens.active.kitty_keyboard.push(value.flags), + .kitty_keyboard_pop => self.terminal.screens.active.kitty_keyboard.pop(@intCast(value)), + .kitty_keyboard_set => self.terminal.screens.active.kitty_keyboard.set(.set, value.flags), + .kitty_keyboard_set_or => self.terminal.screens.active.kitty_keyboard.set(.@"or", value.flags), + .kitty_keyboard_set_not => self.terminal.screens.active.kitty_keyboard.set(.not, value.flags), + .modify_key_format => { + self.terminal.flags.modify_other_keys_2 = false; + switch (value) { + .other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true, + else => {}, + } + }, + .active_status_display => self.terminal.status_display = value, + .decaln => try self.terminal.decaln(), + .full_reset => self.terminal.fullReset(), + .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), + .end_hyperlink => self.terminal.screens.active.endHyperlink(), + .prompt_start => { + self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt; + self.terminal.flags.shell_redraws_prompt = value.redraw; + }, + .prompt_continuation => self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation, + .prompt_end => self.terminal.markSemanticPrompt(.input), + .end_of_input => self.terminal.markSemanticPrompt(.command), + .end_of_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, + .mouse_shape => self.terminal.mouse_shape = value, + .color_operation => try self.colorOperation(value.op, &value.requests), + .kitty_color_report => try self.kittyColorOperation(value), + + // No supported DCS commands have any terminal-modifying effects, + // but they may in the future. For now we just ignore it. + .dcs_hook, + .dcs_put, + .dcs_unhook, + => {}, + + // APC can modify terminal state (Kitty graphics) but we don't + // currently support it in the readonly stream. + .apc_start, + .apc_end, + .apc_put, + => {}, + + // Have no terminal-modifying effect + .bell, + .enquiry, + .request_mode, + .request_mode_unknown, + .size_report, + .xtversion, + .device_attributes, + .device_status, + .kitty_keyboard_query, + .window_title, + .report_pwd, + .show_desktop_notification, + .progress_report, + .clipboard_contents, + .title_push, + .title_pop, + => {}, + } + } + + inline fn horizontalTab(self: *Handler, count: u16) !void { + for (0..count) |_| { + const x = self.terminal.screens.active.cursor.x; + try self.terminal.horizontalTab(); + if (x == self.terminal.screens.active.cursor.x) break; + } + } + + inline fn horizontalTabBack(self: *Handler, count: u16) !void { + for (0..count) |_| { + const x = self.terminal.screens.active.cursor.x; + try self.terminal.horizontalTabBack(); + if (x == self.terminal.screens.active.cursor.x) break; + } + } + + fn setMode(self: *Handler, mode: modes.Mode, enabled: bool) !void { + // Set the mode on the terminal + self.terminal.modes.set(mode, enabled); + + // Some modes require additional processing + switch (mode) { + .autorepeat, + .reverse_colors, + => {}, + + .origin => self.terminal.setCursorPos(1, 1), + + .enable_left_and_right_margin => if (!enabled) { + self.terminal.scrolling_region.left = 0; + self.terminal.scrolling_region.right = self.terminal.cols - 1; + }, + + .alt_screen_legacy => try self.terminal.switchScreenMode(.@"47", enabled), + .alt_screen => try self.terminal.switchScreenMode(.@"1047", enabled), + .alt_screen_save_cursor_clear_enter => try self.terminal.switchScreenMode(.@"1049", enabled), + + .save_cursor => if (enabled) { + self.terminal.saveCursor(); + } else { + try self.terminal.restoreCursor(); + }, + + .enable_mode_3 => {}, + + .@"132_column" => try self.terminal.deccolm( + self.terminal.screens.active.alloc, + if (enabled) .@"132_cols" else .@"80_cols", + ), + + .synchronized_output, + .linefeed, + .in_band_size_reports, + .focus_event, + => {}, + + .mouse_event_x10 => { + if (enabled) { + self.terminal.flags.mouse_event = .x10; + } else { + self.terminal.flags.mouse_event = .none; + } + }, + .mouse_event_normal => { + if (enabled) { + self.terminal.flags.mouse_event = .normal; + } else { + self.terminal.flags.mouse_event = .none; + } + }, + .mouse_event_button => { + if (enabled) { + self.terminal.flags.mouse_event = .button; + } else { + self.terminal.flags.mouse_event = .none; + } + }, + .mouse_event_any => { + if (enabled) { + self.terminal.flags.mouse_event = .any; + } else { + self.terminal.flags.mouse_event = .none; + } + }, + + .mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10, + .mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10, + .mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10, + .mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10, + + else => {}, + } + } + + fn colorOperation( + self: *Handler, + op: osc_color.Operation, + requests: *const osc_color.List, + ) !void { + _ = op; + if (requests.count() == 0) return; + + var it = requests.constIterator(0); + while (it.next()) |req| { + switch (req.*) { + .set => |set| { + switch (set.target) { + .palette => |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.set(i, set.color); + }, + .dynamic => |dynamic| switch (dynamic) { + .foreground => self.terminal.colors.foreground.set(set.color), + .background => self.terminal.colors.background.set(set.color), + .cursor => self.terminal.colors.cursor.set(set.color), + .pointer_foreground, + .pointer_background, + .tektronix_foreground, + .tektronix_background, + .highlight_background, + .tektronix_cursor, + .highlight_foreground, + => {}, + }, + .special => {}, + } + }, + + .reset => |target| switch (target) { + .palette => |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(i); + }, + .dynamic => |dynamic| switch (dynamic) { + .foreground => self.terminal.colors.foreground.reset(), + .background => self.terminal.colors.background.reset(), + .cursor => self.terminal.colors.cursor.reset(), + .pointer_foreground, + .pointer_background, + .tektronix_foreground, + .tektronix_background, + .highlight_background, + .tektronix_cursor, + .highlight_foreground, + => {}, + }, + .special => {}, + }, + + .reset_palette => { + const mask = &self.terminal.colors.palette.mask; + var mask_it = mask.iterator(.{}); + while (mask_it.next()) |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(@intCast(i)); + } + mask.* = .initEmpty(); + }, + + .query, + .reset_special, + => {}, + } + } + } + + fn kittyColorOperation( + self: *Handler, + request: kitty_color.OSC, + ) !void { + for (request.list.items) |item| { + switch (item) { + .set => |v| switch (v.key) { + .palette => |palette| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.set(palette, v.color); + }, + .special => |special| switch (special) { + .foreground => self.terminal.colors.foreground.set(v.color), + .background => self.terminal.colors.background.set(v.color), + .cursor => self.terminal.colors.cursor.set(v.color), + else => {}, + }, + }, + .reset => |key| switch (key) { + .palette => |palette| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(palette); + }, + .special => |special| switch (special) { + .foreground => self.terminal.colors.foreground.reset(), + .background => self.terminal.colors.background.reset(), + .cursor => self.terminal.colors.cursor.reset(), + else => {}, + }, + }, + .query => {}, + } + } + } +}; + +test "basic print" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + try s.nextSlice("Hello"); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello", str); +} + +test "cursor movement" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Move cursor using escape sequences + try s.nextSlice("Hello\x1B[1;1H"); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + + // Move to position 2,3 + try s.nextSlice("\x1B[2;3H"); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); +} + +test "erase operations" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 20, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Print some text + try s.nextSlice("Hello World"); + try testing.expectEqual(@as(usize, 11), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + + // Move cursor to position 1,6 and erase from cursor to end of line + try s.nextSlice("\x1B[1;6H"); + try s.nextSlice("\x1B[K"); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello", str); +} + +test "tabs" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + try s.nextSlice("A\tB"); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.x); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A B", str); +} + +test "modes" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Test wraparound mode + try testing.expect(t.modes.get(.wraparound)); + try s.nextSlice("\x1B[?7l"); // Disable wraparound + try testing.expect(!t.modes.get(.wraparound)); + try s.nextSlice("\x1B[?7h"); // Enable wraparound + try testing.expect(t.modes.get(.wraparound)); +} + +test "scrolling regions" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set scrolling region from line 5 to 20 + try s.nextSlice("\x1B[5;20r"); + try testing.expectEqual(@as(usize, 4), t.scrolling_region.top); + try testing.expectEqual(@as(usize, 19), t.scrolling_region.bottom); + try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); + try testing.expectEqual(@as(usize, 79), t.scrolling_region.right); +} + +test "charsets" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Configure G0 as DEC special graphics + try s.nextSlice("\x1B(0"); + try s.nextSlice("`"); // Should print diamond character + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("◆", str); +} + +test "alt screen" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 5 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write to primary screen + try s.nextSlice("Primary"); + try testing.expectEqual(.primary, t.screens.active_key); + + // Switch to alt screen + try s.nextSlice("\x1B[?1049h"); + try testing.expectEqual(.alternate, t.screens.active_key); + + // Write to alt screen + try s.nextSlice("Alt"); + + // Switch back to primary + try s.nextSlice("\x1B[?1049l"); + try testing.expectEqual(.primary, t.screens.active_key); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Primary", str); +} + +test "cursor save and restore" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Move cursor to 10,15 + try s.nextSlice("\x1B[10;15H"); + try testing.expectEqual(@as(usize, 14), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); + + // Save cursor + try s.nextSlice("\x1B7"); + + // Move cursor elsewhere + try s.nextSlice("\x1B[1;1H"); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + + // Restore cursor + try s.nextSlice("\x1B8"); + try testing.expectEqual(@as(usize, 14), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); +} + +test "attributes" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set bold and write text + try s.nextSlice("\x1B[1mBold\x1B[0m"); + + // Verify we can write attributes - just check the string was written + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Bold", str); +} + +test "DECALN screen alignment" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 3 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Run DECALN + try s.nextSlice("\x1B#8"); + + // Verify entire screen is filled with 'E' + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("EEEEEEEEEE\nEEEEEEEEEE\nEEEEEEEEEE", str); + + // Cursor should be at 1,1 + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); +} + +test "full reset" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Make some changes + try s.nextSlice("Hello"); + try s.nextSlice("\x1B[10;20H"); + try s.nextSlice("\x1B[5;20r"); // Set scroll region + try s.nextSlice("\x1B[?7l"); // Disable wraparound + + // Full reset + try s.nextSlice("\x1Bc"); + + // Verify reset state + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.scrolling_region.top); + try testing.expectEqual(@as(usize, 23), t.scrolling_region.bottom); + try testing.expect(t.modes.get(.wraparound)); +} + +test "ignores query actions" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // These should be ignored without error + try s.nextSlice("\x1B[c"); // Device attributes + try s.nextSlice("\x1B[5n"); // Device status report + try s.nextSlice("\x1B[6n"); // Cursor position report + + // Terminal should still be functional + try s.nextSlice("Test"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Test", str); +} + +test "OSC 4 set and reset palette" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Save default color + const default_color_0 = t.colors.palette.original[0]; + + // Set color 0 to red + try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[0].r); + try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[0].g); + try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[0].b); + try testing.expect(t.colors.palette.mask.isSet(0)); + + // Reset color 0 + try s.nextSlice("\x1b]104;0\x1b\\"); + try testing.expectEqual(default_color_0, t.colors.palette.current[0]); + try testing.expect(!t.colors.palette.mask.isSet(0)); +} + +test "OSC 104 reset all palette colors" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set multiple colors + try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + try s.nextSlice("\x1b]4;1;rgb:00/ff/00\x1b\\"); + try s.nextSlice("\x1b]4;2;rgb:00/00/ff\x1b\\"); + try testing.expect(t.colors.palette.mask.isSet(0)); + try testing.expect(t.colors.palette.mask.isSet(1)); + try testing.expect(t.colors.palette.mask.isSet(2)); + + // Reset all palette colors + try s.nextSlice("\x1b]104\x1b\\"); + try testing.expectEqual(t.colors.palette.original[0], t.colors.palette.current[0]); + try testing.expectEqual(t.colors.palette.original[1], t.colors.palette.current[1]); + try testing.expectEqual(t.colors.palette.original[2], t.colors.palette.current[2]); + try testing.expect(!t.colors.palette.mask.isSet(0)); + try testing.expect(!t.colors.palette.mask.isSet(1)); + try testing.expect(!t.colors.palette.mask.isSet(2)); +} + +test "OSC 10 set and reset foreground color" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Initially unset + try testing.expect(t.colors.foreground.get() == null); + + // Set foreground to red + try s.nextSlice("\x1b]10;rgb:ff/00/00\x1b\\"); + const fg = t.colors.foreground.get().?; + try testing.expectEqual(@as(u8, 0xff), fg.r); + try testing.expectEqual(@as(u8, 0x00), fg.g); + try testing.expectEqual(@as(u8, 0x00), fg.b); + + // Reset foreground + try s.nextSlice("\x1b]110\x1b\\"); + try testing.expect(t.colors.foreground.get() == null); +} + +test "OSC 11 set and reset background color" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set background to green + try s.nextSlice("\x1b]11;rgb:00/ff/00\x1b\\"); + const bg = t.colors.background.get().?; + try testing.expectEqual(@as(u8, 0x00), bg.r); + try testing.expectEqual(@as(u8, 0xff), bg.g); + try testing.expectEqual(@as(u8, 0x00), bg.b); + + // Reset background + try s.nextSlice("\x1b]111\x1b\\"); + try testing.expect(t.colors.background.get() == null); +} + +test "OSC 12 set and reset cursor color" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set cursor to blue + try s.nextSlice("\x1b]12;rgb:00/00/ff\x1b\\"); + const cursor = t.colors.cursor.get().?; + try testing.expectEqual(@as(u8, 0x00), cursor.r); + try testing.expectEqual(@as(u8, 0x00), cursor.g); + try testing.expectEqual(@as(u8, 0xff), cursor.b); + + // Reset cursor + try s.nextSlice("\x1b]112\x1b\\"); + // After reset, cursor might be null (using default) +} + +test "kitty color protocol set palette" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set palette color 5 to magenta using kitty protocol + try s.nextSlice("\x1b]21;5=rgb:ff/00/ff\x1b\\"); + try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[5].r); + try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[5].g); + try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[5].b); + try testing.expect(t.colors.palette.mask.isSet(5)); + try testing.expect(t.flags.dirty.palette); +} + +test "kitty color protocol reset palette" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set and then reset palette color + const original = t.colors.palette.original[7]; + try s.nextSlice("\x1b]21;7=rgb:aa/bb/cc\x1b\\"); + try testing.expect(t.colors.palette.mask.isSet(7)); + + try s.nextSlice("\x1b]21;7=\x1b\\"); + try testing.expectEqual(original, t.colors.palette.current[7]); + try testing.expect(!t.colors.palette.mask.isSet(7)); +} + +test "kitty color protocol set foreground" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set foreground using kitty protocol + try s.nextSlice("\x1b]21;foreground=rgb:12/34/56\x1b\\"); + const fg = t.colors.foreground.get().?; + try testing.expectEqual(@as(u8, 0x12), fg.r); + try testing.expectEqual(@as(u8, 0x34), fg.g); + try testing.expectEqual(@as(u8, 0x56), fg.b); +} + +test "kitty color protocol set background" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set background using kitty protocol + try s.nextSlice("\x1b]21;background=rgb:78/9a/bc\x1b\\"); + const bg = t.colors.background.get().?; + try testing.expectEqual(@as(u8, 0x78), bg.r); + try testing.expectEqual(@as(u8, 0x9a), bg.g); + try testing.expectEqual(@as(u8, 0xbc), bg.b); +} + +test "kitty color protocol set cursor" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set cursor using kitty protocol + try s.nextSlice("\x1b]21;cursor=rgb:de/f0/12\x1b\\"); + const cursor = t.colors.cursor.get().?; + try testing.expectEqual(@as(u8, 0xde), cursor.r); + try testing.expectEqual(@as(u8, 0xf0), cursor.g); + try testing.expectEqual(@as(u8, 0x12), cursor.b); +} + +test "kitty color protocol reset foreground" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set and reset foreground + try s.nextSlice("\x1b]21;foreground=rgb:11/22/33\x1b\\"); + try testing.expect(t.colors.foreground.get() != null); + + try s.nextSlice("\x1b]21;foreground=\x1b\\"); + // After reset, should be unset + try testing.expect(t.colors.foreground.get() == null); +} + +test "palette dirty flag set on color change" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Clear dirty flag + t.flags.dirty.palette = false; + + // Setting palette color should set dirty flag + try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + try testing.expect(t.flags.dirty.palette); + + // Clear and test reset + t.flags.dirty.palette = false; + try s.nextSlice("\x1b]104;0\x1b\\"); + try testing.expect(t.flags.dirty.palette); + + // Clear and test kitty protocol + t.flags.dirty.palette = false; + try s.nextSlice("\x1b]21;1=rgb:00/ff/00\x1b\\"); + try testing.expect(t.flags.dirty.palette); +} diff --git a/src/terminal/style.zig b/src/terminal/style.zig index eac577a53..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 @@ -293,6 +290,169 @@ pub const Style = struct { _ = try writer.write(" }"); } + /// Returns a formatter that renders this style as VT sequences, + /// to be used with `{f}`. This always resets the style first `\x1b[0m` + /// since a style is meant to be fully self-contained. + /// + /// For individual styles, this always emits multiple SGR sequences + /// (i.e. an individual `\x1b[m` for each attribute) rather than + /// trying to combine them into a single sequence. We do this because + /// terminals have varying levels of support for combined sequences + /// especially with mixed separators (e.g. `:` vs `;`). + pub fn formatterVt(self: *const Style) VTFormatter { + return .{ .style = self }; + } + + /// Returns a formatter that renders this style as inline CSS properties, + /// to be used with `{f}`. The output is a valid CSS style string suitable + /// for use in a `style` attribute (e.g., "color: rgb(255, 0, 0); font-weight: bold;"). + /// + /// Palette colors are emitted as CSS variables like `var(--vt-palette-N)`. + pub fn formatterHtml(self: *const Style) HtmlFormatter { + return .{ .style = self }; + } + + const VTFormatter = struct { + style: *const Style, + + /// If set, palette colors will be emitted as RGB values instead of + /// palette indices. This is useful when you want to capture the + /// exact colors at formatting time rather than relying on the + /// terminal's palette. + palette: ?*const color.Palette = null, + + pub fn format( + self: VTFormatter, + writer: *std.Io.Writer, + ) !void { + // Always reset the style. Styles are fully self-contained. + // Even if this style is empty, then that means we want to go + // back to the default. + try writer.writeAll("\x1b[0m"); + + // Our flags + if (self.style.flags.bold) try writer.writeAll("\x1b[1m"); + if (self.style.flags.faint) try writer.writeAll("\x1b[2m"); + if (self.style.flags.italic) try writer.writeAll("\x1b[3m"); + if (self.style.flags.blink) try writer.writeAll("\x1b[5m"); + if (self.style.flags.inverse) try writer.writeAll("\x1b[7m"); + if (self.style.flags.invisible) try writer.writeAll("\x1b[8m"); + if (self.style.flags.strikethrough) try writer.writeAll("\x1b[9m"); + if (self.style.flags.overline) try writer.writeAll("\x1b[53m"); + switch (self.style.flags.underline) { + .none => {}, + .single => try writer.writeAll("\x1b[4m"), + .double => try writer.writeAll("\x1b[4:2m"), + .curly => try writer.writeAll("\x1b[4:3m"), + .dotted => try writer.writeAll("\x1b[4:4m"), + .dashed => try writer.writeAll("\x1b[4:5m"), + } + + // Various RGB colors. + try self.formatColor(writer, 38, self.style.fg_color); + try self.formatColor(writer, 48, self.style.bg_color); + try self.formatColor(writer, 58, self.style.underline_color); + } + + fn formatColor( + self: VTFormatter, + writer: *std.Io.Writer, + prefix: u8, + value: Color, + ) !void { + switch (value) { + .none => {}, + .palette => |idx| { + if (self.palette) |p| { + const rgb = p[idx]; + try writer.print( + "\x1b[{d};2;{d};{d};{d}m", + .{ prefix, rgb.r, rgb.g, rgb.b }, + ); + } else { + try writer.print( + "\x1b[{d};5;{d}m", + .{ prefix, idx }, + ); + } + }, + .rgb => |rgb| try writer.print( + "\x1b[{d};2;{d};{d};{d}m", + .{ prefix, rgb.r, rgb.g, rgb.b }, + ), + } + } + }; + + const HtmlFormatter = struct { + style: *const Style, + + /// If set, palette colors will be emitted as RGB values instead of + /// CSS variables. This is useful when you want to capture the exact + /// colors at formatting time rather than relying on CSS variables. + palette: ?*const color.Palette = null, + + pub fn format( + self: HtmlFormatter, + writer: *std.Io.Writer, + ) !void { + // Colors + try self.formatColor(writer, "color", self.style.fg_color); + try self.formatColor(writer, "background-color", self.style.bg_color); + try self.formatColor(writer, "text-decoration-color", self.style.underline_color); + + // Text decoration line + const has_line = self.style.flags.underline != .none or + self.style.flags.strikethrough or + self.style.flags.overline or + self.style.flags.blink; + if (has_line) { + try writer.writeAll("text-decoration-line:"); + if (self.style.flags.underline != .none) try writer.writeAll(" underline"); + if (self.style.flags.strikethrough) try writer.writeAll(" line-through"); + if (self.style.flags.overline) try writer.writeAll(" overline"); + if (self.style.flags.blink) try writer.writeAll(" blink"); + try writer.writeAll(";"); + } + + // Text decoration style + switch (self.style.flags.underline) { + .none => {}, + .single => try writer.writeAll("text-decoration-style: solid;"), + .double => try writer.writeAll("text-decoration-style: double;"), + .curly => try writer.writeAll("text-decoration-style: wavy;"), + .dotted => try writer.writeAll("text-decoration-style: dotted;"), + .dashed => try writer.writeAll("text-decoration-style: dashed;"), + } + + if (self.style.flags.bold) try writer.writeAll("font-weight: bold;"); + if (self.style.flags.italic) try writer.writeAll("font-style: italic;"); + if (self.style.flags.faint) try writer.writeAll("opacity: 0.5;"); + if (self.style.flags.invisible) try writer.writeAll("visibility: hidden;"); + if (self.style.flags.inverse) try writer.writeAll("filter: invert(100%);"); + } + + fn formatColor( + self: HtmlFormatter, + writer: *std.Io.Writer, + property: []const u8, + c: Color, + ) !void { + switch (c) { + .none => {}, + .palette => |idx| { + if (self.palette) |p| { + const rgb = p[idx]; + try writer.print("{s}: rgb({d}, {d}, {d});", .{ property, rgb.r, rgb.g, rgb.b }); + } else { + try writer.print("{s}: var(--vt-palette-{d});", .{ property, idx }); + } + }, + .rgb => |rgb| try writer.print("{s}: rgb({d}, {d}, {d});", .{ property, rgb.r, rgb.g, rgb.b }), + } + } + }; + /// `PackedStyle` represents the same data as `Style` but without padding, /// which is necessary for hashing via re-interpretation of the underlying /// bytes. @@ -346,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), @@ -364,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 { @@ -394,6 +557,349 @@ pub const Set = RefCountedSet( }, ); +test "Style VT formatting empty" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{}; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m", builder.writer.buffered()); +} + +test "Style VT formatting bold" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .bold = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[1m", builder.writer.buffered()); +} + +test "Style VT formatting faint" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .faint = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[2m", builder.writer.buffered()); +} + +test "Style VT formatting italic" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .italic = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[3m", builder.writer.buffered()); +} + +test "Style VT formatting blink" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .blink = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[5m", builder.writer.buffered()); +} + +test "Style VT formatting inverse" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .inverse = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[7m", builder.writer.buffered()); +} + +test "Style VT formatting invisible" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .invisible = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[8m", builder.writer.buffered()); +} + +test "Style VT formatting strikethrough" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .strikethrough = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[9m", builder.writer.buffered()); +} + +test "Style VT formatting overline" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .overline = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[53m", builder.writer.buffered()); +} + +test "Style VT formatting underline single" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .single } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4m", builder.writer.buffered()); +} + +test "Style VT formatting underline double" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .double } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4:2m", builder.writer.buffered()); +} + +test "Style VT formatting underline curly" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .curly } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4:3m", builder.writer.buffered()); +} + +test "Style VT formatting underline dotted" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .dotted } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4:4m", builder.writer.buffered()); +} + +test "Style VT formatting underline dashed" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .dashed } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4:5m", builder.writer.buffered()); +} + +test "Style VT formatting fg palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .fg_color = .{ .palette = 42 } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;42m", builder.writer.buffered()); +} + +test "Style VT formatting fg rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .fg_color = .{ .rgb = .{ .r = 255, .g = 128, .b = 64 } } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[38;2;255;128;64m", builder.writer.buffered()); +} + +test "Style VT formatting bg palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .bg_color = .{ .palette = 7 } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[48;5;7m", builder.writer.buffered()); +} + +test "Style VT formatting bg rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .bg_color = .{ .rgb = .{ .r = 32, .g = 64, .b = 96 } } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[48;2;32;64;96m", builder.writer.buffered()); +} + +test "Style VT formatting underline_color palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .underline_color = .{ .palette = 15 } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[58;5;15m", builder.writer.buffered()); +} + +test "Style VT formatting underline_color rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .underline_color = .{ .rgb = .{ .r = 200, .g = 100, .b = 50 } } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[58;2;200;100;50m", builder.writer.buffered()); +} + +test "Style VT formatting multiple flags" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .bold = true, .italic = true, .underline = .single } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[1m\x1b[3m\x1b[4m", builder.writer.buffered()); +} + +test "Style VT formatting all flags" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ + .bold = true, + .faint = true, + .italic = true, + .blink = true, + .inverse = true, + .invisible = true, + .strikethrough = true, + .overline = true, + .underline = .curly, + } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[1m\x1b[2m\x1b[3m\x1b[5m\x1b[7m\x1b[8m\x1b[9m\x1b[53m\x1b[4:3m", + builder.writer.buffered(), + ); +} + +test "Style VT formatting combined colors and flags" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .rgb = .{ .r = 255, .g = 0, .b = 0 } }, + .bg_color = .{ .palette = 8 }, + .underline_color = .{ .rgb = .{ .r = 0, .g = 255, .b = 0 } }, + .flags = .{ .bold = true, .italic = true, .underline = .double }, + }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[1m\x1b[3m\x1b[4:2m\x1b[38;2;255;0;0m\x1b[48;5;8m\x1b[58;2;0;255;0m", + builder.writer.buffered(), + ); +} + +test "Style VT formatting all colors rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .rgb = .{ .r = 10, .g = 20, .b = 30 } }, + .bg_color = .{ .rgb = .{ .r = 40, .g = 50, .b = 60 } }, + .underline_color = .{ .rgb = .{ .r = 70, .g = 80, .b = 90 } }, + }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[38;2;10;20;30m\x1b[48;2;40;50;60m\x1b[58;2;70;80;90m", + builder.writer.buffered(), + ); +} + +test "Style VT formatting all colors palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .palette = 1 }, + .bg_color = .{ .palette = 2 }, + .underline_color = .{ .palette = 3 }, + }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[38;5;1m\x1b[48;5;2m\x1b[58;5;3m", + builder.writer.buffered(), + ); +} + +test "Style VT formatting palette with palette set emits rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .fg_color = .{ .palette = 1 } }; + var formatter = style.formatterVt(); + formatter.palette = &color.default; + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings("\x1b[0m\x1b[38;2;204;102;102m", builder.writer.buffered()); +} + +test "Style VT formatting all palette colors with palette set" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .palette = 1 }, + .bg_color = .{ .palette = 2 }, + .underline_color = .{ .palette = 3 }, + }; + var formatter = style.formatterVt(); + formatter.palette = &color.default; + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[38;2;204;102;102m\x1b[48;2;181;189;104m\x1b[58;2;240;198;116m", + builder.writer.buffered(), + ); +} + test "Set basic usage" { const testing = std.testing; const alloc = testing.allocator; @@ -453,3 +959,114 @@ test "Set capacities" { // We want to support at least this many styles without overflowing. _ = Set.Layout.init(16384); } + +test "Style HTML formatting basic bold" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .bold = true } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + try testing.expectEqualStrings("font-weight: bold;", builder.writer.buffered()); +} + +test "Style HTML formatting fg color rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .fg_color = .{ .rgb = .{ .r = 255, .g = 128, .b = 64 } } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + try testing.expectEqualStrings("color: rgb(255, 128, 64);", builder.writer.buffered()); +} + +test "Style HTML formatting bg color palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .bg_color = .{ .palette = 7 } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + try testing.expectEqualStrings("background-color: var(--vt-palette-7);", builder.writer.buffered()); +} + +test "Style HTML formatting combined colors and flags" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .rgb = .{ .r = 255, .g = 0, .b = 0 } }, + .bg_color = .{ .rgb = .{ .r = 0, .g = 0, .b = 255 } }, + .flags = .{ .bold = true, .italic = true }, + }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + const result = builder.writer.buffered(); + try testing.expect(std.mem.indexOf(u8, result, "color: rgb(255, 0, 0);") != null); + try testing.expect(std.mem.indexOf(u8, result, "background-color: rgb(0, 0, 255);") != null); + try testing.expect(std.mem.indexOf(u8, result, "font-weight: bold;") != null); + try testing.expect(std.mem.indexOf(u8, result, "font-style: italic;") != null); +} + +test "Style HTML formatting single decoration line" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .single } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + const result = builder.writer.buffered(); + try testing.expect(std.mem.indexOf(u8, result, "text-decoration-line: underline;") != null); + try testing.expect(std.mem.indexOf(u8, result, "text-decoration-style: solid;") != null); +} + +test "Style HTML formatting multiple decoration lines" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .curly, .strikethrough = true, .overline = true } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + const result = builder.writer.buffered(); + try testing.expect(std.mem.indexOf(u8, result, "text-decoration-line: underline line-through overline;") != null); + try testing.expect(std.mem.indexOf(u8, result, "text-decoration-style: wavy;") != null); +} + +test "Style HTML formatting palette with palette set emits rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .bg_color = .{ .palette = 7 } }; + var formatter = style.formatterHtml(); + formatter.palette = &color.default; + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings("background-color: rgb(197, 200, 198);", builder.writer.buffered()); +} + +test "Style HTML formatting all palette colors with palette set" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .palette = 1 }, + .bg_color = .{ .palette = 2 }, + .underline_color = .{ .palette = 3 }, + }; + var formatter = style.formatterHtml(); + formatter.palette = &color.default; + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings( + "color: rgb(204, 102, 102);background-color: rgb(181, 189, 104);text-decoration-color: rgb(240, 198, 116);", + builder.writer.buffered(), + ); +} diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 67c5a979c..c7cda1442 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -1,424 +1,13 @@ -//! This file contains the implementation for tmux control mode. See -//! tmux(1) for more information on control mode. Some basics are documented -//! here but this is not meant to be a comprehensive source of protocol -//! documentation. +//! Types and functions related to tmux protocols. -const std = @import("std"); -const assert = std.debug.assert; -const oni = @import("oniguruma"); +const control = @import("tmux/control.zig"); +const layout = @import("tmux/layout.zig"); +pub const output = @import("tmux/output.zig"); +pub const ControlParser = control.Parser; +pub const ControlNotification = control.Notification; +pub const Layout = layout.Layout; +pub const Viewer = @import("tmux/viewer.zig").Viewer; -const log = std.log.scoped(.terminal_tmux); - -/// A tmux control mode client. It is expected that the caller establishes -/// the connection in some way (i.e. detects the opening DCS sequence). This -/// just works on a byte stream. -pub const Client = struct { - /// Current state of the client. - state: State = .idle, - - /// The buffer used to store in-progress notifications, output, etc. - buffer: std.Io.Writer.Allocating, - - /// The maximum size in bytes of the buffer. This is used to limit - /// memory usage. If the buffer exceeds this size, the client will - /// enter a broken state (the control mode session will be forcibly - /// exited and future data dropped). - max_bytes: usize = 1024 * 1024, - - const State = enum { - /// Outside of any active notifications. This should drop any output - /// unless it is '%' on the first byte of a line. The buffer will be - /// cleared when it sees '%', this is so that the previous notification - /// data is valid until we receive/process new data. - idle, - - /// We experienced unexpected input and are in a broken state - /// so we cannot continue processing. - broken, - - /// Inside an active notification (started with '%'). - notification, - - /// Inside a begin/end block. - block, - }; - - pub fn deinit(self: *Client) void { - self.buffer.deinit(); - } - - // Handle a byte of input. - pub fn put(self: *Client, byte: u8) !?Notification { - if (self.buffer.written().len >= self.max_bytes) { - self.broken(); - return error.OutOfMemory; - } - - switch (self.state) { - // Drop because we're in a broken state. - .broken => return null, - - // Waiting for a notification so if the byte is not '%' then - // we're in a broken state. Control mode output should always - // be wrapped in '%begin/%end' orelse we expect a notification. - // Return an exit notification. - .idle => if (byte != '%') { - self.broken(); - return .{ .exit = {} }; - } else { - self.buffer.clearRetainingCapacity(); - self.state = .notification; - }, - - // If we're in a notification and its not a newline then - // we accumulate. If it is a newline then we have a - // complete notification we need to parse. - .notification => if (byte == '\n') { - // We have a complete notification, parse it. - return try self.parseNotification(); - }, - - // If we're in a block then we accumulate until we see a newline - // and then we check to see if that line ended the block. - .block => if (byte == '\n') { - const written = self.buffer.written(); - const idx = if (std.mem.lastIndexOfScalar( - u8, - written, - '\n', - )) |v| v + 1 else 0; - const line = written[idx..]; - - if (std.mem.startsWith(u8, line, "%end") or - std.mem.startsWith(u8, line, "%error")) - { - const err = std.mem.startsWith(u8, line, "%error"); - const output = std.mem.trimRight(u8, written[0..idx], "\r\n"); - - // If it is an error then log it. - if (err) log.warn("tmux control mode error={s}", .{output}); - - // Important: do not clear buffer since the notification - // contains it. - self.state = .idle; - return if (err) .{ .block_err = output } else .{ .block_end = output }; - } - - // Didn't end the block, continue accumulating. - }, - } - - try self.buffer.writer.writeByte(byte); - - return null; - } - - fn parseNotification(self: *Client) !?Notification { - assert(self.state == .notification); - - const line = line: { - var line = self.buffer.written(); - if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; - break :line line; - }; - const cmd = cmd: { - const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; - break :cmd line[0..idx]; - }; - - // The notification MUST exist because we guard entering the notification - // state on seeing at least a '%'. - if (std.mem.eql(u8, cmd, "%begin")) { - // We don't use the rest of the tokens for now because tmux - // claims to guarantee that begin/end are always in order and - // never intermixed. In the future, we should probably validate - // this. - // TODO(tmuxcc): do this before merge? - - // Move to block state because we expect a corresponding end/error - // and want to accumulate the data. - self.state = .block; - self.buffer.clearRetainingCapacity(); - return null; - } else if (std.mem.eql(u8, cmd, "%output")) cmd: { - var re = try oni.Regex.init( - "^%output %([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const data = line[@intCast(starts[2])..@intCast(ends[2])]; - - // Important: do not clear buffer here since name points to it - self.state = .idle; - return .{ .output = .{ .pane_id = id, .data = data } }; - } else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: { - var re = try oni.Regex.init( - "^%session-changed \\$([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const name = line[@intCast(starts[2])..@intCast(ends[2])]; - - // Important: do not clear buffer here since name points to it - self.state = .idle; - return .{ .session_changed = .{ .id = id, .name = name } }; - } else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: { - if (!std.mem.eql(u8, line, "%sessions-changed")) { - log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line }); - break :cmd; - } - - self.buffer.clearRetainingCapacity(); - self.state = .idle; - return .{ .sessions_changed = {} }; - } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { - var re = try oni.Regex.init( - "^%window-add @([0-9]+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - - self.buffer.clearRetainingCapacity(); - self.state = .idle; - return .{ .window_add = .{ .id = id } }; - } else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: { - var re = try oni.Regex.init( - "^%window-renamed @([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const name = line[@intCast(starts[2])..@intCast(ends[2])]; - - // Important: do not clear buffer here since name points to it - self.state = .idle; - return .{ .window_renamed = .{ .id = id, .name = name } }; - } else { - // Unknown notification, log it and return to idle state. - log.warn("unknown tmux control mode notification={s}", .{cmd}); - } - - // Unknown command. Clear the buffer and return to idle state. - self.buffer.clearRetainingCapacity(); - self.state = .idle; - - return null; - } - - // Mark the tmux state as broken. - fn broken(self: *Client) void { - self.state = .broken; - self.buffer.deinit(); - } -}; - -/// Possible notification types from tmux control mode. These are documented -/// in tmux(1). -pub const Notification = union(enum) { - enter: void, - exit: void, - - block_end: []const u8, - block_err: []const u8, - - output: struct { - pane_id: usize, - data: []const u8, // unescaped - }, - - session_changed: struct { - id: usize, - name: []const u8, - }, - - sessions_changed: void, - - window_add: struct { - id: usize, - }, - - window_renamed: struct { - id: usize, - name: []const u8, - }, -}; - -test "tmux begin/end empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_end); - try testing.expectEqualStrings("", n.block_end); -} - -test "tmux begin/error empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_err); - try testing.expectEqualStrings("", n.block_err); -} - -test "tmux begin/end data" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_end); - try testing.expectEqualStrings("hello\nworld", n.block_end); -} - -test "tmux output" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .output); - try testing.expectEqual(42, n.output.pane_id); - try testing.expectEqualStrings("foo bar baz", n.output.data); -} - -test "tmux session-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .session_changed); - try testing.expectEqual(42, n.session_changed.id); - try testing.expectEqualStrings("foo", n.session_changed.name); -} - -test "tmux sessions-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .sessions_changed); -} - -test "tmux sessions-changed carriage return" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .sessions_changed); -} - -test "tmux window-add" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .window_add); - try testing.expectEqual(14, n.window_add.id); -} - -test "tmux window-renamed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .window_renamed); - try testing.expectEqual(42, n.window_renamed.id); - try testing.expectEqualStrings("bar", n.window_renamed.name); +test { + @import("std").testing.refAllDecls(@This()); } diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig new file mode 100644 index 000000000..dbc64b340 --- /dev/null +++ b/src/terminal/tmux/control.zig @@ -0,0 +1,725 @@ +//! This file contains the implementation for tmux control mode. See +//! tmux(1) for more information on control mode. Some basics are documented +//! here but this is not meant to be a comprehensive source of protocol +//! documentation. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = @import("../../quirks.zig").inlineAssert; +const oni = @import("oniguruma"); + +const log = std.log.scoped(.terminal_tmux); + +/// A tmux control mode parser. This takes in output from tmux control +/// mode and parses it into a structured notifications. +/// +/// It is up to the caller to establish the connection to the tmux +/// control mode session in some way (e.g. via exec, a network socket, +/// whatever). This is fully agnostic to how the data is received and sent. +pub const Parser = struct { + /// Current state of the client. + state: State = .idle, + + /// The buffer used to store in-progress notifications, output, etc. + buffer: std.Io.Writer.Allocating, + + /// The maximum size in bytes of the buffer. This is used to limit + /// memory usage. If the buffer exceeds this size, the client will + /// enter a broken state (the control mode session will be forcibly + /// exited and future data dropped). + max_bytes: usize = 1024 * 1024, + + const State = enum { + /// Outside of any active notifications. This should drop any output + /// unless it is '%' on the first byte of a line. The buffer will be + /// cleared when it sees '%', this is so that the previous notification + /// data is valid until we receive/process new data. + idle, + + /// We experienced unexpected input and are in a broken state + /// 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 '%'). + notification, + + /// Inside a begin/end block. + block, + }; + + pub fn deinit(self: *Parser) 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. + // + // If we reach our byte limit this will return OutOfMemory. It only + // does this on the first time we exceed the limit; subsequent calls + // will return null as we drop all input in a broken state. + pub fn put(self: *Parser, byte: u8) Allocator.Error!?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; + } + + switch (self.state) { + // Drop because we're in a broken state. + .broken => return null, + + // Waiting for a notification so if the byte is not '%' then + // we're in a broken state. Control mode output should always + // be wrapped in '%begin/%end' orelse we expect a notification. + // Return an exit notification. + .idle => if (byte != '%') { + self.broken(); + return .{ .exit = {} }; + } else { + self.buffer.clearRetainingCapacity(); + self.state = .notification; + }, + + // If we're in a notification and its not a newline then + // we accumulate. If it is a newline then we have a + // complete notification we need to parse. + .notification => if (byte == '\n') { + // We have a complete notification, parse it. + return self.parseNotification() catch { + // If parsing failed, then we do not mark the state + // as broken because we may be able to continue parsing + // other types of notifications. + // + // In the future we may want to emit a notification + // here about unknown or unsupported notifications. + return null; + }; + }, + + // If we're in a block then we accumulate until we see a newline + // and then we check to see if that line ended the block. + .block => if (byte == '\n') { + const written = self.buffer.written(); + const idx = if (std.mem.lastIndexOfScalar( + u8, + written, + '\n', + )) |v| v + 1 else 0; + const line = written[idx..]; + + if (std.mem.startsWith(u8, line, "%end") or + std.mem.startsWith(u8, line, "%error")) + { + const err = std.mem.startsWith(u8, line, "%error"); + const output = std.mem.trimRight(u8, written[0..idx], "\r\n"); + + // If it is an error then log it. + if (err) log.warn("tmux control mode error={s}", .{output}); + + // Important: do not clear buffer since the notification + // contains it. + self.state = .idle; + return if (err) .{ .block_err = output } else .{ .block_end = output }; + } + + // Didn't end the block, continue accumulating. + }, + } + + self.buffer.writer.writeByte(byte) catch |err| switch (err) { + error.WriteFailed => return error.OutOfMemory, + }; + + return null; + } + + const ParseError = error{RegexError}; + + fn parseNotification(self: *Parser) ParseError!?Notification { + assert(self.state == .notification); + + const line = line: { + var line = self.buffer.written(); + if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; + break :line line; + }; + const cmd = cmd: { + const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; + break :cmd line[0..idx]; + }; + + // The notification MUST exist because we guard entering the notification + // state on seeing at least a '%'. + if (std.mem.eql(u8, cmd, "%begin")) { + // We don't use the rest of the tokens for now because tmux + // claims to guarantee that begin/end are always in order and + // never intermixed. In the future, we should probably validate + // this. + // TODO(tmuxcc): do this before merge? + + // Move to block state because we expect a corresponding end/error + // and want to accumulate the data. + self.state = .block; + self.buffer.clearRetainingCapacity(); + return null; + } else if (std.mem.eql(u8, cmd, "%output")) cmd: { + var re = oni.Regex.init( + "^%output %([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const data = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .output = .{ .pane_id = id, .data = data } }; + } else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: { + var re = oni.Regex.init( + "^%session-changed \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .session_changed = .{ .id = id, .name = name } }; + } else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: { + if (!std.mem.eql(u8, line, "%sessions-changed")) { + log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line }); + break :cmd; + } + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .sessions_changed = {} }; + } else if (std.mem.eql(u8, cmd, "%layout-change")) cmd: { + var re = oni.Regex.init( + "^%layout-change @([0-9]+) (.+) (.+) (.*)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const layout = line[@intCast(starts[2])..@intCast(ends[2])]; + const visible_layout = line[@intCast(starts[3])..@intCast(ends[3])]; + const raw_flags = line[@intCast(starts[4])..@intCast(ends[4])]; + + // Important: do not clear buffer here since layout strings point to it + self.state = .idle; + return .{ .layout_change = .{ + .window_id = id, + .layout = layout, + .visible_layout = visible_layout, + .raw_flags = raw_flags, + } }; + } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { + var re = oni.Regex.init( + "^%window-add @([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_add = .{ .id = id } }; + } else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: { + var re = oni.Regex.init( + "^%window-renamed @([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .window_renamed = .{ .id = id, .name = name } }; + } else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: { + var re = oni.Regex.init( + "^%window-pane-changed @([0-9]+) %([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const window_id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const pane_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } }; + } else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: { + var re = oni.Regex.init( + "^%client-detached (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + + // Important: do not clear buffer here since client points to it + self.state = .idle; + return .{ .client_detached = .{ .client = client } }; + } else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: { + var re = oni.Regex.init( + "^%client-session-changed (.+) \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + const session_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[3])..@intCast(ends[3])]; + + // Important: do not clear buffer here since client/name point to it + self.state = .idle; + return .{ .client_session_changed = .{ .client = client, .session_id = session_id, .name = name } }; + } else { + // Unknown notification, log it and return to idle state. + log.warn("unknown tmux control mode notification={s}", .{cmd}); + } + + // Unknown command. Clear the buffer and return to idle state. + self.buffer.clearRetainingCapacity(); + self.state = .idle; + + return null; + } + + // Mark the tmux state as broken. + fn broken(self: *Parser) void { + self.state = .broken; + self.buffer.deinit(); + } +}; + +/// Possible notification types from tmux control mode. These are documented +/// in tmux(1). A lot of the simple documentation was copied from that man +/// page here. +pub const Notification = union(enum) { + /// Entering tmux control mode. This isn't an actual event sent by + /// tmux but is one sent by us to indicate that we have detected that + /// tmux control mode is starting. + enter, + + /// Exit. + /// + /// NOTE: The tmux protocol contains a "reason" string (human friendly) + /// associated with this. We currently drop it because we don't need it + /// but this may be something we want to add later. If we do add it, + /// we have to consider buffer limits and how we handle those (dropping + /// vs truncating, etc.). + exit, + + /// Dispatched at the end of a begin/end block with the raw data. + /// The control mode parser can't parse the data because it is unaware + /// of the command that was sent to trigger this output. + block_end: []const u8, + block_err: []const u8, + + /// Raw output from a pane. + output: struct { + pane_id: usize, + data: []const u8, // unescaped + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + session_changed: struct { + id: usize, + name: []const u8, + }, + + /// A session was created or destroyed. + sessions_changed, + + /// The layout of the window with ID window-id changed. + layout_change: struct { + window_id: usize, + layout: []const u8, + visible_layout: []const u8, + raw_flags: []const u8, + }, + + /// The window with ID window-id was linked to the current session. + window_add: struct { + id: usize, + }, + + /// The window with ID window-id was renamed to name. + window_renamed: struct { + id: usize, + name: []const u8, + }, + + /// The active pane in the window with ID window-id changed to the pane + /// with ID pane-id. + window_pane_changed: struct { + window_id: usize, + pane_id: usize, + }, + + /// The client has detached. + client_detached: struct { + client: []const u8, + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + client_session_changed: struct { + client: []const u8, + session_id: usize, + name: []const u8, + }, + + pub fn format(self: Notification, writer: *std.Io.Writer) !void { + const T = Notification; + const info = @typeInfo(T).@"union"; + + try writer.writeAll(@typeName(T)); + if (info.tag_type) |TagType| { + try writer.writeAll("{ ."); + try writer.writeAll(@tagName(@as(TagType, self))); + try writer.writeAll(" = "); + + inline for (info.fields) |u_field| { + if (self == @field(TagType, u_field.name)) { + const value = @field(self, u_field.name); + switch (u_field.type) { + []const u8 => try writer.print("\"{s}\"", .{std.mem.trim(u8, value, " \t\r\n")}), + else => try writer.print("{any}", .{value}), + } + } + } + + try writer.writeAll(" }"); + } + } +}; + +test "tmux begin/end empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("", n.block_end); +} + +test "tmux begin/error empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_err); + try testing.expectEqualStrings("", n.block_err); +} + +test "tmux begin/end data" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("hello\nworld", n.block_end); +} + +test "tmux output" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .output); + try testing.expectEqual(42, n.output.pane_id); + try testing.expectEqualStrings("foo bar baz", n.output.data); +} + +test "tmux session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .session_changed); + try testing.expectEqual(42, n.session_changed.id); + try testing.expectEqualStrings("foo", n.session_changed.name); +} + +test "tmux sessions-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .sessions_changed); +} + +test "tmux sessions-changed carriage return" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .sessions_changed); +} + +test "tmux layout-change" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%layout-change @2 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} *-") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .layout_change); + try testing.expectEqual(2, n.layout_change.window_id); + try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.layout); + try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.visible_layout); + try testing.expectEqualStrings("*-", n.layout_change.raw_flags); +} + +test "tmux window-add" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_add); + try testing.expectEqual(14, n.window_add.id); +} + +test "tmux window-renamed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_renamed); + try testing.expectEqual(42, n.window_renamed.id); + try testing.expectEqualStrings("bar", n.window_renamed.name); +} + +test "tmux window-pane-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-pane-changed @42 %2") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_pane_changed); + try testing.expectEqual(42, n.window_pane_changed.window_id); + try testing.expectEqual(2, n.window_pane_changed.pane_id); +} + +test "tmux client-detached" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-detached /dev/pts/1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_detached); + try testing.expectEqualStrings("/dev/pts/1", n.client_detached.client); +} + +test "tmux client-session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-session-changed /dev/pts/1 $2 mysession") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_session_changed); + try testing.expectEqualStrings("/dev/pts/1", n.client_session_changed.client); + try testing.expectEqual(2, n.client_session_changed.session_id); + try testing.expectEqualStrings("mysession", n.client_session_changed.name); +} diff --git a/src/terminal/tmux/layout.zig b/src/terminal/tmux/layout.zig new file mode 100644 index 000000000..df1a53917 --- /dev/null +++ b/src/terminal/tmux/layout.zig @@ -0,0 +1,638 @@ +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +/// A tmux layout. +/// +/// This is a tree structure so by definition it pretty much needs to be +/// allocated. We leave allocation up to the user of this struct, but +/// a general recommendation is to use an arena allocator for simplicity +/// in freeing the entire layout at once. +pub const Layout = struct { + /// Width, height of the node + width: usize, + height: usize, + + /// X and Y offset from the top-left corner of the window. + x: usize, + y: usize, + + /// The content of this node, either a pane (leaf) or more nodes + /// (split) horizontally or vertically. + content: Content, + + pub const Content = union(enum) { + pane: usize, + horizontal: []const Layout, + vertical: []const Layout, + }; + + pub const ParseError = Allocator.Error || error{SyntaxError}; + + /// Parse a layout string that includes a 4-character checksum prefix. + /// + /// The expected format is: `XXXX,layout_string` where XXXX is the + /// 4-character hexadecimal checksum and the layout string follows + /// after the comma. For example: `f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}`. + /// + /// Returns `ChecksumMismatch` if the checksum doesn't match the layout. + /// Returns `SyntaxError` if the format is invalid. + pub fn parseWithChecksum( + alloc: Allocator, + str: []const u8, + ) (ParseError || error{ChecksumMismatch})!Layout { + // If the string is less than 5 characters, it can't possibly + // be correct. 4-char checksum + comma. In practice it should + // be even longer, but that'll fail parse later. + if (str.len < 5) return error.SyntaxError; + if (str[4] != ',') return error.SyntaxError; + + // The layout string should start with a 4-character checksum. + const checksum: Checksum = .calculate(str[5..]); + if (!std.mem.startsWith( + u8, + str, + &checksum.asString(), + )) return error.ChecksumMismatch; + + // Checksum matches, parse the rest. + return try parse(alloc, str[5..]); + } + + /// Parse a layout string into a Layout structure. The given allocator + /// will be used for all allocations within the layout. Note that + /// individual nodes can't be freed so this allocator must be some + /// kind of arena allocator. + /// + /// The layout string must be fully provided as a single string. + /// Layouts are generally small so this should not be a problem. + /// + /// Tmux layout strings have the following format: + /// + /// - WxH,X,Y,ID Leaf pane: width×height, x-offset, y-offset, pane ID + /// - WxH,X,Y{...} Horizontal split (left-right), children comma-separated + /// - WxH,X,Y[...] Vertical split (top-bottom), children comma-separated + pub fn parse(alloc: Allocator, str: []const u8) ParseError!Layout { + var offset: usize = 0; + const root = try parseNext( + alloc, + str, + &offset, + ); + if (offset != str.len) return error.SyntaxError; + return root; + } + + fn parseNext( + alloc: Allocator, + str: []const u8, + offset: *usize, + ) ParseError!Layout { + // Find the first `x` to grab the width. + const width: usize = if (std.mem.indexOfScalar( + u8, + str[offset.*..], + 'x', + )) |idx| width: { + defer offset.* += idx + 1; // Consume `x` + break :width std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Find the height, up to a comma. + const height: usize = if (std.mem.indexOfScalar( + u8, + str[offset.*..], + ',', + )) |idx| height: { + defer offset.* += idx + 1; // Consume `,` + break :height std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Find X + const x: usize = if (std.mem.indexOfScalar( + u8, + str[offset.*..], + ',', + )) |idx| x: { + defer offset.* += idx + 1; // Consume `,` + break :x std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Find Y, which can end in any of `,{,[` + const y: usize = if (std.mem.indexOfAny( + u8, + str[offset.*..], + ",{[", + )) |idx| y: { + defer offset.* += idx; // Don't consume the delimiter! + break :y std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Determine our child node. + const content: Layout.Content = switch (str[offset.*]) { + ',' => content: { + // Consume the delimiter + offset.* += 1; + + // Leaf pane. Read up to `,}]` because we may be in + // a set of nodes. If none exist, end of string is fine. + const idx = std.mem.indexOfAny( + u8, + str[offset.*..], + ",}]", + ) orelse str.len - offset.*; + + defer offset.* += idx; // Consume the pane ID, not the delimiter + const pane_id = std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + + break :content .{ .pane = pane_id }; + }, + + '{', '[' => |opening| content: { + var nodes: std.ArrayList(Layout) = .empty; + defer nodes.deinit(alloc); + + // Move beyond our opening + offset.* += 1; + + while (true) { + try nodes.append(alloc, try parseNext( + alloc, + str, + offset, + )); + + // We should not reach the end of string here because + // we expect a closing bracket. + if (offset.* >= str.len) return error.SyntaxError; + + // If it is a comma, we expect another node. + if (str[offset.*] == ',') { + offset.* += 1; // Consume + continue; + } + + // We expect a closing bracket now. + switch (opening) { + '{' => if (str[offset.*] != '}') return error.SyntaxError, + '[' => if (str[offset.*] != ']') return error.SyntaxError, + else => return error.SyntaxError, + } + + // Successfully parsed all children. + offset.* += 1; // Consume closing bracket + break :content switch (opening) { + '{' => .{ .horizontal = try nodes.toOwnedSlice(alloc) }, + '[' => .{ .vertical = try nodes.toOwnedSlice(alloc) }, + else => unreachable, + }; + } + }, + + // indexOfAny above guarantees we have only the above + else => unreachable, + }; + + return .{ + .width = width, + .height = height, + .x = x, + .y = y, + .content = content, + }; + } +}; + +pub const Checksum = enum(u16) { + _, + + /// Calculate the checksum of a tmux layout string. + /// The algorithm rotates the checksum right by 1 bit (with wraparound) + /// and adds the ASCII value of each character. + pub fn calculate(str: []const u8) Checksum { + var result: u16 = 0; + for (str) |c| { + // Rotate right by 1: (result >> 1) + ((result & 1) << 15) + result = (result >> 1) | ((result & 1) << 15); + result +%= c; + } + + return @enumFromInt(result); + } + + /// Convert the checksum to a 4-character hexadecimal string. This + /// is always zero-padded to match the tmux implementation + /// (in layout-custom.c). + pub fn asString(self: Checksum) [4]u8 { + const value = @intFromEnum(self); + const charset = "0123456789abcdef"; + return .{ + charset[(value >> 12) & 0xf], + charset[(value >> 8) & 0xf], + charset[(value >> 4) & 0xf], + charset[value & 0xf], + }; + } +}; + +test "simple single pane" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0,42"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + try testing.expectEqual(0, layout.x); + try testing.expectEqual(0, layout.y); + try testing.expectEqual(42, layout.content.pane); +} + +test "single pane with offset" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "40x12,10,5,7"); + try testing.expectEqual(40, layout.width); + try testing.expectEqual(12, layout.height); + try testing.expectEqual(10, layout.x); + try testing.expectEqual(5, layout.y); + try testing.expectEqual(7, layout.content.pane); +} + +test "single pane large values" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "1920x1080,100,200,999"); + try testing.expectEqual(1920, layout.width); + try testing.expectEqual(1080, layout.height); + try testing.expectEqual(100, layout.x); + try testing.expectEqual(200, layout.y); + try testing.expectEqual(999, layout.content.pane); +} + +test "horizontal split two panes" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0,2}"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + try testing.expectEqual(0, layout.x); + try testing.expectEqual(0, layout.y); + + const children = layout.content.horizontal; + try testing.expectEqual(2, children.len); + + try testing.expectEqual(40, children[0].width); + try testing.expectEqual(24, children[0].height); + try testing.expectEqual(0, children[0].x); + try testing.expectEqual(0, children[0].y); + try testing.expectEqual(1, children[0].content.pane); + + try testing.expectEqual(40, children[1].width); + try testing.expectEqual(24, children[1].height); + try testing.expectEqual(40, children[1].x); + try testing.expectEqual(0, children[1].y); + try testing.expectEqual(2, children[1].content.pane); +} + +test "vertical split two panes" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12,2]"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + try testing.expectEqual(0, layout.x); + try testing.expectEqual(0, layout.y); + + const children = layout.content.vertical; + try testing.expectEqual(2, children.len); + + try testing.expectEqual(80, children[0].width); + try testing.expectEqual(12, children[0].height); + try testing.expectEqual(0, children[0].x); + try testing.expectEqual(0, children[0].y); + try testing.expectEqual(1, children[0].content.pane); + + try testing.expectEqual(80, children[1].width); + try testing.expectEqual(12, children[1].height); + try testing.expectEqual(0, children[1].x); + try testing.expectEqual(12, children[1].y); + try testing.expectEqual(2, children[1].content.pane); +} + +test "horizontal split three panes" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "120x24,0,0{40x24,0,0,1,40x24,40,0,2,40x24,80,0,3}"); + try testing.expectEqual(120, layout.width); + try testing.expectEqual(24, layout.height); + + const children = layout.content.horizontal; + try testing.expectEqual(3, children.len); + try testing.expectEqual(1, children[0].content.pane); + try testing.expectEqual(2, children[1].content.pane); + try testing.expectEqual(3, children[2].content.pane); +} + +test "nested horizontal in vertical" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + // Vertical split with top pane and bottom horizontal split + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12{40x12,0,12,2,40x12,40,12,3}]"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + + const vert_children = layout.content.vertical; + try testing.expectEqual(2, vert_children.len); + + // First child is a simple pane + try testing.expectEqual(1, vert_children[0].content.pane); + + // Second child is a horizontal split + const horiz_children = vert_children[1].content.horizontal; + try testing.expectEqual(2, horiz_children.len); + try testing.expectEqual(2, horiz_children[0].content.pane); + try testing.expectEqual(3, horiz_children[1].content.pane); +} + +test "nested vertical in horizontal" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + // Horizontal split with left pane and right vertical split + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0[40x12,40,0,2,40x12,40,12,3]}"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + + const horiz_children = layout.content.horizontal; + try testing.expectEqual(2, horiz_children.len); + + // First child is a simple pane + try testing.expectEqual(1, horiz_children[0].content.pane); + + // Second child is a vertical split + const vert_children = horiz_children[1].content.vertical; + try testing.expectEqual(2, vert_children.len); + try testing.expectEqual(2, vert_children[0].content.pane); + try testing.expectEqual(3, vert_children[1].content.pane); +} + +test "deeply nested layout" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + // Three levels deep + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0[40x12,0,0,1,40x12,0,12,2],40x24,40,0,3}"); + + const horiz = layout.content.horizontal; + try testing.expectEqual(2, horiz.len); + + const vert = horiz[0].content.vertical; + try testing.expectEqual(2, vert.len); + try testing.expectEqual(1, vert[0].content.pane); + try testing.expectEqual(2, vert[1].content.pane); + + try testing.expectEqual(3, horiz[1].content.pane); +} + +test "syntax error empty string" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "")); +} + +test "syntax error missing width" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "x24,0,0,1")); +} + +test "syntax error missing height" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x,0,0,1")); +} + +test "syntax error missing x" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,,0,1")); +} + +test "syntax error missing y" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,,1")); +} + +test "syntax error missing pane id" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,")); +} + +test "syntax error non-numeric width" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "abcx24,0,0,1")); +} + +test "syntax error non-numeric pane id" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,abc")); +} + +test "syntax error unclosed horizontal bracket" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1")); +} + +test "syntax error unclosed vertical bracket" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1")); +} + +test "syntax error mismatched brackets" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1]")); + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1}")); +} + +test "syntax error trailing data" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,1extra")); +} + +test "syntax error no x separator" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "8024,0,0,1")); +} + +test "syntax error no content delimiter" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0")); +} + +// parseWithChecksum tests + +test "parseWithChecksum valid" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parseWithChecksum(arena.allocator(), "f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); +} + +test "parseWithChecksum mismatch" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.ChecksumMismatch, Layout.parseWithChecksum(arena.allocator(), "0000,80x24,0,0{40x24,0,0,1,40x24,40,0,2}")); +} + +test "parseWithChecksum too short" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62")); + try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "")); +} + +test "parseWithChecksum missing comma" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62x159x48,0,0")); +} + +// Checksum tests + +test "checksum empty string" { + const checksum = Checksum.calculate(""); + try testing.expectEqual(@as(u16, 0), @intFromEnum(checksum)); + try testing.expectEqualStrings("0000", &checksum.asString()); +} + +test "checksum single character" { + // 'A' = 65, first iteration: csum = 0 >> 1 | 0 = 0, then 0 + 65 = 65 + const checksum = Checksum.calculate("A"); + try testing.expectEqual(@as(u16, 65), @intFromEnum(checksum)); + try testing.expectEqualStrings("0041", &checksum.asString()); +} + +test "checksum two characters" { + // 'A' (65): csum = 0, rotate = 0, add 65 => 65 + // 'B' (66): csum = 65, rotate => (65 >> 1) | ((65 & 1) << 15) = 32 | 32768 = 32800 + // add 66 => 32800 + 66 = 32866 + const checksum = Checksum.calculate("AB"); + try testing.expectEqual(@as(u16, 32866), @intFromEnum(checksum)); + try testing.expectEqualStrings("8062", &checksum.asString()); +} + +test "checksum simple layout" { + const checksum = Checksum.calculate("80x24,0,0,42"); + try testing.expectEqualStrings("d962", &checksum.asString()); +} + +test "checksum horizontal split layout" { + const checksum = Checksum.calculate("80x24,0,0{40x24,0,0,1,40x24,40,0,2}"); + try testing.expectEqualStrings("f8f9", &checksum.asString()); +} + +test "checksum asString zero padding" { + // Value 0x000f should produce "000f" + const checksum: Checksum = @enumFromInt(0x000f); + try testing.expectEqualStrings("000f", &checksum.asString()); +} + +test "checksum asString all digits" { + // Value 0x1234 should produce "1234" + const checksum: Checksum = @enumFromInt(0x1234); + try testing.expectEqualStrings("1234", &checksum.asString()); +} + +test "checksum asString with letters" { + // Value 0xabcd should produce "abcd" + const checksum: Checksum = @enumFromInt(0xabcd); + try testing.expectEqualStrings("abcd", &checksum.asString()); +} + +test "checksum asString max value" { + // Value 0xffff should produce "ffff" + const checksum: Checksum = @enumFromInt(0xffff); + try testing.expectEqualStrings("ffff", &checksum.asString()); +} + +test "checksum wraparound" { + const checksum = Checksum.calculate("\xff\xff\xff\xff\xff\xff\xff\xff"); + try testing.expectEqualStrings("03fc", &checksum.asString()); +} + +test "checksum deterministic" { + // Same input should always produce same output + const str = "159x48,0,0{79x48,0,0,79x48,80,0}"; + const checksum1 = Checksum.calculate(str); + const checksum2 = Checksum.calculate(str); + try testing.expectEqual(checksum1, checksum2); +} + +test "checksum different inputs different outputs" { + const checksum1 = Checksum.calculate("80x24,0,0,1"); + const checksum2 = Checksum.calculate("80x24,0,0,2"); + try testing.expect(@intFromEnum(checksum1) != @intFromEnum(checksum2)); +} + +test "checksum known tmux layout bb62" { + // From tmux documentation: "bb62,159x48,0,0{79x48,0,0,79x48,80,0}" + // The checksum "bb62" corresponds to the layout "159x48,0,0{79x48,0,0,79x48,80,0}" + const checksum = Checksum.calculate("159x48,0,0{79x48,0,0,79x48,80,0}"); + try testing.expectEqualStrings("bb62", &checksum.asString()); +} diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig new file mode 100644 index 000000000..6b8073e44 --- /dev/null +++ b/src/terminal/tmux/output.zig @@ -0,0 +1,590 @@ +const std = @import("std"); +const testing = std.testing; + +pub const ParseError = error{ + MissingEntry, + ExtraEntry, + FormatError, +}; + +/// Parse the output from a command with the given format struct +/// (returned usually by FormatStruct). The format struct is expected +/// to be in the order of the variables used in the format string and +/// the variables are expected to be plain variables (no conditionals, +/// extra formatting, etc.). Each variable is expected to be separated +/// by a single `delimiter` character. +pub fn parseFormatStruct( + comptime T: type, + str: []const u8, + delimiter: u8, +) ParseError!T { + // Parse all our fields + const fields = @typeInfo(T).@"struct".fields; + var it = std.mem.splitScalar(u8, str, delimiter); + var result: T = undefined; + inline for (fields) |field| { + const part = it.next() orelse return error.MissingEntry; + @field(result, field.name) = Variable.parse( + @field(Variable, field.name), + part, + ) catch return error.FormatError; + } + + // We should have consumed all parts now. + if (it.next() != null) return error.ExtraEntry; + + return result; +} + +pub fn comptimeFormat( + comptime vars: []const Variable, + comptime delimiter: u8, +) []const u8 { + comptime { + @setEvalBranchQuota(50000); + var counter: std.Io.Writer.Discarding = .init(&.{}); + try format(&counter.writer, vars, delimiter); + + var buf: [counter.count]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try format(&writer, vars, delimiter); + const final = buf; + return final[0..writer.end]; + } +} + +/// Format a set of variables into the proper format string for tmux +/// that we can handle with `parseFormatStruct`. +pub fn format( + writer: *std.Io.Writer, + vars: []const Variable, + delimiter: u8, +) std.Io.Writer.Error!void { + for (vars, 0..) |variable, i| { + if (i != 0) try writer.writeByte(delimiter); + try writer.print("#{{{t}}}", .{variable}); + } +} + +/// Returns a struct type that contains fields for each of the given +/// format variables. This can be used with `parseFormatStruct` to +/// parse an output string into a format struct. +pub fn FormatStruct(comptime vars: []const Variable) type { + var fields: [vars.len]std.builtin.Type.StructField = undefined; + for (vars, &fields) |variable, *field| { + field.* = .{ + .name = @tagName(variable), + .type = variable.Type(), + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(variable.Type()), + }; + } + + return @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +} + +/// Possible variables in a tmux format string that we support. +/// +/// Tmux supports a large number of variables, but we only implement +/// a subset of them here that are relevant to the use case of implementing +/// control mode for terminal emulators. +pub const Variable = enum { + /// 1 if pane is in alternate screen. + alternate_on, + /// Saved cursor X in alternate screen. + alternate_saved_x, + /// Saved cursor Y in alternate screen. + alternate_saved_y, + /// 1 if bracketed paste mode is enabled. + bracketed_paste, + /// 1 if the cursor is blinking. + cursor_blinking, + /// Cursor colour in pane. Possible formats: + /// - Named colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, + /// `cyan`, `white`, `default`, `terminal`, or bright variants. + /// - 256 colors: `colour` where N is 0-255 (e.g., `colour100`). + /// - RGB hex: `#RRGGBB` (e.g., `#ff0000`). + /// - Empty string if unset. + cursor_colour, + /// Pane cursor flag. + cursor_flag, + /// Cursor shape in pane. Possible values: `block`, `underline`, `bar`, + /// or `default`. + cursor_shape, + /// Cursor X position in pane. + cursor_x, + /// Cursor Y position in pane. + cursor_y, + /// 1 if focus reporting is enabled. + focus_flag, + /// Pane insert flag. + insert_flag, + /// Pane keypad cursor flag. + keypad_cursor_flag, + /// Pane keypad flag. + keypad_flag, + /// Pane mouse all flag. + mouse_all_flag, + /// Pane mouse any flag. + mouse_any_flag, + /// Pane mouse button flag. + mouse_button_flag, + /// Pane mouse SGR flag. + mouse_sgr_flag, + /// Pane mouse standard flag. + mouse_standard_flag, + /// Pane mouse UTF-8 flag. + mouse_utf8_flag, + /// Pane origin flag. + origin_flag, + /// Unique pane ID prefixed with `%` (e.g., `%0`, `%42`). + pane_id, + /// Pane tab positions as a comma-separated list of 0-indexed column + /// numbers (e.g., `8,16,24,32`). Empty string if no tabs are set. + pane_tabs, + /// Bottom of scroll region in pane. + scroll_region_lower, + /// Top of scroll region in pane. + scroll_region_upper, + /// Unique session ID prefixed with `$` (e.g., `$0`, `$42`). + session_id, + /// Server version (e.g., `3.5a`). + version, + /// Unique window ID prefixed with `@` (e.g., `@0`, `@42`). + window_id, + /// Width of window. + window_width, + /// Height of window. + window_height, + /// Window layout description, ignoring zoomed window panes. Format is + /// `,` where checksum is a 4-digit hex CRC16 and layout + /// encodes pane dimensions as `WxH,X,Y[,ID]` with `{...}` for horizontal + /// splits and `[...]` for vertical splits. + window_layout, + /// Pane wrap flag. + wrap_flag, + + /// Parse the given string value into the appropriate resulting + /// type for this variable. + pub fn parse(comptime self: Variable, value: []const u8) !Type(self) { + return switch (self) { + .alternate_on, + .bracketed_paste, + .cursor_blinking, + .cursor_flag, + .focus_flag, + .insert_flag, + .keypad_cursor_flag, + .keypad_flag, + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_sgr_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .origin_flag, + .wrap_flag, + => std.mem.eql(u8, value, "1"), + .alternate_saved_x, + .alternate_saved_y, + .cursor_x, + .cursor_y, + .scroll_region_lower, + .scroll_region_upper, + => try std.fmt.parseInt(usize, value, 10), + .session_id => if (value.len >= 2 and value[0] == '$') + try std.fmt.parseInt(usize, value[1..], 10) + else + return error.FormatError, + .window_id => if (value.len >= 2 and value[0] == '@') + try std.fmt.parseInt(usize, value[1..], 10) + else + return error.FormatError, + .pane_id => if (value.len >= 2 and value[0] == '%') + try std.fmt.parseInt(usize, value[1..], 10) + else + return error.FormatError, + .window_width => try std.fmt.parseInt(usize, value, 10), + .window_height => try std.fmt.parseInt(usize, value, 10), + .cursor_colour, + .cursor_shape, + .pane_tabs, + .version, + .window_layout, + => value, + }; + } + + /// The type of the parsed value for this variable type. + pub fn Type(comptime self: Variable) type { + return switch (self) { + .alternate_on, + .bracketed_paste, + .cursor_blinking, + .cursor_flag, + .focus_flag, + .insert_flag, + .keypad_cursor_flag, + .keypad_flag, + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_sgr_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .origin_flag, + .wrap_flag, + => bool, + .alternate_saved_x, + .alternate_saved_y, + .cursor_x, + .cursor_y, + .scroll_region_lower, + .scroll_region_upper, + .session_id, + .window_id, + .pane_id, + .window_width, + .window_height, + => usize, + .cursor_colour, + .cursor_shape, + .pane_tabs, + .version, + .window_layout, + => []const u8, + }; + } +}; + +test "parse alternate_on" { + try testing.expectEqual(true, try Variable.parse(.alternate_on, "1")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "0")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "true")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "yes")); +} + +test "parse alternate_saved_x" { + try testing.expectEqual(0, try Variable.parse(.alternate_saved_x, "0")); + try testing.expectEqual(42, try Variable.parse(.alternate_saved_x, "42")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.alternate_saved_x, "abc")); +} + +test "parse alternate_saved_y" { + try testing.expectEqual(0, try Variable.parse(.alternate_saved_y, "0")); + try testing.expectEqual(42, try Variable.parse(.alternate_saved_y, "42")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.alternate_saved_y, "abc")); +} + +test "parse cursor_x" { + try testing.expectEqual(0, try Variable.parse(.cursor_x, "0")); + try testing.expectEqual(79, try Variable.parse(.cursor_x, "79")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.cursor_x, "abc")); +} + +test "parse cursor_y" { + try testing.expectEqual(0, try Variable.parse(.cursor_y, "0")); + try testing.expectEqual(23, try Variable.parse(.cursor_y, "23")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.cursor_y, "abc")); +} + +test "parse scroll_region_upper" { + try testing.expectEqual(0, try Variable.parse(.scroll_region_upper, "0")); + try testing.expectEqual(5, try Variable.parse(.scroll_region_upper, "5")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.scroll_region_upper, "abc")); +} + +test "parse scroll_region_lower" { + try testing.expectEqual(0, try Variable.parse(.scroll_region_lower, "0")); + try testing.expectEqual(23, try Variable.parse(.scroll_region_lower, "23")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.scroll_region_lower, "abc")); +} + +test "parse session id" { + try testing.expectEqual(42, try Variable.parse(.session_id, "$42")); + try testing.expectEqual(0, try Variable.parse(.session_id, "$0")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "0")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "@0")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "$")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.session_id, "$abc")); +} + +test "parse window id" { + try testing.expectEqual(42, try Variable.parse(.window_id, "@42")); + try testing.expectEqual(0, try Variable.parse(.window_id, "@0")); + try testing.expectEqual(12345, try Variable.parse(.window_id, "@12345")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "0")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "$0")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "@")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_id, "@abc")); +} + +test "parse window width" { + try testing.expectEqual(80, try Variable.parse(.window_width, "80")); + try testing.expectEqual(0, try Variable.parse(.window_width, "0")); + try testing.expectEqual(12345, try Variable.parse(.window_width, "12345")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "abc")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "80px")); + try testing.expectError(error.Overflow, Variable.parse(.window_width, "-1")); +} + +test "parse window height" { + try testing.expectEqual(24, try Variable.parse(.window_height, "24")); + try testing.expectEqual(0, try Variable.parse(.window_height, "0")); + try testing.expectEqual(12345, try Variable.parse(.window_height, "12345")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "abc")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "24px")); + try testing.expectError(error.Overflow, Variable.parse(.window_height, "-1")); +} + +test "parse window layout" { + try testing.expectEqualStrings("abc123", try Variable.parse(.window_layout, "abc123")); + try testing.expectEqualStrings("", try Variable.parse(.window_layout, "")); + try testing.expectEqualStrings("a]b,c{d}e(f)", try Variable.parse(.window_layout, "a]b,c{d}e(f)")); +} + +test "parse cursor_flag" { + try testing.expectEqual(true, try Variable.parse(.cursor_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "true")); +} + +test "parse insert_flag" { + try testing.expectEqual(true, try Variable.parse(.insert_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "true")); +} + +test "parse keypad_cursor_flag" { + try testing.expectEqual(true, try Variable.parse(.keypad_cursor_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "true")); +} + +test "parse keypad_flag" { + try testing.expectEqual(true, try Variable.parse(.keypad_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "true")); +} + +test "parse mouse_any_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_any_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "true")); +} + +test "parse mouse_button_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_button_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "true")); +} + +test "parse mouse_sgr_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_sgr_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "true")); +} + +test "parse mouse_standard_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_standard_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "true")); +} + +test "parse mouse_utf8_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_utf8_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "true")); +} + +test "parse wrap_flag" { + try testing.expectEqual(true, try Variable.parse(.wrap_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "true")); +} + +test "parse bracketed_paste" { + try testing.expectEqual(true, try Variable.parse(.bracketed_paste, "1")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "0")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "true")); +} + +test "parse cursor_blinking" { + try testing.expectEqual(true, try Variable.parse(.cursor_blinking, "1")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "0")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "true")); +} + +test "parse focus_flag" { + try testing.expectEqual(true, try Variable.parse(.focus_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "true")); +} + +test "parse mouse_all_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_all_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "true")); +} + +test "parse origin_flag" { + try testing.expectEqual(true, try Variable.parse(.origin_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "true")); +} + +test "parse pane_id" { + try testing.expectEqual(42, try Variable.parse(.pane_id, "%42")); + try testing.expectEqual(0, try Variable.parse(.pane_id, "%0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "@0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "%")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.pane_id, "%abc")); +} + +test "parse cursor_colour" { + try testing.expectEqualStrings("red", try Variable.parse(.cursor_colour, "red")); + try testing.expectEqualStrings("#ff0000", try Variable.parse(.cursor_colour, "#ff0000")); + try testing.expectEqualStrings("", try Variable.parse(.cursor_colour, "")); +} + +test "parse cursor_shape" { + try testing.expectEqualStrings("block", try Variable.parse(.cursor_shape, "block")); + try testing.expectEqualStrings("underline", try Variable.parse(.cursor_shape, "underline")); + try testing.expectEqualStrings("bar", try Variable.parse(.cursor_shape, "bar")); + try testing.expectEqualStrings("", try Variable.parse(.cursor_shape, "")); +} + +test "parse pane_tabs" { + try testing.expectEqualStrings("0,8,16,24", try Variable.parse(.pane_tabs, "0,8,16,24")); + try testing.expectEqualStrings("", try Variable.parse(.pane_tabs, "")); + try testing.expectEqualStrings("0", try Variable.parse(.pane_tabs, "0")); +} + +test "parse version" { + try testing.expectEqualStrings("3.5a", try Variable.parse(.version, "3.5a")); + try testing.expectEqualStrings("3.5", try Variable.parse(.version, "3.5")); + try testing.expectEqualStrings("next-3.5", try Variable.parse(.version, "next-3.5")); + try testing.expectEqualStrings("", try Variable.parse(.version, "")); +} + +test "parseFormatStruct single field" { + const T = FormatStruct(&.{.session_id}); + const result = try parseFormatStruct(T, "$42", ' '); + try testing.expectEqual(42, result.session_id); +} + +test "parseFormatStruct multiple fields" { + const T = FormatStruct(&.{ .session_id, .window_id, .window_width, .window_height }); + const result = try parseFormatStruct(T, "$1 @2 80 24", ' '); + try testing.expectEqual(1, result.session_id); + try testing.expectEqual(2, result.window_id); + try testing.expectEqual(80, result.window_width); + try testing.expectEqual(24, result.window_height); +} + +test "parseFormatStruct with string field" { + const T = FormatStruct(&.{ .window_id, .window_layout }); + const result = try parseFormatStruct(T, "@5,abc123", ','); + try testing.expectEqual(5, result.window_id); + try testing.expectEqualStrings("abc123", result.window_layout); +} + +test "parseFormatStruct different delimiter" { + const T = FormatStruct(&.{ .window_width, .window_height }); + const result = try parseFormatStruct(T, "120\t40", '\t'); + try testing.expectEqual(120, result.window_width); + try testing.expectEqual(40, result.window_height); +} + +test "parseFormatStruct missing entry" { + const T = FormatStruct(&.{ .session_id, .window_id }); + try testing.expectError(error.MissingEntry, parseFormatStruct(T, "$1", ' ')); +} + +test "parseFormatStruct extra entry" { + const T = FormatStruct(&.{.session_id}); + try testing.expectError(error.ExtraEntry, parseFormatStruct(T, "$1 @2", ' ')); +} + +test "parseFormatStruct format error" { + const T = FormatStruct(&.{.session_id}); + try testing.expectError(error.FormatError, parseFormatStruct(T, "42", ' ')); + try testing.expectError(error.FormatError, parseFormatStruct(T, "@42", ' ')); + try testing.expectError(error.FormatError, parseFormatStruct(T, "$abc", ' ')); +} + +test "parseFormatStruct empty string" { + const T = FormatStruct(&.{.session_id}); + try testing.expectError(error.FormatError, parseFormatStruct(T, "", ' ')); +} + +test "parseFormatStruct with empty layout field" { + const T = FormatStruct(&.{ .session_id, .window_layout }); + const result = try parseFormatStruct(T, "$1,", ','); + try testing.expectEqual(1, result.session_id); + try testing.expectEqualStrings("", result.window_layout); +} + +fn testFormat( + comptime vars: []const Variable, + comptime delimiter: u8, + comptime expected: []const u8, +) !void { + const comptime_result = comptime comptimeFormat(vars, delimiter); + try testing.expectEqualStrings(expected, comptime_result); + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try format(&writer, vars, delimiter); + try testing.expectEqualStrings(expected, buf[0..writer.end]); +} + +test "format single variable" { + try testFormat(&.{.session_id}, ' ', "#{session_id}"); +} + +test "format multiple variables" { + try testFormat(&.{ .session_id, .window_id, .window_width, .window_height }, ' ', "#{session_id} #{window_id} #{window_width} #{window_height}"); +} + +test "format with comma delimiter" { + try testFormat(&.{ .window_id, .window_layout }, ',', "#{window_id},#{window_layout}"); +} + +test "format with tab delimiter" { + try testFormat(&.{ .window_width, .window_height }, '\t', "#{window_width}\t#{window_height}"); +} + +test "format empty variables" { + try testFormat(&.{}, ' ', ""); +} + +test "format all variables" { + try testFormat(&.{ .session_id, .window_id, .window_width, .window_height, .window_layout }, ' ', "#{session_id} #{window_id} #{window_width} #{window_height} #{window_layout}"); +} diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig new file mode 100644 index 000000000..0fcaaf207 --- /dev/null +++ b/src/terminal/tmux/viewer.zig @@ -0,0 +1,2292 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const testing = std.testing; +const assert = @import("../../quirks.zig").inlineAssert; +const size = @import("../size.zig"); +const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const CursorStyle = @import("../cursor.zig").Style; +const Screen = @import("../Screen.zig"); +const ScreenSet = @import("../ScreenSet.zig"); +const Terminal = @import("../Terminal.zig"); +const Layout = @import("layout.zig").Layout; +const control = @import("control.zig"); +const output = @import("output.zig"); + +const log = std.log.scoped(.terminal_tmux_viewer); + +// TODO: A list of TODOs as I think about them. +// - We need to make startup more robust so session and block can happen +// out of order. +// - We need to ignore `output` for panes that aren't yet initialized +// (until capture-panes are complete). +// - We should note what the active window pane is on the tmux side; +// we can use this at least for initial focus. + +// NOTE: There is some fragility here that can possibly break if tmux +// changes their implementation. In particular, the order of notifications +// and assurances about what is sent when are based on reading the tmux +// source code as of Dec, 2025. These aren't documented as fixed. +// +// I've tried not to depend on anything that seems like it'd change +// in the future. For example, it seems reasonable that command output +// always comes before session attachment. But, I am noting this here +// in case something breaks in the future we can consider it. We should +// be able to easily unit test all variations seen in the real world. + +/// The initial capacity of the command queue. We dynamically resize +/// as necessary so the initial value isn't that important, but if we +/// want to feel good about it we should make it large enough to support +/// our most realistic use cases without resizing. +const COMMAND_QUEUE_INITIAL = 8; + +/// A viewer is a tmux control mode client that attempts to create +/// a remote view of a tmux session, including providing the ability to send +/// new input to the session. +/// +/// This is the primary use case for tmux control mode, but technically +/// tmux control mode clients can do anything a normal tmux client can do, +/// so the `control.zig` and other files in this folder are more general +/// purpose. +/// +/// This struct helps move through a state machine of connecting to a tmux +/// session, negotiating capabilities, listing window state, etc. +/// +/// ## Viewer Lifecycle +/// +/// The viewer progresses through several states from initial connection +/// to steady-state operation. Here is the full flow: +/// +/// ``` +/// ┌─────────────────────────────────────────────┐ +/// │ TMUX CONTROL MODE START │ +/// │ (DCS 1000p received by host) │ +/// └─────────────────┬───────────────────────────┘ +/// │ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ startup_block │ +/// │ │ +/// │ Wait for initial %begin/%end block from │ +/// │ tmux. This is the response to the initial │ +/// │ command (e.g., "attach -t 0"). │ +/// └─────────────────┬───────────────────────────┘ +/// │ %end / %error +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ startup_session │ +/// │ │ +/// │ Wait for %session-changed notification │ +/// │ to get the initial session ID. │ +/// └─────────────────┬───────────────────────────┘ +/// │ %session-changed +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ command_queue │ +/// │ │ +/// │ Main operating state. Process commands │ +/// │ sequentially and handle notifications. │ +/// └─────────────────────────────────────────────┘ +/// │ +/// ┌───────────────────────────┼───────────────────────────┐ +/// │ │ │ +/// ▼ ▼ ▼ +/// ┌──────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────┐ +/// │ tmux_version │ │ list_windows │ │ %output / %layout- │ +/// │ │ │ │ │ change / etc. │ +/// │ Query tmux version for │ │ Get all windows in the │ │ │ +/// │ compatibility checks. │ │ current session. │ │ Handle live updates │ +/// └──────────────────────────┘ └────────────┬─────────────┘ │ from tmux server. │ +/// │ └────────────────────────┘ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ syncLayouts │ +/// │ │ +/// │ For each window, parse layout and sync │ +/// │ panes. New panes trigger capture commands. │ +/// └─────────────────┬───────────────────────────┘ +/// │ +/// ┌───────────────────────────┴───────────────────────────┐ +/// │ For each new pane: │ +/// ▼ ▼ +/// ┌──────────────────────────┐ ┌──────────────────────────┐ +/// │ pane_history │ │ pane_visible │ +/// │ (primary screen) │ │ (primary screen) │ +/// │ │ │ │ +/// │ Capture scrollback │ │ Capture visible area │ +/// │ history into terminal. │ │ into terminal. │ +/// └──────────────────────────┘ └──────────────────────────┘ +/// │ │ +/// ▼ ▼ +/// ┌──────────────────────────┐ ┌──────────────────────────┐ +/// │ pane_history │ │ pane_visible │ +/// │ (alternate screen) │ │ (alternate screen) │ +/// └──────────────────────────┘ └──────────────────────────┘ +/// │ │ +/// └───────────────────────────┬───────────────────────────┘ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ pane_state │ +/// │ │ +/// │ Query cursor position, cursor style, │ +/// │ and alternate screen mode for all panes. │ +/// └─────────────────────────────────────────────┘ +/// │ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ READY FOR OPERATION │ +/// │ │ +/// │ Panes are populated with content. The │ +/// │ viewer handles %output for live updates, │ +/// │ %layout-change for pane changes, and │ +/// │ %session-changed for session switches. │ +/// └─────────────────────────────────────────────┘ +/// ``` +/// +/// ## Error Handling +/// +/// At any point, if an unrecoverable error occurs or tmux sends `%exit`, +/// the viewer transitions to the `defunct` state and emits an `.exit` action. +/// +/// ## Session Changes +/// +/// When `%session-changed` is received during `command_queue` state, the +/// viewer resets itself completely: clears all windows/panes, emits an +/// empty windows action, and restarts the `list_windows` flow for the new +/// session. +/// +pub const Viewer = struct { + /// Allocator used for all internal state. + alloc: Allocator, + + /// Current state of the state machine. + state: State, + + /// The current session ID we're attached to. + session_id: usize, + + /// The tmux server version string (e.g., "3.5a"). We capture this + /// on startup because it will allow us to change behavior between + /// versions as necessary. + tmux_version: []const u8, + + /// The list of commands we've sent that we want to send and wait + /// for a response for. We only send one command at a time just + /// to avoid any possible confusion around ordering. + command_queue: CommandQueue, + + /// The windows in the current session. + windows: std.ArrayList(Window), + + /// The panes in the current session, mapped by pane ID. + panes: PanesMap, + + /// The arena used for the prior action allocated state. This contains + /// the contents for the actions as well as the actions slice itself. + action_arena: ArenaAllocator.State, + + /// A single action pre-allocated that we use for single-action + /// returns (common). This ensures that we can never get allocation + /// errors on single-action returns, especially those such as `.exit`. + action_single: [1]Action, + + pub const CommandQueue = CircBuf(Command, undefined); + pub const PanesMap = std.AutoArrayHashMapUnmanaged(usize, Pane); + + pub const Action = union(enum) { + /// Tmux has closed the control mode connection, we should end + /// our viewer session in some way. + exit, + + /// Send a command to tmux, e.g. `list-windows`. The caller + /// should not worry about parsing this or reading what command + /// it is; just send it to tmux as-is. This will include the + /// trailing newline so you can send it directly. + command: []const u8, + + /// Windows changed. This may add, remove or change windows. The + /// caller is responsible for diffing the new window list against + /// the prior one. Remember that for a given Viewer, window IDs + /// are guaranteed to be stable. Additionally, tmux (as of Dec 2025) + /// never re-uses window IDs within a server process lifetime. + windows: []const Window, + + pub fn format(self: Action, writer: *std.Io.Writer) !void { + const T = Action; + const info = @typeInfo(T).@"union"; + + try writer.writeAll(@typeName(T)); + if (info.tag_type) |TagType| { + try writer.writeAll("{ ."); + try writer.writeAll(@tagName(@as(TagType, self))); + try writer.writeAll(" = "); + + inline for (info.fields) |u_field| { + if (self == @field(TagType, u_field.name)) { + const value = @field(self, u_field.name); + switch (u_field.type) { + []const u8 => try writer.print("\"{s}\"", .{std.mem.trim(u8, value, " \t\r\n")}), + else => try writer.print("{any}", .{value}), + } + } + } + + try writer.writeAll(" }"); + } + } + }; + + pub const Input = union(enum) { + /// Data from tmux was received that needs to be processed. + tmux: control.Notification, + }; + + pub const Window = struct { + id: usize, + width: usize, + height: usize, + layout_arena: ArenaAllocator.State, + layout: Layout, + + pub fn deinit(self: *Window, alloc: Allocator) void { + self.layout_arena.promote(alloc).deinit(); + } + }; + + pub const Pane = struct { + terminal: Terminal, + + pub fn deinit(self: *Pane, alloc: Allocator) void { + self.terminal.deinit(alloc); + } + }; + + /// Initialize a new viewer. + /// + /// The given allocator is used for all internal state. You must + /// call deinit when you're done with the viewer to free it. + pub fn init(alloc: Allocator) Allocator.Error!Viewer { + // Create our initial command queue + var command_queue: CommandQueue = try .init(alloc, COMMAND_QUEUE_INITIAL); + errdefer command_queue.deinit(alloc); + + return .{ + .alloc = alloc, + .state = .startup_block, + // The default value here is meaningless. We don't get started + // until we receive a session-changed notification which will + // set this to a real value. + .session_id = 0, + .tmux_version = "", + .command_queue = command_queue, + .windows = .empty, + .panes = .empty, + .action_arena = .{}, + .action_single = undefined, + }; + } + + pub fn deinit(self: *Viewer) void { + { + for (self.windows.items) |*window| window.deinit(self.alloc); + self.windows.deinit(self.alloc); + } + { + var it = self.command_queue.iterator(.forward); + while (it.next()) |command| command.deinit(self.alloc); + self.command_queue.deinit(self.alloc); + } + { + var it = self.panes.iterator(); + while (it.next()) |kv| kv.value_ptr.deinit(self.alloc); + self.panes.deinit(self.alloc); + } + if (self.tmux_version.len > 0) { + self.alloc.free(self.tmux_version); + } + self.action_arena.promote(self.alloc).deinit(); + } + + /// Send in an input event (such as a tmux protocol notification, + /// keyboard input for a pane, etc.) and process it. The returned + /// list is a set of actions to take as a result of the input prior + /// to the next input. This list may be empty. + pub fn next(self: *Viewer, input: Input) []const Action { + // Developer note: this function must never return an error. If + // an error occurs we must go into a defunct state or some other + // state to gracefully handle it. + return switch (input) { + .tmux => self.nextTmux(input.tmux), + }; + } + + fn nextTmux( + self: *Viewer, + n: control.Notification, + ) []const Action { + return switch (self.state) { + .defunct => defunct: { + log.info("received notification in defunct state, ignoring", .{}); + break :defunct &.{}; + }, + + .startup_block => self.nextStartupBlock(n), + .startup_session => self.nextStartupSession(n), + .command_queue => self.nextCommand(n), + }; + } + + fn nextStartupBlock( + self: *Viewer, + n: control.Notification, + ) []const Action { + assert(self.state == .startup_block); + + switch (n) { + // This is only sent by the DCS parser when we first get + // DCS 1000p, it should never reach us here. + .enter => unreachable, + + // I don't think this is technically possible (reading the + // tmux source code), but if we see an exit we can semantically + // handle this without issue. + .exit => return self.defunct(), + + // Any begin and end (even error) is fine! Now we wait for + // session-changed to get the initial session ID. session-changed + // is guaranteed to come after the initial command output + // since if the initial command is `attach` tmux will run that, + // queue the notification, then do notificatins. + .block_end, .block_err => { + self.state = .startup_session; + return &.{}; + }, + + // I don't like catch-all else branches but startup is such + // a special case of looking for very specific things that + // are unlikely to expand. + else => return &.{}, + } + } + + fn nextStartupSession( + self: *Viewer, + n: control.Notification, + ) []const Action { + assert(self.state == .startup_session); + + switch (n) { + .enter => unreachable, + + .exit => return self.defunct(), + + .session_changed => |info| { + self.session_id = info.id; + + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + + return self.enterCommandQueue( + arena.allocator(), + &.{ .tmux_version, .list_windows }, + ) catch { + log.warn("failed to queue command, becoming defunct", .{}); + return self.defunct(); + }; + }, + + else => return &.{}, + } + } + + fn nextIdle( + self: *Viewer, + n: control.Notification, + ) []const Action { + assert(self.state == .idle); + + switch (n) { + .enter => unreachable, + .exit => return self.defunct(), + else => return &.{}, + } + } + + fn nextCommand( + self: *Viewer, + n: control.Notification, + ) []const Action { + // We have to be in a command queue, but the command queue MAY + // be empty. If it is empty, then receivedCommandOutput will + // handle it by ignoring any command output. That's okay! + assert(self.state == .command_queue); + + // Clear our prior arena so it is ready to be used for any + // actions immediately. + { + var arena = self.action_arena.promote(self.alloc); + _ = arena.reset(.free_all); + self.action_arena = arena.state; + } + + // Setup our empty actions list that commands can populate. + var actions: std.ArrayList(Action) = .empty; + + // Track whether the in-flight command slot is available. Starts true + // if queue is empty (no command in flight). Set to true when a command + // completes (block_end/block_err) or the queue is reset (session_changed). + var command_consumed = self.command_queue.empty(); + + switch (n) { + .enter => unreachable, + .exit => return self.defunct(), + + inline .block_end, + .block_err, + => |content, tag| { + self.receivedCommandOutput( + &actions, + content, + tag == .block_err, + ) catch { + log.warn("failed to process command output, becoming defunct", .{}); + return self.defunct(); + }; + + // Command is consumed since a block end/err is the output + // from a command. + command_consumed = true; + }, + + .output => |out| self.receivedOutput( + out.pane_id, + out.data, + ) catch |err| { + log.warn( + "failed to process output for pane id={}: {}", + .{ out.pane_id, err }, + ); + }, + + // Session changed means we switched to a different tmux session. + // We need to reset our state and start fresh with list-windows. + // This completely replaces the viewer, so treat it like a fresh start. + .session_changed => |info| { + self.sessionChanged( + &actions, + info.id, + ) catch { + log.warn("failed to handle session change, becoming defunct", .{}); + return self.defunct(); + }; + + // Command is consumed because sessionChanged resets + // our entire viewer. + command_consumed = true; + }, + + // Layout changed of a single window. + .layout_change => |info| self.layoutChanged( + &actions, + info.window_id, + info.layout, + ) catch { + // Note: in the future, we can probably handle a failure + // here with a fallback to remove this one window, list + // windows again, and try again. + log.warn("failed to handle layout change, becoming defunct", .{}); + return self.defunct(); + }, + + // A window was added to this session. + .window_add => |info| self.windowAdd(info.id) catch { + log.warn("failed to handle window add, becoming defunct", .{}); + return self.defunct(); + }, + + // The active pane changed. We don't care about this because + // we handle our own focus. + .window_pane_changed => {}, + + // We ignore this one. It means a session was created or + // destroyed. If it was our own session we will get an exit + // notification very soon. If it is another session we don't + // care. + .sessions_changed => {}, + + // We don't use window names for anything, currently. + .window_renamed => {}, + + // This is for other clients, which we don't do anything about. + // For us, we'll get `exit` or `session_changed`, respectively. + .client_detached, + .client_session_changed, + => {}, + } + + // After processing commands, we add our next command to + // execute if we have one. We do this last because command + // processing may itself queue more commands. We only emit a + // command if a prior command was consumed (or never existed). + if (self.state == .command_queue and command_consumed) { + if (self.command_queue.first()) |next_command| { + // We should not have any commands, because our nextCommand + // always queues them. + if (comptime std.debug.runtime_safety) { + for (actions.items) |action| { + if (action == .command) assert(false); + } + } + + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + const arena_alloc = arena.allocator(); + + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + next_command.formatCommand(&builder.writer) catch + return self.defunct(); + actions.append( + arena_alloc, + .{ .command = builder.writer.buffered() }, + ) catch return self.defunct(); + } + } + + return actions.items; + } + + /// When the layout changes for a single window, a pane may be added + /// or removed that we've never seen, in addition to the layout itself + /// physically changing. + /// + /// To handle this, its similar to list-windows except we expect the + /// window to already exist. We update the layout, do the initLayout + /// call for any diffs, setup commands to capture any new panes, + /// prune any removed panes. + fn layoutChanged( + self: *Viewer, + actions: *std.ArrayList(Action), + window_id: usize, + layout_str: []const u8, + ) !void { + // Find the window this layout change is for. + const window: *Window = window: for (self.windows.items) |*w| { + if (w.id == window_id) break :window w; + } else { + log.info("layout change for unknown window id={}", .{window_id}); + return; + }; + + // Clear our prior window arena and setup our layout + window.layout = layout: { + var arena = window.layout_arena.promote(self.alloc); + defer window.layout_arena = arena.state; + _ = arena.reset(.retain_capacity); + break :layout Layout.parseWithChecksum( + arena.allocator(), + layout_str, + ) catch |err| { + log.info( + "failed to parse window layout id={} layout={s}", + .{ window_id, layout_str }, + ); + return err; + }; + }; + + // Reset our arena so we can build up actions. + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + const arena_alloc = arena.allocator(); + + // Our initial action is to definitely let the caller know that + // some windows changed. + try actions.append(arena_alloc, .{ .windows = self.windows.items }); + + // Sync up our panes + try self.syncLayouts(self.windows.items); + } + + /// When a window is added to the session, we need to refresh our window + /// list to get the new window's information. + fn windowAdd( + self: *Viewer, + window_id: usize, + ) !void { + _ = window_id; // We refresh all windows via list-windows + + // Queue list-windows to get the updated window list + try self.queueCommands(&.{.list_windows}); + } + + fn syncLayouts( + self: *Viewer, + windows: []const Window, + ) !void { + // Go through the window layout and setup all our panes. We move + // this into a new panes map so that we can easily prune our old + // list. + var panes: PanesMap = .empty; + errdefer { + // Clear out all the new panes. + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| { + if (!self.panes.contains(kv.key_ptr.*)) { + kv.value_ptr.deinit(self.alloc); + } + } + panes.deinit(self.alloc); + } + for (windows) |window| try initLayout( + self.alloc, + &self.panes, + &panes, + window.layout, + ); + + // Build up the list of removed panes. + var removed: std.ArrayList(usize) = removed: { + var removed: std.ArrayList(usize) = .empty; + errdefer removed.deinit(self.alloc); + var panes_it = self.panes.iterator(); + while (panes_it.next()) |kv| { + if (panes.contains(kv.key_ptr.*)) continue; + try removed.append(self.alloc, kv.key_ptr.*); + } + + break :removed removed; + }; + defer removed.deinit(self.alloc); + + // Ensure we can add the windows + try self.windows.ensureTotalCapacity(self.alloc, windows.len); + + // Get our list of added panes and setup our command queue + // to populate them. + // TODO: errdefer cleanup + { + var panes_it = panes.iterator(); + var added: bool = false; + while (panes_it.next()) |kv| { + const pane_id: usize = kv.key_ptr.*; + if (self.panes.contains(pane_id)) continue; + added = true; + try self.queueCommands(&.{ + .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, + }); + } + + // If we added any panes, then we also want to resync the pane + // state (terminal modes and cursor positions and so on). + if (added) try self.queueCommands(&.{.pane_state}); + } + + // No more errors after this point. We're about to replace all + // our owned state with our temporary state, and our errdefers + // above will double-free if there is an error. + errdefer comptime unreachable; + + // Replace our window list if it changed. We assume it didn't + // change if our pointer is pointing to the same data. + if (windows.ptr != self.windows.items.ptr) { + for (self.windows.items) |*window| window.deinit(self.alloc); + self.windows.clearRetainingCapacity(); + self.windows.appendSliceAssumeCapacity(windows); + } + + // Replace our panes + { + // First remove our old panes + for (removed.items) |id| if (self.panes.fetchSwapRemove( + id, + )) |entry_const| { + var entry = entry_const; + entry.value.deinit(self.alloc); + }; + // We can now deinit self.panes because the existing + // entries are preserved. + self.panes.deinit(self.alloc); + self.panes = panes; + } + } + + /// When a session changes, we have to basically reset our whole state. + /// To do this, we emit an empty windows event (so callers can clear all + /// windows), reset ourself, and start all over. + fn sessionChanged( + self: *Viewer, + actions: *std.ArrayList(Action), + session_id: usize, + ) (Allocator.Error || std.Io.Writer.Error)!void { + // Build up a new viewer. Its the easiest way to reset ourselves. + var replacement: Viewer = try .init(self.alloc); + errdefer replacement.deinit(); + + // Our actions must start out empty so we don't mix arenas + assert(actions.items.len == 0); + errdefer actions.* = .empty; + + // Build actions: empty windows notification + list-windows command + var arena = replacement.action_arena.promote(replacement.alloc); + const arena_alloc = arena.allocator(); + try actions.append(arena_alloc, .{ .windows = &.{} }); + + // Setup our command queue and put ourselves in the command queue + // state. + try replacement.queueCommands(&.{.list_windows}); + replacement.state = .command_queue; + + // Transfer preserved version to replacement + replacement.tmux_version = try replacement.alloc.dupe(u8, self.tmux_version); + + // Save arena state back before swap + replacement.action_arena = arena.state; + + // Swap our self, no more error handling after this. + errdefer comptime unreachable; + self.deinit(); + self.* = replacement; + + // Set our session ID and jump directly to the list + self.session_id = session_id; + + assert(self.state == .command_queue); + } + + fn receivedCommandOutput( + self: *Viewer, + actions: *std.ArrayList(Action), + content: []const u8, + is_err: bool, + ) !void { + // Get the command we're expecting output for. We need to get the + // non-pointer value because we are deleting it from the circular + // buffer immediately. This shallow copy is all we need since + // all the memory in Command is owned by GPA. + const command: Command = if (self.command_queue.first()) |ptr| switch (ptr.*) { + // I truly can't explain this. A simple `ptr.*` copy will cause + // our memory to become undefined when deleteOldest is called + // below. I logged all the pointers and they don't match so I + // don't know how its being set to undefined. But a copy like + // this does work. + inline else => |v, tag| @unionInit( + Command, + @tagName(tag), + v, + ), + } else { + // If we have no pending commands, this is unexpected. + log.info("unexpected block output err={}", .{is_err}); + return; + }; + self.command_queue.deleteOldest(1); + defer command.deinit(self.alloc); + + // We'll use our arena for the return value here so we can + // easily accumulate actions. + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + const arena_alloc = arena.allocator(); + + // Process our command + switch (command) { + .user => {}, + + .pane_state => try self.receivedPaneState(content), + + .list_windows => try self.receivedListWindows( + arena_alloc, + actions, + content, + ), + + .pane_history => |cap| try self.receivedPaneHistory( + cap.screen_key, + cap.id, + content, + ), + + .pane_visible => |cap| try self.receivedPaneVisible( + cap.screen_key, + cap.id, + content, + ), + + .tmux_version => try self.receivedTmuxVersion(content), + } + } + + fn receivedTmuxVersion( + self: *Viewer, + content: []const u8, + ) !void { + const line = std.mem.trim(u8, content, " \t\r\n"); + if (line.len == 0) return; + + const data = output.parseFormatStruct( + Format.tmux_version.Struct(), + line, + Format.tmux_version.delim, + ) catch |err| { + log.info("failed to parse tmux version: {s}", .{line}); + return err; + }; + + if (self.tmux_version.len > 0) { + self.alloc.free(self.tmux_version); + } + self.tmux_version = try self.alloc.dupe(u8, data.version); + } + + fn receivedListWindows( + self: *Viewer, + arena_alloc: Allocator, + actions: *std.ArrayList(Action), + content: []const u8, + ) !void { + // If there is an error, reset our actions to what it was before. + errdefer actions.shrinkRetainingCapacity(actions.items.len); + + // This stores our new window state from this list-windows output. + var windows: std.ArrayList(Window) = .empty; + defer windows.deinit(self.alloc); + + // Parse all our windows + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |line_raw| { + const line = std.mem.trim(u8, line_raw, " \t\r"); + if (line.len == 0) continue; + const data = output.parseFormatStruct( + Format.list_windows.Struct(), + line, + Format.list_windows.delim, + ) catch |err| { + log.info("failed to parse list-windows line: {s}", .{line}); + return err; + }; + + // Parse the layout + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const window_alloc = arena.allocator(); + const layout: Layout = Layout.parseWithChecksum( + window_alloc, + data.window_layout, + ) catch |err| { + log.info( + "failed to parse window layout id={} layout={s}", + .{ data.window_id, data.window_layout }, + ); + return err; + }; + + try windows.append(self.alloc, .{ + .id = data.window_id, + .width = data.window_width, + .height = data.window_height, + .layout_arena = arena.state, + .layout = layout, + }); + } + + // Setup our windows action so the caller can process GUI + // window changes. + try actions.append(arena_alloc, .{ .windows = windows.items }); + + // Sync up our layouts. This will populate unknown panes, prune, etc. + try self.syncLayouts(windows.items); + } + + fn receivedPaneState( + self: *Viewer, + content: []const u8, + ) !void { + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |line_raw| { + const line = std.mem.trim(u8, line_raw, " \t\r"); + if (line.len == 0) continue; + + const data = output.parseFormatStruct( + Format.list_panes.Struct(), + line, + Format.list_panes.delim, + ) catch |err| { + log.info("failed to parse list-panes line: {s}", .{line}); + return err; + }; + + // Get the pane for this ID + const entry = self.panes.getEntry(data.pane_id) orelse { + log.info("received pane state for untracked pane id={}", .{data.pane_id}); + continue; + }; + const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + + // Determine which screen to use based on alternate_on + const screen_key: ScreenSet.Key = if (data.alternate_on) .alternate else .primary; + + // Set cursor position on the appropriate screen (tmux uses 0-based) + if (t.screens.get(screen_key)) |screen| { + cursor: { + const cursor_x = std.math.cast( + size.CellCountInt, + data.cursor_x, + ) orelse break :cursor; + const cursor_y = std.math.cast( + size.CellCountInt, + data.cursor_y, + ) orelse break :cursor; + if (cursor_x >= screen.pages.cols or + cursor_y >= screen.pages.rows) break :cursor; + screen.cursorAbsolute(cursor_x, cursor_y); + } + + // Set cursor shape on this screen + if (data.cursor_shape.len > 0) { + if (std.mem.eql(u8, data.cursor_shape, "block")) { + screen.cursor.cursor_style = .block; + } else if (std.mem.eql(u8, data.cursor_shape, "underline")) { + screen.cursor.cursor_style = .underline; + } else if (std.mem.eql(u8, data.cursor_shape, "bar")) { + screen.cursor.cursor_style = .bar; + } + } + // "default" or unknown: leave as-is + } + + // Set alternate screen saved cursor position + if (t.screens.get(.alternate)) |alt_screen| cursor: { + const alt_x = std.math.cast( + size.CellCountInt, + data.alternate_saved_x, + ) orelse break :cursor; + const alt_y = std.math.cast( + size.CellCountInt, + data.alternate_saved_y, + ) orelse break :cursor; + + // If our coordinates are outside our screen we ignore it. + // tmux actually sends MAX_INT for when there isn't a set + // cursor position, so this isn't theoretical. + if (alt_x >= alt_screen.pages.cols or + alt_y >= alt_screen.pages.rows) break :cursor; + + alt_screen.cursorAbsolute(alt_x, alt_y); + } + + // Set cursor visibility + t.modes.set(.cursor_visible, data.cursor_flag); + + // Set cursor blinking + t.modes.set(.cursor_blinking, data.cursor_blinking); + + // Terminal modes + t.modes.set(.insert, data.insert_flag); + t.modes.set(.wraparound, data.wrap_flag); + t.modes.set(.keypad_keys, data.keypad_flag); + t.modes.set(.cursor_keys, data.keypad_cursor_flag); + t.modes.set(.origin, data.origin_flag); + + // Mouse modes + t.modes.set(.mouse_event_any, data.mouse_all_flag); + t.modes.set(.mouse_event_button, data.mouse_any_flag); + t.modes.set(.mouse_event_normal, data.mouse_button_flag); + t.modes.set(.mouse_event_x10, data.mouse_standard_flag); + t.modes.set(.mouse_format_utf8, data.mouse_utf8_flag); + t.modes.set(.mouse_format_sgr, data.mouse_sgr_flag); + + // Focus and bracketed paste + t.modes.set(.focus_event, data.focus_flag); + t.modes.set(.bracketed_paste, data.bracketed_paste); + + // Scroll region (tmux uses 0-based values) + scroll: { + const scroll_top = std.math.cast( + size.CellCountInt, + data.scroll_region_upper, + ) orelse break :scroll; + const scroll_bottom = std.math.cast( + size.CellCountInt, + data.scroll_region_lower, + ) orelse break :scroll; + t.scrolling_region.top = scroll_top; + t.scrolling_region.bottom = scroll_bottom; + } + + // Tab stops - parse comma-separated list and set + t.tabstops.reset(0); // Clear all tabstops first + if (data.pane_tabs.len > 0) { + var tabs_it = std.mem.splitScalar(u8, data.pane_tabs, ','); + while (tabs_it.next()) |tab_str| { + const col = std.fmt.parseInt(usize, tab_str, 10) catch continue; + const col_cell = std.math.cast(size.CellCountInt, col) orelse continue; + if (col_cell >= t.cols) continue; + t.tabstops.set(col_cell); + } + } + } + } + + fn receivedPaneHistory( + self: *Viewer, + screen_key: ScreenSet.Key, + id: usize, + content: []const u8, + ) !void { + // Get our pane + const entry = self.panes.getEntry(id) orelse { + log.info("received pane history for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + _ = try t.switchScreen(screen_key); + const screen: *Screen = t.screens.active; + + // Get a VT stream from the terminal so we can send data as-is into + // it. This will populate the active area too so it won't be exactly + // correct but we'll get the active contents soon. + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice(content) catch |err| { + log.info("failed to process pane history for pane id={}: {}", .{ id, err }); + return err; + }; + + // Populate the active area to be empty since this is only history. + // We'll fill it with blanks and move the cursor to the top-left. + t.carriageReturn(); + for (0..t.rows) |_| try t.index(); + t.setCursorPos(1, 1); + + // Our active area should be empty + if (comptime std.debug.runtime_safety) { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + screen.dumpString(&discarding.writer, .{ + .tl = screen.pages.getTopLeft(.active), + .unwrap = false, + }) catch unreachable; + assert(discarding.count == 0); + } + } + + fn receivedPaneVisible( + self: *Viewer, + screen_key: ScreenSet.Key, + id: usize, + content: []const u8, + ) !void { + // Get our pane + const entry = self.panes.getEntry(id) orelse { + log.info("received pane visible for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + _ = try t.switchScreen(screen_key); + + // Erase the active area and reset the cursor to the top-left + // before writing the visible content. + t.eraseDisplay(.complete, false); + t.setCursorPos(1, 1); + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice(content) catch |err| { + log.info("failed to process pane visible for pane id={}: {}", .{ id, err }); + return err; + }; + } + + fn receivedOutput( + self: *Viewer, + id: usize, + data: []const u8, + ) !void { + const entry = self.panes.getEntry(id) orelse { + log.info("received output for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice(data) catch |err| { + log.info("failed to process output for pane id={}: {}", .{ id, err }); + return err; + }; + } + + fn initLayout( + gpa_alloc: Allocator, + panes_old: *const PanesMap, + panes_new: *PanesMap, + layout: Layout, + ) !void { + switch (layout.content) { + // Nested layouts, continue going. + .horizontal, .vertical => |layouts| { + for (layouts) |l| { + try initLayout( + gpa_alloc, + panes_old, + panes_new, + l, + ); + } + }, + + // A leaf! Initialize. + .pane => |id| pane: { + const gop = try panes_new.getOrPut(gpa_alloc, id); + if (gop.found_existing) break :pane; + errdefer _ = panes_new.swapRemove(gop.key_ptr.*); + + // If we already have this pane, it is already initialized + // so just copy it over. + if (panes_old.getEntry(id)) |entry| { + gop.value_ptr.* = entry.value_ptr.*; + break :pane; + } + + // TODO: We need to gracefully handle overflow of our + // max cols/width here. In practice we shouldn't hit this + // so we cast but its not safe. + var t: Terminal = try .init(gpa_alloc, .{ + .cols = @intCast(layout.width), + .rows = @intCast(layout.height), + }); + errdefer t.deinit(gpa_alloc); + + gop.value_ptr.* = .{ + .terminal = t, + }; + }, + } + } + + /// Enters the command queue state from any other state, queueing + /// the commands and returning an action to execute the first command. + fn enterCommandQueue( + self: *Viewer, + arena_alloc: Allocator, + commands: []const Command, + ) Allocator.Error![]const Action { + assert(self.state != .command_queue); + assert(commands.len > 0); + + // Build our command string to send for the action. + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + commands[0].formatCommand(&builder.writer) catch return error.OutOfMemory; + const action: Action = .{ .command = builder.writer.buffered() }; + + // Add our commands + try self.command_queue.ensureUnusedCapacity(self.alloc, commands.len); + for (commands) |cmd| self.command_queue.appendAssumeCapacity(cmd); + + // Move into the command queue state + self.state = .command_queue; + + return self.singleAction(action); + } + + /// Queue multiple commands to execute. This doesn't add anything + /// to the actions queue or return actions or anything because the + /// command_queue state will automatically send the next command when + /// it receives output. + fn queueCommands( + self: *Viewer, + commands: []const Command, + ) Allocator.Error!void { + try self.command_queue.ensureUnusedCapacity( + self.alloc, + commands.len, + ); + for (commands) |command| { + self.command_queue.appendAssumeCapacity(command); + } + } + + /// Helper to return a single action. The input action may use the arena + /// for allocated memory; this will not touch the arena. + fn singleAction(self: *Viewer, action: Action) []const Action { + // Make our single action slice. + self.action_single[0] = action; + return &self.action_single; + } + + fn defunct(self: *Viewer) []const Action { + self.state = .defunct; + return self.singleAction(.exit); + } +}; + +const State = enum { + /// We start in this state just after receiving the initial + /// DCS 1000p opening sequence. We wait for an initial + /// begin/end block that is guaranteed to be sent by tmux for + /// the initial control mode command. (See tmux server-client.c + /// where control mode starts). + startup_block, + + /// After receiving the initial block, we wait for a session-changed + /// notification to record the initial session ID. + startup_session, + + /// Tmux has closed the control mode connection + defunct, + + /// We're sitting on the command queue waiting for command output + /// in the order provided in the `command_queue` field. This field + /// isn't part of the state because it can be queued at any state. + /// + /// Precondition: if self.command_queue.len > 0, then the first + /// command in the queue has already been sent to tmux (via a + /// `command` Action). The next output is assumed to be the result + /// of this command. + /// + /// To satisfy the above, any transitions INTO this state should + /// send a command Action for the first command in the queue. + command_queue, +}; + +const Command = union(enum) { + /// List all windows so we can sync our window state. + list_windows, + + /// Capture history for the given pane ID. + pane_history: CapturePane, + + /// Capture visible area for the given pane ID. + pane_visible: CapturePane, + + /// Capture the pane terminal state as best we can. The pane ID(s) + /// are part of the output so we can map it back to our panes. + pane_state, + + /// Get the tmux server version. + tmux_version, + + /// User command. This is a command provided by the user. Since + /// this is user provided, we can't be sure what it is. + user: []const u8, + + const CapturePane = struct { + id: usize, + screen_key: ScreenSet.Key, + }; + + pub fn deinit(self: Command, alloc: Allocator) void { + return switch (self) { + .list_windows, + .pane_history, + .pane_visible, + .pane_state, + .tmux_version, + => {}, + .user => |v| alloc.free(v), + }; + } + + /// Format the command into the command that should be executed + /// by tmux. Trailing newlines are appended so this can be sent as-is + /// to tmux. + pub fn formatCommand( + self: Command, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + switch (self) { + .list_windows => try writer.writeAll(std.fmt.comptimePrint( + "list-windows -F '{s}'\n", + .{comptime Format.list_windows.comptimeFormat()}, + )), + + .pane_history => |cap| try writer.print( + // -p = output to stdout instead of buffer + // -e = output escape sequences for SGR + // -a = capture alternate screen (only valid for alternate) + // -q = quiet, don't error if alternate screen doesn't exist + // -S - = start at the top of history ("-") + // -E -1 = end at the last line of history (1 before the + // visible area is -1). + // -t %{d} = target a specific pane ID + "capture-pane -p -e -q {s}-S - -E -1 -t %{d}\n", + .{ + if (cap.screen_key == .alternate) "-a " else "", + cap.id, + }, + ), + + .pane_visible => |cap| try writer.print( + // -p = output to stdout instead of buffer + // -e = output escape sequences for SGR + // -a = capture alternate screen (only valid for alternate) + // -q = quiet, don't error if alternate screen doesn't exist + // -t %{d} = target a specific pane ID + // (no -S/-E = capture visible area only) + "capture-pane -p -e -q {s}-t %{d}\n", + .{ + if (cap.screen_key == .alternate) "-a " else "", + cap.id, + }, + ), + + .pane_state => try writer.writeAll(std.fmt.comptimePrint( + "list-panes -F '{s}'\n", + .{comptime Format.list_panes.comptimeFormat()}, + )), + + .tmux_version => try writer.writeAll(std.fmt.comptimePrint( + "display-message -p '{s}'\n", + .{comptime Format.tmux_version.comptimeFormat()}, + )), + + .user => |v| try writer.writeAll(v), + } + } +}; + +/// Format strings used for commands in our viewer. +const Format = struct { + /// The variables included in this format, in order. + vars: []const output.Variable, + + /// The delimiter to use between variables. This must be a character + /// guaranteed to not appear in any of the variable outputs. + delim: u8, + + const list_panes: Format = .{ + .delim = ';', + .vars = &.{ + .pane_id, + // Cursor position & appearance + .cursor_x, + .cursor_y, + .cursor_flag, + .cursor_shape, + .cursor_colour, + .cursor_blinking, + // Alternate screen + .alternate_on, + .alternate_saved_x, + .alternate_saved_y, + // Terminal modes + .insert_flag, + .wrap_flag, + .keypad_flag, + .keypad_cursor_flag, + .origin_flag, + // Mouse modes + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .mouse_sgr_flag, + // Focus & special features + .focus_flag, + .bracketed_paste, + // Scroll region + .scroll_region_upper, + .scroll_region_lower, + // Tab stops + .pane_tabs, + }, + }; + + const list_windows: Format = .{ + .delim = ' ', + .vars = &.{ + .session_id, + .window_id, + .window_width, + .window_height, + .window_layout, + }, + }; + + const tmux_version: Format = .{ + .delim = ' ', + .vars = &.{.version}, + }; + + /// The format string, available at comptime. + pub fn comptimeFormat(comptime self: Format) []const u8 { + return output.comptimeFormat(self.vars, self.delim); + } + + /// The struct that can contain the parsed output. + pub fn Struct(comptime self: Format) type { + return output.FormatStruct(self.vars); + } +}; + +const TestStep = struct { + input: Viewer.Input, + contains_tags: []const std.meta.Tag(Viewer.Action) = &.{}, + contains_command: []const u8 = "", + check: ?*const fn (viewer: *Viewer, []const Viewer.Action) anyerror!void = null, + check_command: ?*const fn (viewer: *Viewer, []const u8) anyerror!void = null, + + fn run(self: TestStep, viewer: *Viewer) !void { + const actions = viewer.next(self.input); + + // Common mistake, forgetting the newline on a command. + for (actions) |action| { + if (action == .command) { + try testing.expect(std.mem.endsWith(u8, action.command, "\n")); + } + } + + for (self.contains_tags) |tag| { + var found = false; + for (actions) |action| { + if (action == tag) { + found = true; + break; + } + } + try testing.expect(found); + } + + if (self.contains_command.len > 0) { + var found = false; + for (actions) |action| { + if (action == .command and + std.mem.startsWith(u8, action.command, self.contains_command)) + { + found = true; + break; + } + } + try testing.expect(found); + } + + if (self.check) |check_fn| { + try check_fn(viewer, actions); + } + + if (self.check_command) |check_fn| { + var found = false; + for (actions) |action| { + if (action == .command) { + found = true; + try check_fn(viewer, action.command); + } + } + try testing.expect(found); + } + } +}; + +/// A helper to run a series of test steps against a viewer and assert +/// that the expected actions are produced. +/// +/// I'm generally not a fan of these types of abstracted tests because +/// it makes diagnosing failures harder, but being able to construct +/// simulated tmux inputs and verify outputs is going to be extremely +/// important since the tmux control mode protocol is very complex and +/// fragile. +fn testViewer(viewer: *Viewer, steps: []const TestStep) !void { + for (steps, 0..) |step, i| { + step.run(viewer) catch |err| { + log.warn("testViewer step failed i={} step={}", .{ i, step }); + return err; + }; + } +} + +test "immediate exit" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + .{ + .input = .{ .tmux = .exit }, + .check = (struct { + fn check(_: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + } + }).check, + }, + }); +} + +test "session changed resets state" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "first", + } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + }, + // Receive window layout with two panes (same format as "initial flow" test) + .{ + .input = .{ .tmux = .{ + .block_end = + \\$1 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.session_id); + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(2, v.panes.count()); + try testing.expectEqualStrings("3.5a", v.tmux_version); + } + }).check, + }, + // Now session changes - should reset everything but keep version + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 2, + .name = "second", + } } }, + .contains_tags = &.{ .windows, .command }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + // Session ID should be updated + try testing.expectEqual(2, v.session_id); + // Windows should be cleared (empty windows action sent) + var found_empty_windows = false; + for (actions) |action| { + if (action == .windows and action.windows.len == 0) { + found_empty_windows = true; + } + } + try testing.expect(found_empty_windows); + // Old windows should be cleared + try testing.expectEqual(0, v.windows.items.len); + // Old panes should be cleared + try testing.expectEqual(0, v.panes.count()); + // Version should still be preserved + try testing.expectEqualStrings("3.5a", v.tmux_version); + } + }).check, + }, + // Receive new window layout for new session (same layout, different session/window) + // Uses same pane IDs 0,1 - they should be re-created since old panes were cleared + .{ + .input = .{ .tmux = .{ + .block_end = + \\$2 @1 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.session_id); + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(1, v.windows.items[0].id); + // Panes 0 and 1 should be created (fresh, since old ones were cleared) + try testing.expectEqual(2, v.panes.count()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "initial flow" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 42, + .name = "main", + } } }, + .contains_command = "display-message", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(42, v.session_id); + } + }).check, + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqualStrings("3.5a", v.tmux_version); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .contains_command = "capture-pane", + // pane_history for pane 0 (primary) + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ + .block_end = + \\Hello, world! + , + } }, + // Moves on to pane_visible for pane 0 (primary) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello, world!", str); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 0 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 0 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 (primary) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 (primary) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + }, + .{ + .input = .{ .tmux = .{ .output = .{ .pane_id = 0, .data = "new output" } } }, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expect(std.mem.containsAtLeast(u8, str, 1, "new output")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .output = .{ .pane_id = 999, .data = "ignored" } } }, + .check = (struct { + fn check(_: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "layout change" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(1, v.panes.count()); + try testing.expect(v.panes.contains(0)); + } + }).check, + }, + // Complete all capture-pane commands for pane 0 (primary and alternate) + // plus pane_state + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Now send a layout_change that splits into two panes + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{.windows}, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Should still have 1 window + try testing.expectEqual(1, v.windows.items.len); + // Should now have 2 panes (0 and 2) + try testing.expectEqual(2, v.panes.count()); + try testing.expect(v.panes.contains(0)); + try testing.expect(v.panes.contains(2)); + // Commands should be queued for the new pane (4 capture-pane + 1 pane_state) + try testing.expectEqual(5, v.command_queue.len()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "layout_change does not return command when queue not empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + // Do NOT complete capture-pane commands - queue still has commands. + // Send a layout_change that splits into two panes. + // This should NOT return a command action since queue was not empty. + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{.windows}, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.panes.count()); + // Should not contain a command action + for (actions) |action| { + try testing.expect(action != .command); + } + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "layout_change returns command when queue was empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + }, + // Complete all capture-pane commands for pane 0 + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Queue should now be empty + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(v.command_queue.empty()); + } + }).check, + }, + // Now send a layout_change that splits into two panes. + // This should return a command action since we're queuing commands + // for the new pane and the queue was empty. + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.panes.count()); + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "window_add queues list_windows when queue empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + }, + // Complete all capture-pane commands for pane 0 + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Queue should now be empty + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(v.command_queue.empty()); + } + }).check, + }, + // Now send window_add - should trigger list-windows command + .{ + .input = .{ .tmux = .{ .window_add = .{ .id = 1 } } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Command queue should have list_windows + try testing.expect(!v.command_queue.empty()); + try testing.expectEqual(1, v.command_queue.len()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "window_add queues list_windows when queue not empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Queue should have capture-pane commands + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + // Do NOT complete capture-pane commands - queue still has commands. + // Send window_add - should queue list-windows but NOT return command action + .{ + .input = .{ .tmux = .{ .window_add = .{ .id = 1 } } }, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + // Should not contain a command action since queue was not empty + for (actions) |action| { + try testing.expect(action != .command); + } + // But list_windows should be in the queue + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "two pane flow with pane state" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial block_end from attach + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Session changed notification + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 0, + .name = "0", + } } }, + .contains_command = "display-message", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, v.session_id); + } + }).check, + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + }, + // list-windows output with 2 panes in a vertical split + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 165 79 ca97,165x79,0,0[165x40,0,0,0,165x38,0,41,4] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.windows.items.len); + const window = v.windows.items[0]; + try testing.expectEqual(0, window.id); + try testing.expectEqual(165, window.width); + try testing.expectEqual(79, window.height); + try testing.expectEqual(2, v.panes.count()); + try testing.expect(v.panes.contains(0)); + try testing.expect(v.panes.contains(4)); + } + }).check, + }, + // capture-pane pane 0 primary history + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + \\prompt % + , + } }, + }, + // capture-pane pane 0 primary visible + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + // History has 2 lines with "prompt %" (padded to screen width) + try testing.expect(std.mem.containsAtLeast(u8, str, 2, "prompt %")); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("prompt %", str); + } + } + }).check, + }, + // capture-pane pane 0 alternate history (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 0 alternate visible (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 4 primary history + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + }, + // capture-pane pane 4 primary visible + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(4).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("prompt %", str); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + // Active screen starts with "prompt %" at beginning + try testing.expect(std.mem.startsWith(u8, str, "prompt %")); + } + } + }).check, + }, + // capture-pane pane 4 alternate history (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 4 alternate visible (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // list-panes output with terminal state + .{ + .input = .{ .tmux = .{ + .block_end = + \\%0;42;0;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;39;8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160 + \\%4;10;5;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;37;8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160 + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Pane 0: cursor at (42, 0), cursor visible, wraparound on + { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.get(.primary).?; + try testing.expectEqual(42, screen.cursor.x); + try testing.expectEqual(0, screen.cursor.y); + try testing.expect(t.modes.get(.cursor_visible)); + try testing.expect(t.modes.get(.wraparound)); + try testing.expect(!t.modes.get(.insert)); + try testing.expect(!t.modes.get(.origin)); + try testing.expect(!t.modes.get(.keypad_keys)); + try testing.expect(!t.modes.get(.cursor_keys)); + } + // Pane 4: cursor at (10, 5), cursor visible, wraparound on + { + const pane: *Viewer.Pane = v.panes.getEntry(4).?.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.get(.primary).?; + try testing.expectEqual(10, screen.cursor.x); + try testing.expectEqual(5, screen.cursor.y); + try testing.expect(t.modes.get(.cursor_visible)); + try testing.expect(t.modes.get(.wraparound)); + try testing.expect(!t.modes.get(.insert)); + try testing.expect(!t.modes.get(.origin)); + try testing.expect(!t.modes.get(.keypad_keys)); + try testing.expect(!t.modes.get(.cursor_keys)); + } + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} 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 319ae0ee6..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; @@ -102,24 +102,17 @@ pub fn threadEnter( errdefer self.subprocess.stop(); // Watcher to detect subprocess exit - var process: ?xev.Process = process: { + var process: ?xev.Process = if (self.subprocess.process) |v| switch (v) { + .fork_exec => |cmd| try xev.Process.init( + cmd.pid orelse return error.ProcessNoPid, + ), + // If we're executing via Flatpak then we can't do // traditional process watching (its implemented // as a special case in os/flatpak.zig) since the // command is on the host. - if (comptime build_config.flatpak) { - if (self.subprocess.flatpak_command != null) { - break :process null; - } - } - - // Get the pid from the subprocess - const command = self.subprocess.command orelse - return error.ProcessNotStarted; - const pid = command.pid orelse - return error.ProcessNoPid; - break :process try xev.Process.init(pid); - }; + .flatpak => null, + } else return error.ProcessNotStarted; errdefer if (process) |*p| p.deinit(); // Track our process start time for abnormal exits @@ -167,17 +160,19 @@ pub fn threadEnter( termio.Termio.ThreadData, td, processExit, - ) else if (comptime build_config.flatpak) { - // If we're in flatpak and we have a flatpak command - // then we can run the special flatpak logic for watching. - if (self.subprocess.flatpak_command) |*c| { - c.waitXev( + ) else if (comptime build_config.flatpak) flatpak: { + switch (self.subprocess.process orelse break :flatpak) { + // If we're in flatpak and we have a flatpak command + // then we can run the special flatpak logic for watching. + .flatpak => |*c| c.waitXev( td.loop, &td.backend.exec.flatpak_wait_c, termio.Termio.ThreadData, td, flatpakExit, - ); + ), + + .fork_exec => {}, } } @@ -587,10 +582,18 @@ const Subprocess = struct { grid_size: renderer.GridSize, screen_size: renderer.ScreenSize, pty: ?Pty = null, - command: ?Command = null, - flatpak_command: ?FlatpakHostCommand = null, + process: ?Process = null, linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, + /// Union that represents the running process type. + const Process = union(enum) { + /// Standard POSIX fork/exec + fork_exec: Command, + + /// Flatpak DBus command + flatpak: FlatpakHostCommand, + }; + const ArgsFormatter = struct { args: []const [:0]const u8, @@ -883,7 +886,7 @@ const Subprocess = struct { read: Pty.Fd, write: Pty.Fd, } { - assert(self.pty == null and self.command == null); + assert(self.pty == null and self.process == null); // This function is funny because on POSIX systems it can // fail in the forked process. This is flipped to true if @@ -908,6 +911,23 @@ const Subprocess = struct { self.pty = null; }; + // Cleanup we only run in our parent when we successfully start + // the process. + defer if (!in_child and self.process != null) { + if (comptime builtin.os.tag != .windows) { + // Once our subcommand is started we can close the slave + // side. This prevents the slave fd from being leaked to + // future children. + _ = posix.close(pty.slave); + } + + // Successful start we can clear out some memory. + if (self.env) |*env| { + env.deinit(); + self.env = null; + } + }; + log.debug("starting command command={f}", .{ArgsFormatter{ .args = self.args }}); // If we can't access the cwd, then don't set any cwd and inherit. @@ -959,15 +979,15 @@ const Subprocess = struct { } // Flatpak command must have a stable pointer. - self.flatpak_command = .{ + self.process = .{ .flatpak = .{ .argv = self.args, .cwd = cwd, .env = if (self.env) |*env| env else null, .stdin = pty.slave, .stdout = pty.slave, .stderr = pty.slave, - }; - var cmd = &self.flatpak_command.?; + } }; + var cmd = &self.process.?.flatpak; const pid = try cmd.spawn(alloc); errdefer killCommandFlatpak(cmd); @@ -976,11 +996,6 @@ const Subprocess = struct { pid, }); - // Once started, we can close the pty child side. We do this after - // wait right now but that is fine too. This lets us read the - // parent and detect EOF. - _ = posix.close(pty.slave); - return .{ .read = pty.master, .write = pty.master, @@ -1033,20 +1048,7 @@ const Subprocess = struct { log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"}); } - if (comptime builtin.os.tag != .windows) { - // Once our subcommand is started we can close the slave - // side. This prevents the slave fd from being leaked to - // future children. - _ = posix.close(pty.slave); - } - - // Successful start we can clear out some memory. - if (self.env) |*env| { - env.deinit(); - self.env = null; - } - - self.command = cmd; + self.process = .{ .fork_exec = cmd }; return switch (builtin.os.tag) { .windows => .{ .read = pty.out_pipe, @@ -1071,7 +1073,7 @@ const Subprocess = struct { /// Called to notify that we exited externally so we can unset our /// running state. pub fn externalExit(self: *Subprocess) void { - self.command = null; + self.process = null; } /// Stop the subprocess. This is safe to call anytime. This will wait @@ -1079,25 +1081,23 @@ const Subprocess = struct { /// for it to terminate, so it will not block. /// This does not close the pty. pub fn stop(self: *Subprocess) void { - // Kill our command - if (self.command) |*cmd| { - // Note: this will also wait for the command to exit, so - // DO NOT call cmd.wait - killCommand(cmd) catch |err| - log.err("error sending SIGHUP to command, may hang: {}", .{err}); - self.command = null; - } + switch (self.process orelse return) { + .fork_exec => |*cmd| { + // Note: this will also wait for the command to exit, so + // DO NOT call cmd.wait + killCommand(cmd) catch |err| + log.err("error sending SIGHUP to command, may hang: {}", .{err}); + }, - // Kill our Flatpak command - if (comptime build_config.flatpak) { - if (self.flatpak_command) |*cmd| { + .flatpak => |*cmd| if (comptime build_config.flatpak) { killCommandFlatpak(cmd) catch |err| log.err("error sending SIGHUP to command, may hang: {}", .{err}); _ = cmd.wait() catch |err| log.err("error waiting for command to exit: {}", .{err}); - self.flatpak_command = null; - } + }, } + + self.process = null; } /// Resize the pty subprocess. This is safe to call anytime. @@ -1137,41 +1137,45 @@ const Subprocess = struct { _ = try command.wait(false); }, - else => if (getpgid(pid)) |pgid| { - // It is possible to send a killpg between the time that - // our child process calls setsid but before or simultaneous - // to calling execve. In this case, the direct child dies - // but grandchildren survive. To work around this, we loop - // and repeatedly kill the process group until all - // descendents are well and truly dead. We will not rest - // until the entire family tree is obliterated. - while (true) { - switch (posix.errno(c.killpg(pgid, c.SIGHUP))) { - .SUCCESS => log.debug("process group killed pgid={}", .{pgid}), - else => |err| killpg: { - if ((comptime builtin.target.os.tag.isDarwin()) and - err == .PERM) - { - log.debug("killpg failed with EPERM, expected on Darwin and ignoring", .{}); - break :killpg; - } + else => try killPid(pid), + } + } + } - log.warn("error killing process group pgid={} err={}", .{ pgid, err }); - return error.KillFailed; - }, - } + fn killPid(pid: c.pid_t) !void { + const pgid = getpgid(pid) orelse return; - // See Command.zig wait for why we specify WNOHANG. - // The gist is that it lets us detect when children - // are still alive without blocking so that we can - // kill them again. - const res = posix.waitpid(pid, std.c.W.NOHANG); - log.debug("waitpid result={}", .{res.pid}); - if (res.pid != 0) break; - std.Thread.sleep(10 * std.time.ns_per_ms); + // It is possible to send a killpg between the time that + // our child process calls setsid but before or simultaneous + // to calling execve. In this case, the direct child dies + // but grandchildren survive. To work around this, we loop + // and repeatedly kill the process group until all + // descendents are well and truly dead. We will not rest + // until the entire family tree is obliterated. + while (true) { + switch (posix.errno(c.killpg(pgid, c.SIGHUP))) { + .SUCCESS => log.debug("process group killed pgid={}", .{pgid}), + else => |err| killpg: { + if ((comptime builtin.target.os.tag.isDarwin()) and + err == .PERM) + { + log.debug("killpg failed with EPERM, expected on Darwin and ignoring", .{}); + break :killpg; } + + log.warn("error killing process group pgid={} err={}", .{ pgid, err }); + return error.KillFailed; }, } + + // See Command.zig wait for why we specify WNOHANG. + // The gist is that it lets us detect when children + // are still alive without blocking so that we can + // kill them again. + const res = posix.waitpid(pid, std.c.W.NOHANG); + log.debug("waitpid result={}", .{res.pid}); + if (res.pid != 0) break; + std.Thread.sleep(10 * std.time.ns_per_ms); } } 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 e41fe33a9..7263418a7 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -5,30 +5,26 @@ 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); +/// Mutex state argument for queueMessage. +pub const MutexState = enum { locked, unlocked }; + /// Allocator alloc: Allocator, @@ -64,7 +60,7 @@ mailbox: termio.Mailbox, /// The stream parser. This parses the stream of escape codes and so on /// from the child process and calls callbacks in the stream handler. -terminal_stream: terminalpkg.Stream(StreamHandler), +terminal_stream: StreamHandler.Stream, /// Last time the cursor was reset. This is used to prevent message /// flooding with cursor resets. @@ -231,26 +227,33 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .rows = grid_size.rows, .max_scrollback = opts.full_config.@"scrollback-limit", .default_modes = default_modes, + .colors = .{ + .background = .init(opts.config.background.toTerminalRGB()), + .foreground = .init(opts.config.foreground.toTerminalRGB()), + .cursor = cursor: { + const color = opts.config.cursor_color orelse break :cursor .unset; + const rgb = color.toTerminalRGB() orelse break :cursor .unset; + break :cursor .init(rgb); + }, + .palette = .init(opts.config.palette), + }, }; }); errdefer term.deinit(alloc); - term.default_palette = opts.config.palette; - term.color_palette.colors = opts.config.palette; // Set the image size limits - try term.screen.kitty_images.setLimit( - alloc, - &term.screen, - opts.config.image_storage_limit, - ); - try term.secondary_screen.kitty_images.setLimit( - alloc, - &term.secondary_screen, - opts.config.image_storage_limit, - ); + var it = term.screens.all.iterator(); + while (it.next()) |entry| { + const screen: *terminalpkg.Screen = entry.value.*; + try screen.kitty_images.setLimit( + alloc, + screen, + opts.config.image_storage_limit, + ); + } // Set our default cursor style - term.screen.cursor.cursor_style = opts.config.cursor_style; + term.screens.active.cursor.cursor_style = opts.config.cursor_style; // Setup our terminal size in pixels for certain requests. term.width_px = term.cols * opts.size.cell.width; @@ -262,39 +265,20 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { // Create our stream handler. This points to memory in self so it // isn't safe to use until self.* is set. - const handler: StreamHandler = handler: { - const default_cursor_color: ?terminalpkg.color.RGB = color: { - if (opts.config.cursor_color) |color| switch (color) { - .color => break :color color.color.toTerminalRGB(), - .@"cell-foreground", - .@"cell-background", - => {}, - }; - - break :color null; - }; - - break :handler .{ - .alloc = alloc, - .termio_mailbox = &self.mailbox, - .surface_mailbox = opts.surface_mailbox, - .renderer_state = opts.renderer_state, - .renderer_wakeup = opts.renderer_wakeup, - .renderer_mailbox = opts.renderer_mailbox, - .size = &self.size, - .terminal = &self.terminal, - .osc_color_report_format = opts.config.osc_color_report_format, - .clipboard_write = opts.config.clipboard_write, - .enquiry_response = opts.config.enquiry_response, - .default_foreground_color = opts.config.foreground.toTerminalRGB(), - .default_background_color = opts.config.background.toTerminalRGB(), - .default_cursor_style = opts.config.cursor_style, - .default_cursor_blink = opts.config.cursor_blink, - .default_cursor_color = default_cursor_color, - .cursor_color = null, - .foreground_color = null, - .background_color = null, - }; + const handler: StreamHandler = .{ + .alloc = alloc, + .termio_mailbox = &self.mailbox, + .surface_mailbox = opts.surface_mailbox, + .renderer_state = opts.renderer_state, + .renderer_wakeup = opts.renderer_wakeup, + .renderer_mailbox = opts.renderer_mailbox, + .size = &self.size, + .terminal = &self.terminal, + .osc_color_report_format = opts.config.osc_color_report_format, + .clipboard_write = opts.config.clipboard_write, + .enquiry_response = opts.config.enquiry_response, + .default_cursor_style = opts.config.cursor_style, + .default_cursor_blink = opts.config.cursor_blink, }; const thread_enter_state = try ThreadEnterState.create( @@ -313,13 +297,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .size = opts.size, .backend = backend, .mailbox = opts.mailbox, - .terminal_stream = stream: { - var s: terminalpkg.Stream(StreamHandler) = .init(handler); - // Populate the OSC parser allocator (optional) because - // we want to support large OSC payloads such as OSC 52. - s.parser.osc_parser.alloc = alloc; - break :stream s; - }, + .terminal_stream = .initAlloc(alloc, handler), .thread_enter_state = thread_enter_state, }; } @@ -331,7 +309,6 @@ pub fn deinit(self: *Termio) void { self.mailbox.deinit(self.alloc); // Clear any StreamHandler state - self.terminal_stream.handler.deinit(); self.terminal_stream.deinit(); // Clear any initial state if we have it @@ -406,7 +383,7 @@ pub fn threadExit(self: *Termio, data: *ThreadData) void { pub fn queueMessage( self: *Termio, msg: termio.Message, - mutex: enum { locked, unlocked }, + mutex: MutexState, ) void { self.mailbox.send(msg, switch (mutex) { .locked => self.renderer_state.mutex, @@ -456,30 +433,28 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi // - command, working-directory: we never restart the underlying // process so we don't care or need to know about these. - // Update the default palette. Note this will only apply to new colors drawn - // since we decode all palette colors to RGB on usage. - self.terminal.default_palette = config.palette; + // Update the default palette. + self.terminal.colors.palette.changeDefault(config.palette); + self.terminal.flags.dirty.palette = true; - // Update the active palette, except for any colors that were modified with - // OSC 4 - for (0..config.palette.len) |i| { - if (!self.terminal.color_palette.mask.isSet(i)) { - self.terminal.color_palette.colors[i] = config.palette[i]; - self.terminal.flags.dirty.palette = true; - } - } + // Update all our other colors + self.terminal.colors.background.default = config.background.toTerminalRGB(); + self.terminal.colors.foreground.default = config.foreground.toTerminalRGB(); + self.terminal.colors.cursor.default = cursor: { + const color = config.cursor_color orelse break :cursor null; + break :cursor color.toTerminalRGB() orelse break :cursor null; + }; // Set the image size limits - try self.terminal.screen.kitty_images.setLimit( - self.alloc, - &self.terminal.screen, - config.image_storage_limit, - ); - try self.terminal.secondary_screen.kitty_images.setLimit( - self.alloc, - &self.terminal.secondary_screen, - config.image_storage_limit, - ); + var it = self.terminal.screens.all.iterator(); + while (it.next()) |entry| { + const screen: *terminalpkg.Screen = entry.value.*; + try screen.kitty_images.setLimit( + self.alloc, + screen, + config.image_storage_limit, + ); + } } /// Resize the terminal. @@ -597,20 +572,20 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { // emulator-level screen clear, this messes up the running programs // knowledge of where the cursor is and causes rendering issues. So, // for alt screen, we do nothing. - if (self.terminal.active_screen == .alternate) return; + if (self.terminal.screens.active_key == .alternate) return; // Clear our selection - self.terminal.screen.clearSelection(); + self.terminal.screens.active.clearSelection(); // Clear our scrollback if (history) self.terminal.eraseDisplay(.scrollback, false); // If we're not at a prompt, we just delete above the cursor. if (!self.terminal.cursorIsAtPrompt()) { - if (self.terminal.screen.cursor.y > 0) { - self.terminal.screen.eraseRows( + if (self.terminal.screens.active.cursor.y > 0) { + self.terminal.screens.active.eraseRows( .{ .active = .{ .y = 0 } }, - .{ .active = .{ .y = self.terminal.screen.cursor.y - 1 } }, + .{ .active = .{ .y = self.terminal.screens.active.cursor.y - 1 } }, ); } @@ -620,8 +595,8 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { // graphics that are placed baove the cursor or if it deletes // all of them. We delete all of them for now but if this behavior // isn't fully correct we should fix this later. - self.terminal.screen.kitty_images.delete( - self.terminal.screen.alloc, + self.terminal.screens.active.kitty_images.delete( + self.terminal.screens.active.alloc, &self.terminal, .{ .all = true }, ); @@ -654,7 +629,7 @@ pub fn jumpToPrompt(self: *Termio, delta: isize) !void { { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - self.terminal.screen.scroll(.{ .delta_prompt = delta }); + self.terminal.screens.active.scroll(.{ .delta_prompt = delta }); } try self.renderer_wakeup.notify(); diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index edf966df7..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); @@ -471,15 +470,23 @@ fn stopCallback( fn startScrollTimer(self: *Thread, cb: *CallbackData) void { self.scroll_active = true; - // Start the timer which loops - self.scroll.run( - &self.loop, - &self.scroll_c, - selection_scroll_ms, - CallbackData, - cb, - selectionScrollCallback, - ); + switch (self.scroll_c.state()) { + // If it is already active, e.g. startScrollTimer is called multiple + // times, then we just return. We can't simply check `scroll_active` + // because its possible that `stopScrollTimer` was called but there + // was no loop tick between then and now to halt out completion. + .active => return, + + // If the completion is not active then we need to start it. + .dead => self.scroll.run( + &self.loop, + &self.scroll_c, + selection_scroll_ms, + CallbackData, + cb, + selectionScrollCallback, + ), + } } fn stopScrollTimer(self: *Thread) void { 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 8b2648dbd..71492230e 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -259,8 +259,9 @@ fn setupBash( resource_dir: []const u8, env: *EnvMap, ) !?config.Command { - var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 3); - defer args.deinit(alloc); + var stack_fallback = std.heap.stackFallback(4096, alloc); + var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get()); + defer cmd.deinit(); // Iterator that yields each argument in the original command line. // This will allocate once proportionate to the command line length. @@ -269,14 +270,9 @@ fn setupBash( // Start accumulating arguments with the executable and initial flags. if (iter.next()) |exe| { - try args.append(alloc, try alloc.dupeZ(u8, exe)); + try cmd.appendArg(exe); } else return null; - try args.append(alloc, "--posix"); - - // On macOS, we request a login shell to match that platform's norms. - if (comptime builtin.target.os.tag.isDarwin()) { - try args.append(alloc, "--login"); - } + try cmd.appendArg("--posix"); // Stores the list of intercepted command line flags that will be passed // to our shell integration script: --norc --noprofile @@ -309,17 +305,17 @@ fn setupBash( if (std.mem.indexOfScalar(u8, arg, 'c') != null) { return null; } - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) { // All remaining arguments should be passed directly to the shell // command. We shouldn't perform any further option processing. - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); while (iter.next()) |remaining_arg| { - try args.append(alloc, try alloc.dupeZ(u8, remaining_arg)); + try cmd.appendArg(remaining_arg); } break; } else { - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); } } try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]); @@ -357,9 +353,11 @@ fn setupBash( ); try env.put("ENV", integ_dir); - // Since we built up a command line, we don't need to wrap it in - // ANOTHER shell anymore and can do a direct command. - return .{ .direct = try args.toOwnedSlice(alloc) }; + // Get the command string from the builder, then copy it to the arena + // allocator. The stackFallback allocator's memory becomes invalid after + // this function returns, so we must copy to the arena. + const cmd_str = try cmd.toOwnedSlice(); + return .{ .shell = try alloc.dupeZ(u8, cmd_str) }; } test "bash" { @@ -373,12 +371,7 @@ test "bash" { const command = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?); try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_INJECT").?); } @@ -421,12 +414,7 @@ test "bash: inject flags" { const command = try setupBash(alloc, .{ .shell = "bash --norc" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("1 --norc", env.get("GHOSTTY_BASH_INJECT").?); } @@ -437,12 +425,7 @@ test "bash: inject flags" { const command = try setupBash(alloc, .{ .shell = "bash --noprofile" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("1 --noprofile", env.get("GHOSTTY_BASH_INJECT").?); } } @@ -459,24 +442,14 @@ test "bash: rcfile" { // bash --rcfile { const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?); } // bash --init-file { const command = try setupBash(alloc, .{ .shell = "bash --init-file profile.sh" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?); } } @@ -538,35 +511,13 @@ test "bash: additional arguments" { // "-" argument separator { const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, ".", &env); - try testing.expect(command.?.direct.len >= 6); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } - - const offset = if (comptime builtin.target.os.tag.isDarwin()) 3 else 2; - try testing.expectEqualStrings("-", command.?.direct[offset + 0]); - try testing.expectEqualStrings("--arg", command.?.direct[offset + 1]); - try testing.expectEqualStrings("file1", command.?.direct[offset + 2]); - try testing.expectEqualStrings("file2", command.?.direct[offset + 3]); + try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?.shell); } // "--" argument separator { const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, ".", &env); - try testing.expect(command.?.direct.len >= 6); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } - - const offset = if (comptime builtin.target.os.tag.isDarwin()) 3 else 2; - try testing.expectEqualStrings("--", command.?.direct[offset + 0]); - try testing.expectEqualStrings("--arg", command.?.direct[offset + 1]); - try testing.expectEqualStrings("file1", command.?.direct[offset + 2]); - try testing.expectEqualStrings("file2", command.?.direct[offset + 3]); + try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?.shell); } } @@ -659,12 +610,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 06ff29809..182770339 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"); @@ -45,22 +45,6 @@ pub const StreamHandler = struct { default_cursor: bool = true, default_cursor_style: terminal.CursorStyle, default_cursor_blink: ?bool, - default_cursor_color: ?terminal.color.RGB, - - /// Actual cursor color. This can be changed with OSC 12. If unset, falls - /// back to the default cursor color. - cursor_color: ?terminal.color.RGB, - - /// The default foreground and background color are those set by the user's - /// config file. - default_foreground_color: terminal.color.RGB, - default_background_color: terminal.color.RGB, - - /// The foreground and background color as set by an OSC 10 or OSC 11 - /// sequence. If unset then the respective color falls back to the default - /// value. - foreground_color: ?terminal.color.RGB, - background_color: ?terminal.color.RGB, /// The response to use for ENQ requests. The memory is owned by /// whoever owns StreamHandler. @@ -86,6 +70,9 @@ pub const StreamHandler = struct { /// such as XTGETTCAP. dcs: terminal.dcs.Handler = .{}, + /// The tmux control mode viewer state. + tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {}, + /// This is set to true when a message was written to the termio /// mailbox. This can be used by callers to determine if they need /// to wake up the termio thread. @@ -95,9 +82,20 @@ pub const StreamHandler = struct { /// this to determine if we need to default the window title. seen_title: bool = false, + pub const Stream = terminal.Stream(StreamHandler); + + /// True if we have tmux control mode built in. + pub const tmux_enabled = terminal.options.tmux_control_mode; + pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); self.dcs.deinit(); + if (comptime tmux_enabled) tmux: { + const viewer = self.tmux_viewer orelse break :tmux; + viewer.deinit(); + self.alloc.destroy(viewer); + self.tmux_viewer = null; + } } /// This queues a render operation with the renderer thread. The render @@ -112,20 +110,8 @@ pub const StreamHandler = struct { self.osc_color_report_format = config.osc_color_report_format; self.clipboard_write = config.clipboard_write; self.enquiry_response = config.enquiry_response; - self.default_foreground_color = config.foreground.toTerminalRGB(); - self.default_background_color = config.background.toTerminalRGB(); self.default_cursor_style = config.cursor_style; self.default_cursor_blink = config.cursor_blink; - self.default_cursor_color = color: { - if (config.cursor_color) |color| switch (color) { - .color => break :color color.color.toTerminalRGB(), - .@"cell-foreground", - .@"cell-background", - => {}, - }; - - break :color null; - }; // If our cursor is the default, then we update it immediately. if (self.default_cursor) self.setCursorStyle(.default) catch |err| { @@ -186,6 +172,193 @@ pub const StreamHandler = struct { _ = self.renderer_mailbox.push(msg, .{ .forever = {} }); } + pub fn vt( + self: *StreamHandler, + 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 => { + @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 => { + @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 => { + @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( + self.terminal.screens.active.cursor.y + 1, + self.terminal.screens.active.cursor.x + 1 +| value.value, + ), + .cursor_row_relative => self.terminal.setCursorPos( + self.terminal.screens.active.cursor.y + 1 +| value.value, + self.terminal.screens.active.cursor.x + 1, + ), + .cursor_style => try self.setCursorStyle(value), + .erase_display_below => self.terminal.eraseDisplay(.below, value), + .erase_display_above => self.terminal.eraseDisplay(.above, value), + .erase_display_complete => { + try self.terminal.scrollViewport(.{ .bottom = {} }); + self.terminal.eraseDisplay(.complete, value); + }, + .erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value), + .erase_display_scroll_complete => self.terminal.eraseDisplay(.scroll_complete, value), + .erase_line_right => self.terminal.eraseLine(.right, value), + .erase_line_left => self.terminal.eraseLine(.left, value), + .erase_line_complete => self.terminal.eraseLine(.complete, value), + .erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value), + .delete_chars => self.terminal.deleteChars(value), + .erase_chars => self.terminal.eraseChars(value), + .insert_lines => self.terminal.insertLines(value), + .insert_blanks => self.terminal.insertBlanks(value), + .delete_lines => self.terminal.deleteLines(value), + .scroll_up => try self.terminal.scrollUp(value), + .scroll_down => self.terminal.scrollDown(value), + .tab_clear_current => self.terminal.tabClear(.current), + .tab_clear_all => self.terminal.tabClear(.all), + .tab_set => self.terminal.tabSet(), + .tab_reset => self.terminal.tabReset(), + .index => try self.index(), + .next_line => try self.nextLine(), + .reverse_index => try self.reverseIndex(), + .full_reset => try self.fullReset(), + .set_mode => try self.setMode(value.mode, true), + .reset_mode => try self.setMode(value.mode, false), + .save_mode => self.terminal.modes.save(value.mode), + .restore_mode => { + // For restore mode we have to restore but if we set it, we + // always have to call setMode because setting some modes have + // side effects and we want to make sure we process those. + const v = self.terminal.modes.restore(value.mode); + try self.setMode(value.mode, v); + }, + .request_mode => try self.requestMode(value.mode), + .request_mode_unknown => try self.requestModeUnknown(value.mode, value.ansi), + .top_and_bottom_margin => self.terminal.setTopAndBottomMargin(value.top_left, value.bottom_right), + .left_and_right_margin => self.terminal.setLeftAndRightMargin(value.top_left, value.bottom_right), + .left_and_right_margin_ambiguous => { + if (self.terminal.modes.get(.enable_left_and_right_margin)) { + self.terminal.setLeftAndRightMargin(0, 0); + } else { + self.terminal.saveCursor(); + } + }, + .save_cursor => try self.saveCursor(), + .restore_cursor => try self.restoreCursor(), + .modify_key_format => try self.setModifyKeyFormat(value), + .protected_mode_off => self.terminal.setProtectedMode(.off), + .protected_mode_iso => self.terminal.setProtectedMode(.iso), + .protected_mode_dec => self.terminal.setProtectedMode(.dec), + .mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false, + .size_report => self.sendSizeReport(value), + .xtversion => try self.reportXtversion(), + .device_attributes => try self.deviceAttributes(value), + .device_status => try self.deviceStatusReport(value.request), + .kitty_keyboard_query => try self.queryKittyKeyboard(), + .kitty_keyboard_push => { + log.debug("pushing kitty keyboard mode: {}", .{value.flags}); + self.terminal.screens.active.kitty_keyboard.push(value.flags); + }, + .kitty_keyboard_pop => { + log.debug("popping kitty keyboard mode n={}", .{value}); + self.terminal.screens.active.kitty_keyboard.pop(@intCast(value)); + }, + .kitty_keyboard_set => { + log.debug("setting kitty keyboard mode: set {}", .{value.flags}); + self.terminal.screens.active.kitty_keyboard.set(.set, value.flags); + }, + .kitty_keyboard_set_or => { + log.debug("setting kitty keyboard mode: or {}", .{value.flags}); + self.terminal.screens.active.kitty_keyboard.set(.@"or", value.flags); + }, + .kitty_keyboard_set_not => { + log.debug("setting kitty keyboard mode: not {}", .{value.flags}); + self.terminal.screens.active.kitty_keyboard.set(.not, value.flags); + }, + .kitty_color_report => try self.kittyColorReport(value), + .color_operation => try self.colorOperation(value.op, &value.requests, value.terminator), + .prompt_end => try self.promptEnd(), + .end_of_input => try self.endOfInput(), + .end_hyperlink => try self.endHyperlink(), + .active_status_display => self.terminal.status_display = value, + .decaln => try self.decaln(), + .window_title => try self.windowTitle(value.title), + .report_pwd => try self.reportPwd(value.url), + .show_desktop_notification => try self.showDesktopNotification(value.title, value.body), + .progress_report => self.progressReport(value), + .start_hyperlink => try self.startHyperlink(value.uri, value.id), + .clipboard_contents => try self.clipboardContents(value.kind, value.data), + .prompt_start => self.promptStart(value.aid, value.redraw), + .prompt_continuation => self.promptContinuation(value.aid), + .end_of_command => self.endOfCommand(value.exit_code), + .mouse_shape => try self.setMouseShape(value), + .configure_charset => self.configureCharset(value.slot, value.charset), + .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), + .dcs_unhook => try self.dcsUnhook(), + .apc_start => self.apc.start(), + .apc_end => try self.apcEnd(), + .apc_put => self.apc.feed(self.alloc, value), + + // Unimplemented + .title_push, + .title_pop, + => {}, + } + } + pub inline fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { var cmd = self.dcs.hook(self.alloc, dcs) orelse return; defer cmd.deinit(); @@ -207,9 +380,73 @@ pub const StreamHandler = struct { fn dcsCommand(self: *StreamHandler, cmd: *terminal.dcs.Command) !void { // log.warn("DCS command: {}", .{cmd}); switch (cmd.*) { - .tmux => |tmux| { - // TODO: process it - log.warn("tmux control mode event unimplemented cmd={}", .{tmux}); + .tmux => |tmux| tmux: { + // If tmux control mode is disabled at the build level, + // then this whole block shouldn't be analyzed. + if (comptime !tmux_enabled) break :tmux; + log.info("tmux control mode event cmd={f}", .{tmux}); + + switch (tmux) { + .enter => { + // Setup our viewer state + assert(self.tmux_viewer == null); + const viewer = try self.alloc.create(terminal.tmux.Viewer); + errdefer self.alloc.destroy(viewer); + viewer.* = try .init(self.alloc); + errdefer viewer.deinit(); + self.tmux_viewer = viewer; + break :tmux; + }, + + .exit => if (self.tmux_viewer) |viewer| { + // Free our viewer state + viewer.deinit(); + self.alloc.destroy(viewer); + self.tmux_viewer = null; + break :tmux; + }, + + else => {}, + } + + assert(tmux != .enter); + assert(tmux != .exit); + + const viewer = self.tmux_viewer orelse { + // This can only really happen if we failed to + // initialize the viewer on enter. + log.info( + "received tmux control mode command without viewer: {f}", + .{tmux}, + ); + + break :tmux; + }; + + for (viewer.next(.{ .tmux = tmux })) |action| { + log.info("tmux viewer action={f}", .{action}); + switch (action) { + .exit => { + // We ignore this because we will fully exit when + // our DCS connection ends. We may want to handle + // this in the future to notify our GUI we're + // disconnected though. + }, + + .command => |command| { + assert(command.len > 0); + assert(command[command.len - 1] == '\n'); + self.messageWriter(try termio.Message.writeReq( + self.alloc, + command, + )); + }, + + .windows => { + // TODO + }, + } + } }, .xtgettcap => |*gettcap| { @@ -248,7 +485,7 @@ pub const StreamHandler = struct { .decscusr => { const blink = self.terminal.modes.get(.cursor_blinking); - const style: u8 = switch (self.terminal.screen.cursor.cursor_style) { + const style: u8 = switch (self.terminal.screens.active.cursor.cursor_style) { .block => if (blink) 1 else 2, .underline => if (blink) 3 else 4, .bar => if (blink) 5 else 6, @@ -293,14 +530,6 @@ pub const StreamHandler = struct { } } - pub inline fn apcStart(self: *StreamHandler) !void { - self.apc.start(); - } - - pub inline fn apcPut(self: *StreamHandler, byte: u8) !void { - self.apc.feed(self.alloc, byte); - } - pub fn apcEnd(self: *StreamHandler) !void { var cmd = self.apc.end() orelse return; defer cmd.deinit(self.alloc); @@ -322,126 +551,32 @@ pub const StreamHandler = struct { } } - pub inline fn print(self: *StreamHandler, ch: u21) !void { - try self.terminal.print(ch); - } - - pub inline fn printRepeat(self: *StreamHandler, count: usize) !void { - try self.terminal.printRepeat(count); - } - - pub inline fn bell(self: *StreamHandler) !void { + inline fn bell(self: *StreamHandler) void { self.surfaceMessageWriter(.ring_bell); } - pub inline fn backspace(self: *StreamHandler) !void { - self.terminal.backspace(); - } - - pub inline fn horizontalTab(self: *StreamHandler, count: u16) !void { + inline fn horizontalTab(self: *StreamHandler, count: u16) !void { for (0..count) |_| { - const x = self.terminal.screen.cursor.x; + const x = self.terminal.screens.active.cursor.x; try self.terminal.horizontalTab(); - if (x == self.terminal.screen.cursor.x) break; + if (x == self.terminal.screens.active.cursor.x) break; } } - pub inline fn horizontalTabBack(self: *StreamHandler, count: u16) !void { + inline fn horizontalTabBack(self: *StreamHandler, count: u16) !void { for (0..count) |_| { - const x = self.terminal.screen.cursor.x; + const x = self.terminal.screens.active.cursor.x; try self.terminal.horizontalTabBack(); - if (x == self.terminal.screen.cursor.x) break; + if (x == self.terminal.screens.active.cursor.x) break; } } - pub inline fn linefeed(self: *StreamHandler) !void { + inline fn linefeed(self: *StreamHandler) !void { // Small optimization: call index instead of linefeed because they're // identical and this avoids one layer of function call overhead. try self.terminal.index(); } - pub inline fn carriageReturn(self: *StreamHandler) !void { - self.terminal.carriageReturn(); - } - - pub inline fn setCursorLeft(self: *StreamHandler, amount: u16) !void { - self.terminal.cursorLeft(amount); - } - - pub inline fn setCursorRight(self: *StreamHandler, amount: u16) !void { - self.terminal.cursorRight(amount); - } - - pub inline fn setCursorDown(self: *StreamHandler, amount: u16, carriage: bool) !void { - self.terminal.cursorDown(amount); - if (carriage) self.terminal.carriageReturn(); - } - - pub inline fn setCursorUp(self: *StreamHandler, amount: u16, carriage: bool) !void { - self.terminal.cursorUp(amount); - if (carriage) self.terminal.carriageReturn(); - } - - pub inline fn setCursorCol(self: *StreamHandler, col: u16) !void { - self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, col); - } - - pub inline fn setCursorColRelative(self: *StreamHandler, offset: u16) !void { - self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1, - self.terminal.screen.cursor.x + 1 +| offset, - ); - } - - pub inline fn setCursorRow(self: *StreamHandler, row: u16) !void { - self.terminal.setCursorPos(row, self.terminal.screen.cursor.x + 1); - } - - pub inline fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void { - self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1 +| offset, - self.terminal.screen.cursor.x + 1, - ); - } - - pub inline fn setCursorPos(self: *StreamHandler, row: u16, col: u16) !void { - self.terminal.setCursorPos(row, col); - } - - pub inline fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void { - if (mode == .complete) { - // Whenever we erase the full display, scroll to bottom. - try self.terminal.scrollViewport(.{ .bottom = {} }); - try self.queueRender(); - } - - self.terminal.eraseDisplay(mode, protected); - } - - pub inline fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void { - self.terminal.eraseLine(mode, protected); - } - - pub inline fn deleteChars(self: *StreamHandler, count: usize) !void { - self.terminal.deleteChars(count); - } - - pub inline fn eraseChars(self: *StreamHandler, count: usize) !void { - self.terminal.eraseChars(count); - } - - pub inline fn insertLines(self: *StreamHandler, count: usize) !void { - self.terminal.insertLines(count); - } - - pub inline fn insertBlanks(self: *StreamHandler, count: usize) !void { - self.terminal.insertBlanks(count); - } - - pub inline fn deleteLines(self: *StreamHandler, count: usize) !void { - self.terminal.deleteLines(count); - } - pub inline fn reverseIndex(self: *StreamHandler) !void { self.terminal.reverseIndex(); } @@ -455,48 +590,25 @@ pub const StreamHandler = struct { self.terminal.carriageReturn(); } - pub inline fn setTopAndBottomMargin(self: *StreamHandler, top: u16, bot: u16) !void { - self.terminal.setTopAndBottomMargin(top, bot); - } - - pub inline fn setLeftAndRightMarginAmbiguous(self: *StreamHandler) !void { - if (self.terminal.modes.get(.enable_left_and_right_margin)) { - try self.setLeftAndRightMargin(0, 0); - } else { - try self.saveCursor(); - } - } - - pub inline fn setLeftAndRightMargin(self: *StreamHandler, left: u16, right: u16) !void { - self.terminal.setLeftAndRightMargin(left, right); - } - pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void { self.terminal.flags.modify_other_keys_2 = false; switch (format) { - .other_keys => |v| switch (v) { - .numeric => self.terminal.flags.modify_other_keys_2 = true, - else => {}, - }, + .other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true, else => {}, } } - pub fn requestMode(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { - // Get the mode value and respond. - const code: u8 = code: { - const mode = terminal.modes.modeFromInt(mode_raw, ansi) orelse break :code 0; - if (self.terminal.modes.get(mode)) break :code 1; - break :code 2; - }; + fn requestMode(self: *StreamHandler, mode: terminal.Mode) !void { + const tag: terminal.modes.ModeTag = @bitCast(@intFromEnum(mode)); + const code: u8 = if (self.terminal.modes.get(mode)) 1 else 2; var msg: termio.Message = .{ .write_small = .{} }; const resp = try std.fmt.bufPrint( &msg.write_small.data, "\x1B[{s}{};{}$y", .{ - if (ansi) "" else "?", - mode_raw, + if (tag.ansi) "" else "?", + tag.value, code, }, ); @@ -504,18 +616,18 @@ pub const StreamHandler = struct { self.messageWriter(msg); } - pub inline fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void { - // log.debug("save mode={}", .{mode}); - self.terminal.modes.save(mode); - } - - pub inline fn restoreMode(self: *StreamHandler, mode: terminal.Mode) !void { - // For restore mode we have to restore but if we set it, we - // always have to call setMode because setting some modes have - // side effects and we want to make sure we process those. - const v = self.terminal.modes.restore(mode); - // log.debug("restore mode={} v={}", .{ mode, v }); - try self.setMode(mode, v); + fn requestModeUnknown(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { + var msg: termio.Message = .{ .write_small = .{} }; + const resp = try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B[{s}{};0$y", + .{ + if (ansi) "" else "?", + mode_raw, + }, + ); + msg.write_small.len = @intCast(resp.len); + self.messageWriter(msg); } pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void { @@ -570,10 +682,7 @@ pub const StreamHandler = struct { .autorepeat => {}, // Schedule a render since we changed colors - .reverse_colors => { - self.terminal.flags.dirty.reverse_colors = true; - try self.queueRender(); - }, + .reverse_colors => self.terminal.flags.dirty.reverse_colors = true, // Origin resets cursor pos. This is called whether or not // we're enabling or disabling origin mode and whether or @@ -588,18 +697,15 @@ pub const StreamHandler = struct { }, .alt_screen_legacy => { - self.terminal.switchScreenMode(.@"47", enabled); - try self.queueRender(); + try self.terminal.switchScreenMode(.@"47", enabled); }, .alt_screen => { - self.terminal.switchScreenMode(.@"1047", enabled); - try self.queueRender(); + try self.terminal.switchScreenMode(.@"1047", enabled); }, .alt_screen_save_cursor_clear_enter => { - self.terminal.switchScreenMode(.@"1049", enabled); - try self.queueRender(); + try self.terminal.switchScreenMode(.@"1049", enabled); }, // Mode 1048 is xterm's conditional save cursor depending @@ -635,7 +741,6 @@ pub const StreamHandler = struct { // forever. .synchronized_output => { if (enabled) self.messageWriter(.{ .start_synchronized_output = {} }); - try self.queueRender(); }, .linefeed => { @@ -696,34 +801,18 @@ pub const StreamHandler = struct { } } - pub inline fn setMouseShiftCapture(self: *StreamHandler, v: bool) !void { - self.terminal.flags.mouse_shift_capture = if (v) .true else .false; - } - - pub inline fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void { - switch (attr) { - .unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}), - - else => self.terminal.setAttribute(attr) catch |err| - log.warn("error setting attribute {}: {}", .{ attr, err }), - } - } - - pub inline fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { - try self.terminal.screen.startHyperlink(uri, id); + inline fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { + try self.terminal.screens.active.startHyperlink(uri, id); } pub inline fn endHyperlink(self: *StreamHandler) !void { - self.terminal.screen.endHyperlink(); + self.terminal.screens.active.endHyperlink(); } pub fn deviceAttributes( self: *StreamHandler, req: terminal.DeviceAttributeReq, - params: []const u16, ) !void { - _ = params; - // For the below, we quack as a VT220. We don't quack as // a 420 because we don't support DCS sequences. switch (req) { @@ -757,11 +846,11 @@ pub const StreamHandler = struct { x: usize, y: usize, } = if (self.terminal.modes.get(.origin)) .{ - .x = self.terminal.screen.cursor.x -| self.terminal.scrolling_region.left, - .y = self.terminal.screen.cursor.y -| self.terminal.scrolling_region.top, + .x = self.terminal.screens.active.cursor.x -| self.terminal.scrolling_region.left, + .y = self.terminal.screens.active.cursor.y -| self.terminal.scrolling_region.top, } else .{ - .x = self.terminal.screen.cursor.x, - .y = self.terminal.screen.cursor.y, + .x = self.terminal.screens.active.cursor.x, + .y = self.terminal.screens.active.cursor.y, }; // Response always is at least 4 chars, so this leaves the @@ -791,7 +880,7 @@ pub const StreamHandler = struct { switch (style) { .default => { self.default_cursor = true; - self.terminal.screen.cursor.cursor_style = self.default_cursor_style; + self.terminal.screens.active.cursor.cursor_style = self.default_cursor_style; self.terminal.modes.set( .cursor_blinking, self.default_cursor_blink orelse true, @@ -799,59 +888,41 @@ pub const StreamHandler = struct { }, .blinking_block => { - self.terminal.screen.cursor.cursor_style = .block; + self.terminal.screens.active.cursor.cursor_style = .block; self.terminal.modes.set(.cursor_blinking, true); }, .steady_block => { - self.terminal.screen.cursor.cursor_style = .block; + self.terminal.screens.active.cursor.cursor_style = .block; self.terminal.modes.set(.cursor_blinking, false); }, .blinking_underline => { - self.terminal.screen.cursor.cursor_style = .underline; + self.terminal.screens.active.cursor.cursor_style = .underline; self.terminal.modes.set(.cursor_blinking, true); }, .steady_underline => { - self.terminal.screen.cursor.cursor_style = .underline; + self.terminal.screens.active.cursor.cursor_style = .underline; self.terminal.modes.set(.cursor_blinking, false); }, .blinking_bar => { - self.terminal.screen.cursor.cursor_style = .bar; + self.terminal.screens.active.cursor.cursor_style = .bar; self.terminal.modes.set(.cursor_blinking, true); }, .steady_bar => { - self.terminal.screen.cursor.cursor_style = .bar; + self.terminal.screens.active.cursor.cursor_style = .bar; self.terminal.modes.set(.cursor_blinking, false); }, - - else => log.warn("unimplemented cursor style: {}", .{style}), } } - pub inline fn setProtectedMode(self: *StreamHandler, mode: terminal.ProtectedMode) !void { - self.terminal.setProtectedMode(mode); - } - pub inline fn decaln(self: *StreamHandler) !void { try self.terminal.decaln(); } - pub inline fn tabClear(self: *StreamHandler, cmd: terminal.TabClear) !void { - self.terminal.tabClear(cmd); - } - - pub inline fn tabSet(self: *StreamHandler) !void { - self.terminal.tabSet(); - } - - pub inline fn tabReset(self: *StreamHandler) !void { - self.terminal.tabReset(); - } - pub inline fn saveCursor(self: *StreamHandler) !void { self.terminal.saveCursor(); } @@ -865,38 +936,14 @@ pub const StreamHandler = struct { self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response)); } - pub inline fn scrollDown(self: *StreamHandler, count: usize) !void { - self.terminal.scrollDown(count); - } - - pub inline fn scrollUp(self: *StreamHandler, count: usize) !void { - self.terminal.scrollUp(count); - } - - pub fn setActiveStatusDisplay( - self: *StreamHandler, - req: terminal.StatusDisplay, - ) !void { - self.terminal.status_display = req; - } - - pub fn configureCharset( + fn configureCharset( self: *StreamHandler, slot: terminal.CharsetSlot, set: terminal.Charset, - ) !void { + ) void { self.terminal.configureCharset(slot, set); } - pub fn invokeCharset( - self: *StreamHandler, - active: terminal.CharsetActiveSlot, - slot: terminal.CharsetSlot, - single: bool, - ) !void { - self.terminal.invokeCharset(active, slot, single); - } - pub fn fullReset( self: *StreamHandler, ) !void { @@ -911,7 +958,7 @@ pub const StreamHandler = struct { log.debug("querying kitty keyboard mode", .{}); var data: termio.Message.WriteReq.Small.Array = undefined; const resp = try std.fmt.bufPrint(&data, "\x1b[?{}u", .{ - self.terminal.screen.kitty_keyboard.current().int(), + self.terminal.screens.active.kitty_keyboard.current().int(), }); self.messageWriter(.{ @@ -922,28 +969,6 @@ pub const StreamHandler = struct { }); } - pub fn pushKittyKeyboard( - self: *StreamHandler, - flags: terminal.kitty.KeyFlags, - ) !void { - log.debug("pushing kitty keyboard mode: {}", .{flags}); - self.terminal.screen.kitty_keyboard.push(flags); - } - - pub fn popKittyKeyboard(self: *StreamHandler, n: u16) !void { - log.debug("popping kitty keyboard mode n={}", .{n}); - self.terminal.screen.kitty_keyboard.pop(@intCast(n)); - } - - pub fn setKittyKeyboard( - self: *StreamHandler, - mode: terminal.kitty.KeySetMode, - flags: terminal.kitty.KeyFlags, - ) !void { - log.debug("setting kitty keyboard mode: {} {}", .{ mode, flags }); - self.terminal.screen.kitty_keyboard.set(mode, flags); - } - pub fn reportXtversion( self: *StreamHandler, ) !void { @@ -964,7 +989,7 @@ pub const StreamHandler = struct { //------------------------------------------------------------------------- // OSC - pub fn changeWindowTitle(self: *StreamHandler, title: []const u8) !void { + fn windowTitle(self: *StreamHandler, title: []const u8) !void { var buf: [256]u8 = undefined; if (title.len >= buf.len) { log.warn("change title requested larger than our buffer size, ignoring", .{}); @@ -995,7 +1020,7 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .set_title = buf }); } - pub inline fn setMouseShape( + inline fn setMouseShape( self: *StreamHandler, shape: terminal.MouseShape, ) !void { @@ -1007,7 +1032,7 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .set_mouse_shape = shape }); } - pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { + fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { // Note: we ignore the "kind" field and always use the standard clipboard. // iTerm also appears to do this but other terminals seem to only allow // certain. Let's investigate more. @@ -1037,13 +1062,13 @@ pub const StreamHandler = struct { }); } - pub inline fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) !void { + inline fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) void { _ = aid; self.terminal.markSemanticPrompt(.prompt); self.terminal.flags.shell_redraws_prompt = redraw; } - pub inline fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) !void { + inline fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) void { _ = aid; self.terminal.markSemanticPrompt(.prompt_continuation); } @@ -1057,11 +1082,11 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.start_command); } - pub inline fn endOfCommand(self: *StreamHandler, exit_code: ?u8) !void { + inline fn endOfCommand(self: *StreamHandler, exit_code: ?u8) void { self.surfaceMessageWriter(.{ .stop_command = exit_code }); } - pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { + fn reportPwd(self: *StreamHandler, url: []const u8) !void { // Special handling for the empty URL. We treat the empty URL // as resetting the pwd as if we never saw a pwd. I can't find any // other terminal that does this but it seems like a reasonable @@ -1075,7 +1100,7 @@ pub const StreamHandler = struct { // If we haven't seen a title, we're using the pwd as our title. // Set it to blank which will reset our title behavior. if (!self.seen_title) { - try self.changeWindowTitle(""); + try self.windowTitle(""); assert(!self.seen_title); } @@ -1089,7 +1114,13 @@ pub const StreamHandler = struct { return; } - const uri: std.Uri = internal_os.hostname.parseUrl(url) catch |e| { + // Attempt to parse this file-style URI using options appropriate + // for this OSC 7 context (e.g. kitty-shell-cwd expects the full, + // unencoded path). + const uri: std.Uri = internal_os.uri.parse(url, .{ + .mac_address = comptime builtin.os.tag != .macos, + .raw_path = std.mem.startsWith(u8, url, "kitty-shell-cwd://"), + }) catch |e| { log.warn("invalid url in OSC 7: {}", .{e}); return; }; @@ -1097,26 +1128,18 @@ pub const StreamHandler = struct { if (!std.mem.eql(u8, "file", uri.scheme) and !std.mem.eql(u8, "kitty-shell-cwd", uri.scheme)) { - log.warn("OSC 7 scheme must be file, got: {s}", .{uri.scheme}); + log.warn("OSC 7 scheme must be file or kitty-shell-cwd, got: {s}", .{uri.scheme}); return; } - // RFC 793 defines port numbers as 16-bit numbers. 5 digits is sufficient to represent - // the maximum since 2^16 - 1 = 65_535. - // See https://www.rfc-editor.org/rfc/rfc793#section-3.1. - const PORT_NUMBER_MAX_DIGITS = 5; - // Make sure there is space for a max length hostname + the max number of digits. - var host_and_port_buf: [posix.HOST_NAME_MAX + PORT_NUMBER_MAX_DIGITS]u8 = undefined; - const hostname_from_uri = internal_os.hostname.bufPrintHostnameFromFileUri( - &host_and_port_buf, - uri, - ) catch |err| switch (err) { - error.NoHostnameInUri => { + var host_buffer: [std.Uri.host_name_max]u8 = undefined; + const host = uri.getHost(&host_buffer) catch |err| switch (err) { + error.UriMissingHost => { log.warn("OSC 7 uri must contain a hostname: {}", .{err}); return; }, - error.NoSpaceLeft => |e| { - log.warn("failed to get full hostname for OSC 7 validation: {}", .{e}); + error.UriHostTooLong => { + log.warn("failed to get full hostname for OSC 7 validation: {}", .{err}); return; }, }; @@ -1124,9 +1147,7 @@ pub const StreamHandler = struct { // OSC 7 is a little sketchy because anyone can send any value from // any host (such an SSH session). The best practice terminals follow // is to valid the hostname to be local. - const host_valid = internal_os.hostname.isLocalHostname( - hostname_from_uri, - ) catch |err| switch (err) { + const host_valid = internal_os.hostname.isLocal(host) catch |err| switch (err) { error.PermissionDenied, error.Unexpected, => { @@ -1135,43 +1156,16 @@ pub const StreamHandler = struct { }, }; if (!host_valid) { - log.warn("OSC 7 host must be local", .{}); + log.warn("OSC 7 host ({s}) must be local", .{host}); return; } - // We need to unescape the path. We first try to unescape onto - // the stack and fall back to heap allocation if we have to. - var path_buf: [1024]u8 = undefined; - const path, const heap = path: { - // Get the raw string of the URI. Its unclear to me if the various - // tags of this enum guarantee no percent-encoding so we just - // check all of it. This isn't a performance critical path. - const path = switch (uri.path) { - .raw => |v| v, - .percent_encoded => |v| v, - }; - - // If the path doesn't have any escapes, we can use it directly. - if (std.mem.indexOfScalar(u8, path, '%') == null) - break :path .{ path, false }; - - // First try to stack-allocate - var stack_writer: std.Io.Writer = .fixed(&path_buf); - if (uri.path.formatRaw(&stack_writer)) |_| { - break :path .{ stack_writer.buffered(), false }; - } else |_| {} - - // Fall back to heap - var alloc_writer: std.Io.Writer.Allocating = .init(self.alloc); - if (uri.path.formatRaw(&alloc_writer.writer)) |_| { - break :path .{ alloc_writer.written(), true }; - } else |_| {} - - // Fall back to using it directly... - log.warn("failed to unescape OSC 7 path, using it directly path={s}", .{path}); - break :path .{ path, false }; - }; - defer if (heap) self.alloc.free(path); + // We need the raw path, which might require unescaping. We try to + // avoid making any heap allocations by using the stack first. + var arena_alloc: std.heap.ArenaAllocator = .init(self.alloc); + var stack_alloc = std.heap.stackFallback(1024, arena_alloc.allocator()); + defer arena_alloc.deinit(); + const path = try uri.path.toRawMaybeAlloc(stack_alloc.get()); log.debug("terminal pwd: {s}", .{path}); try self.terminal.setPwd(path); @@ -1186,12 +1180,12 @@ pub const StreamHandler = struct { // If we haven't seen a title, use our pwd as the title. if (!self.seen_title) { - try self.changeWindowTitle(path); + try self.windowTitle(path); self.seen_title = false; } } - pub fn handleColorOperation( + fn colorOperation( self: *StreamHandler, op: terminal.osc.color.Operation, requests: *const terminal.osc.color.List, @@ -1217,28 +1211,12 @@ pub const StreamHandler = struct { switch (set.target) { .palette => |i| { self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = set.color; - self.terminal.color_palette.mask.set(i); + self.terminal.colors.palette.set(i, set.color); }, .dynamic => |dynamic| switch (dynamic) { - .foreground => { - self.foreground_color = set.color; - _ = self.renderer_mailbox.push(.{ - .foreground_color = set.color, - }, .{ .forever = {} }); - }, - .background => { - self.background_color = set.color; - _ = self.renderer_mailbox.push(.{ - .background_color = set.color, - }, .{ .forever = {} }); - }, - .cursor => { - self.cursor_color = set.color; - _ = self.renderer_mailbox.push(.{ - .cursor_color = set.color, - }, .{ .forever = {} }); - }, + .foreground => self.terminal.colors.foreground.set(set.color), + .background => self.terminal.colors.background.set(set.color), + .cursor => self.terminal.colors.cursor.set(set.color), .pointer_foreground, .pointer_background, .tektronix_foreground, @@ -1262,52 +1240,44 @@ pub const StreamHandler = struct { .reset => |target| switch (target) { .palette => |i| { - const mask = &self.terminal.color_palette.mask; self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); + self.terminal.colors.palette.reset(i); self.surfaceMessageWriter(.{ .color_change = .{ .target = target, - .color = self.terminal.color_palette.colors[i], + .color = self.terminal.colors.palette.current[i], }, }); }, .dynamic => |dynamic| switch (dynamic) { .foreground => { - self.foreground_color = null; - _ = self.renderer_mailbox.push(.{ - .foreground_color = self.foreground_color, - }, .{ .forever = {} }); + self.terminal.colors.foreground.reset(); - self.surfaceMessageWriter(.{ .color_change = .{ - .target = target, - .color = self.default_foreground_color, - } }); - }, - .background => { - self.background_color = null; - _ = self.renderer_mailbox.push(.{ - .background_color = self.background_color, - }, .{ .forever = {} }); - - self.surfaceMessageWriter(.{ .color_change = .{ - .target = target, - .color = self.default_background_color, - } }); - }, - .cursor => { - self.cursor_color = null; - - _ = self.renderer_mailbox.push(.{ - .cursor_color = self.cursor_color, - }, .{ .forever = {} }); - - if (self.default_cursor_color) |color| { + if (self.terminal.colors.foreground.default) |c| { self.surfaceMessageWriter(.{ .color_change = .{ .target = target, - .color = color, + .color = c, + } }); + } + }, + .background => { + self.terminal.colors.background.reset(); + + if (self.terminal.colors.background.default) |c| { + self.surfaceMessageWriter(.{ .color_change = .{ + .target = target, + .color = c, + } }); + } + }, + .cursor => { + self.terminal.colors.cursor.reset(); + + if (self.terminal.colors.cursor.default) |c| { + self.surfaceMessageWriter(.{ .color_change = .{ + .target = target, + .color = c, } }); } }, @@ -1326,15 +1296,15 @@ pub const StreamHandler = struct { }, .reset_palette => { - const mask = &self.terminal.color_palette.mask; - var mask_iterator = mask.iterator(.{}); - while (mask_iterator.next()) |i| { + const mask = &self.terminal.colors.palette.mask; + var mask_it = mask.iterator(.{}); + while (mask_it.next()) |i| { self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + self.terminal.colors.palette.reset(@intCast(i)); self.surfaceMessageWriter(.{ .color_change = .{ .target = .{ .palette = @intCast(i) }, - .color = self.terminal.color_palette.colors[i], + .color = self.terminal.colors.palette.current[i], }, }); } @@ -1350,14 +1320,12 @@ pub const StreamHandler = struct { if (self.osc_color_report_format == .none) break :report; const color = switch (kind) { - .palette => |i| self.terminal.color_palette.colors[i], + .palette => |i| self.terminal.colors.palette.current[i], .dynamic => |dynamic| switch (dynamic) { - .foreground => self.foreground_color orelse self.default_foreground_color, - .background => self.background_color orelse self.default_background_color, - .cursor => self.cursor_color orelse - self.default_cursor_color orelse - self.foreground_color orelse - self.default_foreground_color, + .foreground => self.terminal.colors.foreground.get().?, + .background => self.terminal.colors.background.get().?, + .cursor => self.terminal.colors.cursor.get() orelse + self.terminal.colors.foreground.get().?, .pointer_foreground, .pointer_background, .tektronix_foreground, @@ -1440,7 +1408,7 @@ pub const StreamHandler = struct { } } - pub fn showDesktopNotification( + fn showDesktopNotification( self: *StreamHandler, title: []const u8, body: []const u8, @@ -1468,7 +1436,7 @@ pub const StreamHandler = struct { } } - pub fn sendKittyColorReport( + fn kittyColorReport( self: *StreamHandler, request: terminal.kitty.color.OSC, ) !void { @@ -1483,11 +1451,11 @@ pub const StreamHandler = struct { if (stream.written().len == 0) try writer.writeAll("\x1b]21"); const color: terminal.color.RGB = switch (key) { - .palette => |palette| self.terminal.color_palette.colors[palette], + .palette => |palette| self.terminal.colors.palette.current[palette], .special => |special| switch (special) { - .foreground => self.foreground_color orelse self.default_foreground_color, - .background => self.background_color orelse self.default_background_color, - .cursor => self.cursor_color orelse self.default_cursor_color, + .foreground => self.terminal.colors.foreground.get(), + .background => self.terminal.colors.background.get(), + .cursor => self.terminal.colors.cursor.get(), else => { log.warn("ignoring unsupported kitty color protocol key: {f}", .{key}); continue; @@ -1506,71 +1474,39 @@ pub const StreamHandler = struct { .set => |v| switch (v.key) { .palette => |palette| { self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[palette] = v.color; - self.terminal.color_palette.mask.unset(palette); + self.terminal.colors.palette.set(palette, v.color); }, - .special => |special| { - const msg: renderer.Message = switch (special) { - .foreground => msg: { - self.foreground_color = v.color; - break :msg .{ .foreground_color = v.color }; - }, - .background => msg: { - self.background_color = v.color; - break :msg .{ .background_color = v.color }; - }, - .cursor => msg: { - self.cursor_color = v.color; - break :msg .{ .cursor_color = v.color }; - }, - else => { - log.warn( - "ignoring unsupported kitty color protocol key: {f}", - .{v.key}, - ); - continue; - }, - }; - - // See messageWriter which has similar logic and - // explains why we may have to do this. - self.rendererMessageWriter(msg); + .special => |special| switch (special) { + .foreground => self.terminal.colors.foreground.set(v.color), + .background => self.terminal.colors.background.set(v.color), + .cursor => self.terminal.colors.cursor.set(v.color), + else => { + log.warn( + "ignoring unsupported kitty color protocol key: {f}", + .{v.key}, + ); + continue; + }, }, }, .reset => |key| switch (key) { .palette => |palette| { self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[palette] = self.terminal.default_palette[palette]; - self.terminal.color_palette.mask.unset(palette); + self.terminal.colors.palette.reset(palette); }, - .special => |special| { - const msg: renderer.Message = switch (special) { - .foreground => msg: { - self.foreground_color = null; - break :msg .{ .foreground_color = self.foreground_color }; - }, - .background => msg: { - self.background_color = null; - break :msg .{ .background_color = self.background_color }; - }, - .cursor => msg: { - self.cursor_color = null; - break :msg .{ .cursor_color = self.cursor_color }; - }, - else => { - log.warn( - "ignoring unsupported kitty color protocol key: {f}", - .{key}, - ); - continue; - }, - }; - - // See messageWriter which has similar logic and - // explains why we may have to do this. - self.rendererMessageWriter(msg); + .special => |special| switch (special) { + .foreground => self.terminal.colors.foreground.reset(), + .background => self.terminal.colors.background.reset(), + .cursor => self.terminal.colors.cursor.reset(), + else => { + log.warn( + "ignoring unsupported kitty color protocol key: {f}", + .{key}, + ); + continue; + }, }, }, } @@ -1593,7 +1529,7 @@ pub const StreamHandler = struct { } /// Display a GUI progress report. - pub fn handleProgressReport(self: *StreamHandler, report: terminal.osc.Command.ProgressReport) error{}!void { + fn progressReport(self: *StreamHandler, report: terminal.osc.Command.ProgressReport) void { self.surfaceMessageWriter(.{ .progress_report = report }); } }; diff --git a/src/unicode/grapheme.zig b/src/unicode/grapheme.zig index 2311bbeec..47be43bb0 100644 --- a/src/unicode/grapheme.zig +++ b/src/unicode/grapheme.zig @@ -1,6 +1,6 @@ const std = @import("std"); const table = @import("props_table.zig").table; -const GraphemeBoundaryClass = @import("Properties.zig").GraphemeBoundaryClass; +const GraphemeBoundaryClass = @import("props.zig").GraphemeBoundaryClass; /// Determines if there is a grapheme break between two codepoints. This /// must be called sequentially maintaining the state between calls. diff --git a/src/unicode/main.zig b/src/unicode/main.zig index cb2fb567f..427c65614 100644 --- a/src/unicode/main.zig +++ b/src/unicode/main.zig @@ -2,7 +2,7 @@ pub const lut = @import("lut.zig"); const grapheme = @import("grapheme.zig"); pub const table = @import("props_table.zig").table; -pub const Properties = @import("Properties.zig"); +pub const Properties = @import("props.zig").Properties; pub const graphemeBreak = grapheme.graphemeBreak; pub const GraphemeBreakState = grapheme.BreakState; diff --git a/src/unicode/Properties.zig b/src/unicode/props.zig similarity index 53% rename from src/unicode/Properties.zig rename to src/unicode/props.zig index c8c4a581c..492dad34a 100644 --- a/src/unicode/Properties.zig +++ b/src/unicode/props.zig @@ -3,39 +3,46 @@ //! Adding to this lets you find new properties but also potentially makes //! our lookup tables less efficient. Any changes to this should run the //! benchmarks in src/bench to verify that we haven't regressed. -const Properties = @This(); const std = @import("std"); -/// Codepoint width. We clamp to [0, 2] since Ghostty handles control -/// characters and we max out at 2 for wide characters (i.e. 3-em dash -/// becomes a 2-em dash). -width: u2 = 0, +pub const Properties = packed struct { + /// Codepoint width. We clamp to [0, 2] since Ghostty handles control + /// characters and we max out at 2 for wide characters (i.e. 3-em dash + /// becomes a 2-em dash). + width: u2 = 0, -/// Grapheme boundary class. -grapheme_boundary_class: GraphemeBoundaryClass = .invalid, + /// Grapheme boundary class. + grapheme_boundary_class: GraphemeBoundaryClass = .invalid, -// 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; -} + /// Emoji VS compatibility + emoji_vs_base: bool = false, -// Needed for lut.Generator -pub fn format( - self: Properties, - writer: *std.Io.Writer, -) !void { - try writer.print( - \\.{{ - \\ .width= {}, - \\ .grapheme_boundary_class= .{s}, - \\}} - , .{ - self.width, - @tagName(self.grapheme_boundary_class), - }); -} + // 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_base == b.emoji_vs_base; + } + + // Needed for lut.Generator + pub fn format( + self: Properties, + writer: *std.Io.Writer, + ) !void { + try writer.print( + \\.{{ + \\ .width= {}, + \\ .grapheme_boundary_class= .{s}, + \\ .emoji_vs_base= {}, + \\}} + , .{ + self.width, + @tagName(self.grapheme_boundary_class), + self.emoji_vs_base, + }); + } +}; /// Possible grapheme boundary classes. This isn't an exhaustive list: /// we omit control, CR, LF, etc. because in Ghostty's usage that are diff --git a/src/unicode/props_table.zig b/src/unicode/props_table.zig index d168fbb9c..dac7e6f5e 100644 --- a/src/unicode/props_table.zig +++ b/src/unicode/props_table.zig @@ -1,4 +1,4 @@ -const Properties = @import("Properties.zig"); +const Properties = @import("props.zig").Properties; const lut = @import("lut.zig"); /// The lookup tables for Ghostty. diff --git a/src/unicode/props_uucode.zig b/src/unicode/props_uucode.zig index 6aed7d7d5..2440d437c 100644 --- a/src/unicode/props_uucode.zig +++ b/src/unicode/props_uucode.zig @@ -3,19 +3,14 @@ const std = @import("std"); const assert = std.debug.assert; const uucode = @import("uucode"); const lut = @import("lut.zig"); -const Properties = @import("Properties.zig"); -const GraphemeBoundaryClass = Properties.GraphemeBoundaryClass; +const Properties = @import("props.zig").Properties; +const GraphemeBoundaryClass = @import("props.zig").GraphemeBoundaryClass; /// Gets the grapheme boundary class for a codepoint. /// The use case for this is only in generating lookup tables. 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, @@ -48,14 +45,16 @@ fn graphemeBoundaryClass(cp: u21) GraphemeBoundaryClass { } pub fn get(cp: u21) Properties { - const width = if (cp > uucode.config.max_code_point) - 1 - else - uucode.get(.width, cp); + if (cp > uucode.config.max_code_point) return .{ + .width = 1, + .grapheme_boundary_class = .invalid, + .emoji_vs_base = false, + }; return .{ - .width = width, + .width = uucode.get(.width, cp), .grapheme_boundary_class = graphemeBoundaryClass(cp), + .emoji_vs_base = uucode.get(.is_emoji_vs_base, cp), }; } diff --git a/test/ucs-detect.sh b/test/ucs-detect.sh new file mode 100755 index 000000000..5cffd0520 --- /dev/null +++ b/test/ucs-detect.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +# This runs ucs-detect with the same settings consistently so we can +# compare our results over time. This is based on: +# https://github.com/jquast/ucs-detect/blob/2958b7766783c92b3aad6a55e1e752cbe07ccaf3/data/ghostty.yaml +ucs-detect \ + --limit-codepoints=5000 \ + --limit-words=5000 \ + --limit-errors=1000 \ + --stream=stderr diff --git a/typos.toml b/typos.toml index 5a23527d9..26876aef9 100644 --- a/typos.toml +++ b/typos.toml @@ -5,6 +5,9 @@ extend-exclude = [ "build.zig.zon.nix", "build.zig.zon.txt", "build.zig.zon.json", + # Build artifacts + "macos/build/*", + "zig-out/*", # vendored code "vendor/*", "pkg/*", @@ -55,6 +58,10 @@ typ = "typ" kend = "kend" # GTK GIR = "GIR" +# terminfo +rin = "rin" +# sprites +ower = "ower" [type.po] extend-glob = ["*.po"]