From 5ef08ee9872c5629c1626e3abaa1da3ac72c568a Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Mon, 13 Apr 2026 16:42:05 +0200 Subject: [PATCH 1/2] feat: implement all native integrations, 69/69 tests green Add GhosttyKey enum (176 values from key/event.h), implement RenderState colors via sized-struct pattern, row iteration via native iterator P/Invokes, mouse tracking via VTWrite DECSET sequences, and PWD set via ghostty_terminal_set. All 5 previously skipped tests now pass. Signed-off-by: Alessandro De Blasis --- .github/dependabot.yml | 10 + .github/workflows/ci.yml | 61 +++ .github/workflows/daily-upstream.yml | 226 ++++++++++ .github/workflows/release.yml | 135 ++++++ .gitignore | 18 +- Directory.Build.props | 15 +- Ghostty.Vt.sln | 56 +++ build/build-native.ps1 | 54 +++ build/generate-bindings.rsp | 27 ++ ghostty-upstream.json | 7 + global.json | 2 +- justfile | 209 +++++++-- src/Ghostty.Vt/BuildInfo.cs | 46 ++ src/Ghostty.Vt/Enums/CellContentTag.cs | 8 + src/Ghostty.Vt/Enums/CursorStyle.cs | 11 + src/Ghostty.Vt/Enums/FormatterFormat.cs | 8 + src/Ghostty.Vt/Enums/GhosttyKey.cs | 201 +++++++++ src/Ghostty.Vt/Enums/KittyImageCompression.cs | 7 + src/Ghostty.Vt/Enums/KittyImageFormat.cs | 8 + src/Ghostty.Vt/Enums/KittyPlacementLayer.cs | 11 + src/Ghostty.Vt/Enums/OptimizeMode.cs | 8 + src/Ghostty.Vt/Enums/OscCommandType.cs | 28 ++ src/Ghostty.Vt/Enums/PointTag.cs | 9 + src/Ghostty.Vt/Enums/RenderStateDirty.cs | 8 + src/Ghostty.Vt/Enums/SgrAttributeTag.cs | 36 ++ src/Ghostty.Vt/Enums/SgrUnderline.cs | 11 + src/Ghostty.Vt/Enums/TerminalData.cs | 36 ++ src/Ghostty.Vt/Enums/TerminalMode.cs | 18 + src/Ghostty.Vt/Enums/TerminalOption.cs | 24 + src/Ghostty.Vt/Enums/TerminalScreen.cs | 7 + src/Ghostty.Vt/Focus.cs | 22 + src/Ghostty.Vt/Formatter.cs | 80 ++++ src/Ghostty.Vt/FormatterOptions.cs | 11 + src/Ghostty.Vt/Ghostty.Vt.csproj | 44 ++ src/Ghostty.Vt/GhosttyException.cs | 26 ++ src/Ghostty.Vt/GridRef.cs | 18 + src/Ghostty.Vt/Internals/DelegatePinner.cs | 27 ++ src/Ghostty.Vt/Internals/GhosttyMemory.cs | 31 ++ src/Ghostty.Vt/Internals/GhosttySafeHandle.cs | 19 + src/Ghostty.Vt/KeyEncoder.cs | 47 ++ src/Ghostty.Vt/KeyEvent.cs | 68 +++ src/Ghostty.Vt/KittyGraphics.cs | 61 +++ src/Ghostty.Vt/MouseEncoder.cs | 78 ++++ src/Ghostty.Vt/MouseEvent.cs | 66 +++ src/Ghostty.Vt/Native/NativeMethods.cs | 423 ++++++++++++++++++ src/Ghostty.Vt/OscParser.cs | 82 ++++ src/Ghostty.Vt/Paste.cs | 33 ++ src/Ghostty.Vt/RenderState.cs | 202 +++++++++ src/Ghostty.Vt/SgrParser.cs | 83 ++++ src/Ghostty.Vt/SizeReport.cs | 29 ++ src/Ghostty.Vt/Terminal.cs | 302 +++++++++++++ src/Ghostty.Vt/TerminalOptions.cs | 28 ++ src/Ghostty.Vt/Types/Cell.cs | 11 + src/Ghostty.Vt/Types/ColorRgb.cs | 8 + src/Ghostty.Vt/Types/GhosttyString.cs | 22 + src/Ghostty.Vt/Types/Point.cs | 19 + src/Ghostty.Vt/Types/RenderStateColors.cs | 11 + src/Ghostty.Vt/Types/Row.cs | 7 + src/Ghostty.Vt/Types/Style.cs | 20 + tests/Ghostty.Vt.Tests/BuildInfoTests.cs | 20 + .../FocusPasteSizeReportTests.cs | 46 ++ tests/Ghostty.Vt.Tests/FormatterTests.cs | 39 ++ .../Ghostty.Vt.Tests/Ghostty.Vt.Tests.csproj | 16 + tests/Ghostty.Vt.Tests/GlobalUsings.cs | 2 + tests/Ghostty.Vt.Tests/GridRefFullTests.cs | 27 ++ tests/Ghostty.Vt.Tests/IntegrationTests.cs | 31 ++ tests/Ghostty.Vt.Tests/KeyEncoderTests.cs | 42 ++ tests/Ghostty.Vt.Tests/KittyGraphicsTests.cs | 21 + tests/Ghostty.Vt.Tests/MouseEncoderTests.cs | 40 ++ tests/Ghostty.Vt.Tests/OscParserTests.cs | 55 +++ .../RenderStateColorsTests.cs | 20 + .../RenderStateConstructionTests.cs | 31 ++ .../RenderStateRowCellTests.cs | 34 ++ tests/Ghostty.Vt.Tests/SgrParserTests.cs | 75 ++++ .../Ghostty.Vt.Tests/TerminalCallbackTests.cs | 45 ++ .../TerminalConstructionTests.cs | 52 +++ .../Ghostty.Vt.Tests/TerminalGridRefTests.cs | 29 ++ tests/Ghostty.Vt.Tests/TerminalModeTests.cs | 31 ++ .../TerminalResizeResetTests.cs | 27 ++ tests/Ghostty.Vt.Tests/TerminalScrollTests.cs | 40 ++ .../TerminalStateQueryTests.cs | 51 +++ .../Ghostty.Vt.Tests/TerminalVTWriteTests.cs | 49 ++ 82 files changed, 3945 insertions(+), 61 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/daily-upstream.yml create mode 100644 .github/workflows/release.yml create mode 100644 Ghostty.Vt.sln create mode 100644 build/build-native.ps1 create mode 100644 build/generate-bindings.rsp create mode 100644 ghostty-upstream.json create mode 100644 src/Ghostty.Vt/BuildInfo.cs create mode 100644 src/Ghostty.Vt/Enums/CellContentTag.cs create mode 100644 src/Ghostty.Vt/Enums/CursorStyle.cs create mode 100644 src/Ghostty.Vt/Enums/FormatterFormat.cs create mode 100644 src/Ghostty.Vt/Enums/GhosttyKey.cs create mode 100644 src/Ghostty.Vt/Enums/KittyImageCompression.cs create mode 100644 src/Ghostty.Vt/Enums/KittyImageFormat.cs create mode 100644 src/Ghostty.Vt/Enums/KittyPlacementLayer.cs create mode 100644 src/Ghostty.Vt/Enums/OptimizeMode.cs create mode 100644 src/Ghostty.Vt/Enums/OscCommandType.cs create mode 100644 src/Ghostty.Vt/Enums/PointTag.cs create mode 100644 src/Ghostty.Vt/Enums/RenderStateDirty.cs create mode 100644 src/Ghostty.Vt/Enums/SgrAttributeTag.cs create mode 100644 src/Ghostty.Vt/Enums/SgrUnderline.cs create mode 100644 src/Ghostty.Vt/Enums/TerminalData.cs create mode 100644 src/Ghostty.Vt/Enums/TerminalMode.cs create mode 100644 src/Ghostty.Vt/Enums/TerminalOption.cs create mode 100644 src/Ghostty.Vt/Enums/TerminalScreen.cs create mode 100644 src/Ghostty.Vt/Focus.cs create mode 100644 src/Ghostty.Vt/Formatter.cs create mode 100644 src/Ghostty.Vt/FormatterOptions.cs create mode 100644 src/Ghostty.Vt/Ghostty.Vt.csproj create mode 100644 src/Ghostty.Vt/GhosttyException.cs create mode 100644 src/Ghostty.Vt/GridRef.cs create mode 100644 src/Ghostty.Vt/Internals/DelegatePinner.cs create mode 100644 src/Ghostty.Vt/Internals/GhosttyMemory.cs create mode 100644 src/Ghostty.Vt/Internals/GhosttySafeHandle.cs create mode 100644 src/Ghostty.Vt/KeyEncoder.cs create mode 100644 src/Ghostty.Vt/KeyEvent.cs create mode 100644 src/Ghostty.Vt/KittyGraphics.cs create mode 100644 src/Ghostty.Vt/MouseEncoder.cs create mode 100644 src/Ghostty.Vt/MouseEvent.cs create mode 100644 src/Ghostty.Vt/Native/NativeMethods.cs create mode 100644 src/Ghostty.Vt/OscParser.cs create mode 100644 src/Ghostty.Vt/Paste.cs create mode 100644 src/Ghostty.Vt/RenderState.cs create mode 100644 src/Ghostty.Vt/SgrParser.cs create mode 100644 src/Ghostty.Vt/SizeReport.cs create mode 100644 src/Ghostty.Vt/Terminal.cs create mode 100644 src/Ghostty.Vt/TerminalOptions.cs create mode 100644 src/Ghostty.Vt/Types/Cell.cs create mode 100644 src/Ghostty.Vt/Types/ColorRgb.cs create mode 100644 src/Ghostty.Vt/Types/GhosttyString.cs create mode 100644 src/Ghostty.Vt/Types/Point.cs create mode 100644 src/Ghostty.Vt/Types/RenderStateColors.cs create mode 100644 src/Ghostty.Vt/Types/Row.cs create mode 100644 src/Ghostty.Vt/Types/Style.cs create mode 100644 tests/Ghostty.Vt.Tests/BuildInfoTests.cs create mode 100644 tests/Ghostty.Vt.Tests/FocusPasteSizeReportTests.cs create mode 100644 tests/Ghostty.Vt.Tests/FormatterTests.cs create mode 100644 tests/Ghostty.Vt.Tests/Ghostty.Vt.Tests.csproj create mode 100644 tests/Ghostty.Vt.Tests/GlobalUsings.cs create mode 100644 tests/Ghostty.Vt.Tests/GridRefFullTests.cs create mode 100644 tests/Ghostty.Vt.Tests/IntegrationTests.cs create mode 100644 tests/Ghostty.Vt.Tests/KeyEncoderTests.cs create mode 100644 tests/Ghostty.Vt.Tests/KittyGraphicsTests.cs create mode 100644 tests/Ghostty.Vt.Tests/MouseEncoderTests.cs create mode 100644 tests/Ghostty.Vt.Tests/OscParserTests.cs create mode 100644 tests/Ghostty.Vt.Tests/RenderStateColorsTests.cs create mode 100644 tests/Ghostty.Vt.Tests/RenderStateConstructionTests.cs create mode 100644 tests/Ghostty.Vt.Tests/RenderStateRowCellTests.cs create mode 100644 tests/Ghostty.Vt.Tests/SgrParserTests.cs create mode 100644 tests/Ghostty.Vt.Tests/TerminalCallbackTests.cs create mode 100644 tests/Ghostty.Vt.Tests/TerminalConstructionTests.cs create mode 100644 tests/Ghostty.Vt.Tests/TerminalGridRefTests.cs create mode 100644 tests/Ghostty.Vt.Tests/TerminalModeTests.cs create mode 100644 tests/Ghostty.Vt.Tests/TerminalResizeResetTests.cs create mode 100644 tests/Ghostty.Vt.Tests/TerminalScrollTests.cs create mode 100644 tests/Ghostty.Vt.Tests/TerminalStateQueryTests.cs create mode 100644 tests/Ghostty.Vt.Tests/TerminalVTWriteTests.cs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..66d6b24 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..094cc70 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-native: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Zig + uses: mlugg/setup-zig@v1 + with: + version: '0.14.0' + + - name: Clone Ghostty upstream + run: | + git clone --depth 1 https://github.com/ghostty-org/ghostty.git /tmp/ghostty + + - name: Build native libghostty-vt + run: | + cd /tmp/ghostty + zig build lib-vt -Dtarget=x86_64-linux -Doptimize=ReleaseSafe + mkdir -p ${{ github.workspace }}/runtimes/linux-x64/native + cp zig-out/lib/libghostty-vt.so ${{ github.workspace }}/runtimes/linux-x64/native/ + + - name: Upload native artifact + uses: actions/upload-artifact@v4 + with: + name: libghostty-vt-linux-x64 + path: runtimes/linux-x64/native/libghostty-vt.so + + build-test: + needs: build-native + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Download native artifact + uses: actions/download-artifact@v4 + with: + name: libghostty-vt-linux-x64 + path: runtimes/linux-x64/native + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test --no-build --configuration Release --logger "trx" diff --git a/.github/workflows/daily-upstream.yml b/.github/workflows/daily-upstream.yml new file mode 100644 index 0000000..ed70030 --- /dev/null +++ b/.github/workflows/daily-upstream.yml @@ -0,0 +1,226 @@ +name: Daily Upstream Sync + +on: + schedule: + - cron: '30 3 * * *' + workflow_dispatch: + +jobs: + check-upstream: + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.check.outputs.changed }} + new_commit: ${{ steps.check.outputs.commit }} + new_version: ${{ steps.check.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Check for upstream changes + id: check + run: | + CURRENT=$(jq -r '.commit' ghostty-upstream.json) + LATEST=$(git ls-remote https://github.com/ghostty-org/ghostty.git HEAD | awk '{print $1}') + + if [ "$CURRENT" = "$LATEST" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No upstream changes detected" + exit 0 + fi + + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "commit=$LATEST" >> "$GITHUB_OUTPUT" + + # Clone to read version from build.zig.zon + git clone --depth 1 --filter=blob:none --sparse \ + https://github.com/ghostty-org/ghostty.git /tmp/ghostty-check + cd /tmp/ghostty-check + git sparse-checkout set build.zig.zon + VERSION=$(grep -oP '(?<="version": ")[^"]+' build.zig.zon | head -1) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + echo "New upstream: $LATEST (version $VERSION)" + + build-native: + needs: check-upstream + if: needs.check-upstream.outputs.changed == 'true' + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + target: x86_64-windows + rid: win-x64 + artifact: libghostty-vt.dll + - os: ubuntu-latest + target: x86_64-linux + rid: linux-x64 + artifact: libghostty-vt.so + - os: macos-latest + target: aarch64-macos + rid: osx-arm64 + artifact: libghostty-vt.dylib + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Zig + uses: mlugg/setup-zig@v1 + with: + version: '0.14.0' + + - name: Clone Ghostty at new commit + run: | + git clone --depth 1 --branch main \ + https://github.com/ghostty-org/ghostty.git \ + /tmp/ghostty + cd /tmp/ghostty + git fetch origin ${{ needs.check-upstream.outputs.new_commit }} + git checkout ${{ needs.check-upstream.outputs.new_commit }} + + - name: Build native libghostty-vt + run: | + cd /tmp/ghostty + zig build lib-vt -Dtarget=${{ matrix.target }} -Doptimize=ReleaseSafe + mkdir -p ${{ github.workspace }}/runtimes/${{ matrix.rid }}/native + cp zig-out/lib/${{ matrix.artifact }} \ + ${{ github.workspace }}/runtimes/${{ matrix.rid }}/native/ + + - name: Upload native artifact + uses: actions/upload-artifact@v4 + with: + name: native-${{ matrix.rid }} + path: runtimes/${{ matrix.rid }}/native/${{ matrix.artifact }} + + regenerate-bindings: + needs: build-native + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Clone Ghostty headers + run: | + git clone --depth 1 --filter=blob:none --sparse \ + https://github.com/ghostty-org/ghostty.git /tmp/ghostty + cd /tmp/ghostty + git sparse-checkout set include + + - name: Regenerate bindings + run: | + dotnet tool install --global ClangSharpPInvokeGenerator + clangsharp-pinvoke-generator @build/generate-bindings.rsp + + - name: Check for binding changes + id: bindings + run: | + git diff --exit-code src/Ghostty.Vt/Native/NativeMethods.cs + if ($LASTEXITCODE -ne 0) { + echo "changed=true" >> $env:GITHUB_OUTPUT + } else { + echo "changed=false" >> $env:GITHUB_OUTPUT + } + + - name: Upload bindings + if: steps.bindings.outputs.changed == 'true' + uses: actions/upload-artifact@v4 + with: + name: regenerated-bindings + path: src/Ghostty.Vt/Native/NativeMethods.cs + + publish: + needs: [check-upstream, build-native, regenerate-bindings] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Download all native artifacts + uses: actions/download-artifact@v4 + with: + pattern: native-* + path: runtimes + merge-multiple: true + + - name: Download regenerated bindings (if changed) + uses: actions/download-artifact@v4 + with: + name: regenerated-bindings + path: src/Ghostty.Vt/Native + continue-on-error: true + + - name: Build + run: dotnet build --configuration Release + + - name: Test + run: dotnet test --no-build --configuration Release + + - name: Compute version + id: version + run: | + VERSION_BASE="${{ needs.check-upstream.outputs.new_version }}" + COMMIT="${{ needs.check-upstream.outputs.new_commit }}" + SHORT="${COMMIT:0:8}" + TIMESTAMP=$(date -u +%Y%m%d%H%M) + VERSION_BASE="${VERSION_BASE%-dev}" + PACKAGE_VERSION="${VERSION_BASE}-ci.${TIMESTAMP}.${SHORT}" + echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT" + echo "Package version: $PACKAGE_VERSION" + + - name: Pack + run: | + dotnet pack src/Ghostty.Vt/Ghostty.Vt.csproj \ + --configuration Release \ + -p:Version=${{ steps.version.outputs.package_version }} + + - name: Publish to NuGet + run: | + dotnet nuget push artifacts/**/*.nupkg \ + --api-key ${{ secrets.NUGET_API_KEY }} \ + --source https://api.nuget.org/v3/index.json + + - name: Update ghostty-upstream.json + run: | + cat > ghostty-upstream.json << EOF + { + "repo": "https://github.com/ghostty-org/ghostty.git", + "branch": "main", + "commit": "${{ needs.check-upstream.outputs.new_commit }}", + "upstreamVersion": "${{ needs.check-upstream.outputs.new_version }}", + "lastUpdated": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } + EOF + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add ghostty-upstream.json + git diff --cached --quiet || git commit -m "chore: update upstream to ${{ needs.check-upstream.outputs.new_commit }}" + git push + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ steps.version.outputs.package_version }}" \ + --title "v${{ steps.version.outputs.package_version }}" \ + --notes "Automated daily build from upstream ghostty-org/ghostty@${{ needs.check-upstream.outputs.new_commit }}" + + - name: Create issue on failure + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + SHORT="${{ needs.check-upstream.outputs.new_commit }}" + gh issue create \ + --title "Upstream sync failed for ${SHORT:0:8}" \ + --body "Daily upstream sync failed. Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details." + + notify-failure: + needs: [check-upstream, publish] + if: failure() + runs-on: ubuntu-latest + steps: + - name: Failure already handled + run: echo "Failure notification handled by publish job issue creation" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ab8ba28 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,135 @@ +name: Release + +on: + workflow_dispatch: + inputs: + upstream_tag: + description: 'Upstream Ghostty tag (e.g., v1.3.1)' + required: true + type: string + package_version: + description: 'NuGet package version (defaults to tag without v prefix)' + required: false + type: string + +jobs: + build-native: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + target: x86_64-windows + rid: win-x64 + artifact: libghostty-vt.dll + - os: ubuntu-latest + target: x86_64-linux + rid: linux-x64 + artifact: libghostty-vt.so + - os: macos-latest + target: aarch64-macos + rid: osx-arm64 + artifact: libghostty-vt.dylib + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Zig + uses: mlugg/setup-zig@v1 + with: + version: '0.14.0' + + - name: Clone Ghostty at tag + run: | + git clone --depth 1 --branch ${{ inputs.upstream_tag }} \ + https://github.com/ghostty-org/ghostty.git /tmp/ghostty + + - name: Build native libghostty-vt + run: | + cd /tmp/ghostty + zig build lib-vt -Dtarget=${{ matrix.target }} -Doptimize=ReleaseSafe + mkdir -p ${{ github.workspace }}/runtimes/${{ matrix.rid }}/native + cp zig-out/lib/${{ matrix.artifact }} \ + ${{ github.workspace }}/runtimes/${{ matrix.rid }}/native/ + + - name: Upload native artifact + uses: actions/upload-artifact@v4 + with: + name: native-${{ matrix.rid }} + path: runtimes/${{ matrix.rid }}/native/${{ matrix.artifact }} + + publish: + needs: build-native + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Download all native artifacts + uses: actions/download-artifact@v4 + with: + pattern: native-* + path: runtimes + merge-multiple: true + + - name: Determine package version + id: version + run: | + if [ -n "${{ inputs.package_version }}" ]; then + echo "pkg_version=${{ inputs.package_version }}" >> "$GITHUB_OUTPUT" + else + TAG="${{ inputs.upstream_tag }}" + echo "pkg_version=${TAG#v}" >> "$GITHUB_OUTPUT" + fi + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test --no-build --configuration Release + + - name: Pack + run: | + dotnet pack src/Ghostty.Vt/Ghostty.Vt.csproj \ + --configuration Release \ + -p:Version=${{ steps.version.outputs.pkg_version }} + + - name: Publish to NuGet + run: | + dotnet nuget push artifacts/**/*.nupkg \ + --api-key ${{ secrets.NUGET_API_KEY }} \ + --source https://api.nuget.org/v3/index.json + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ inputs.upstream_tag }}" \ + --title "${{ inputs.upstream_tag }}" \ + --notes "Stable release built from upstream ghostty-org/ghostty@${{ inputs.upstream_tag }}" + + - name: Update ghostty-upstream.json + run: | + COMMIT=$(git ls-remote https://github.com/ghostty-org/ghostty.git "refs/tags/${{ inputs.upstream_tag }}" | awk '{print $1}') + cat > ghostty-upstream.json << EOF + { + "repo": "https://github.com/ghostty-org/ghostty.git", + "branch": "main", + "commit": "$COMMIT", + "upstreamVersion": "${{ steps.version.outputs.pkg_version }}", + "lastUpdated": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } + EOF + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add ghostty-upstream.json + git diff --cached --quiet || git commit -m "chore: release ${{ inputs.upstream_tag }}" + git push diff --git a/.gitignore b/.gitignore index de0786b..518053c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,8 @@ -# Build output -libghostty/ -.ghostty-src/ - -# .NET bin/ obj/ -.vs/ *.user *.suo - -# OS -.DS_Store -Thumbs.db - -# Test results +runtimes/ +.vs/ +artifacts/ TestResults/ - -# Worktrees -.worktrees/ diff --git a/Directory.Build.props b/Directory.Build.props index a9b78f3..7d77869 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,13 +1,8 @@ - - - $(MSBuildProgramFiles32)\Microsoft Visual Studio\18\BuildTools\MSBuild\Microsoft\VisualStudio\v18.0\AppxPackage\ - - - $(ProgramW6432)\Microsoft Visual Studio\18\Community\MSBuild\Microsoft\VisualStudio\v18.0\AppxPackage\ + + 13.0 + enable + enable + true diff --git a/Ghostty.Vt.sln b/Ghostty.Vt.sln new file mode 100644 index 0000000..df4e48d --- /dev/null +++ b/Ghostty.Vt.sln @@ -0,0 +1,56 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ghostty.Vt", "src\Ghostty.Vt\Ghostty.Vt.csproj", "{E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ghostty.Vt.Tests", "tests\Ghostty.Vt.Tests\Ghostty.Vt.Tests.csproj", "{BF847C72-31E8-4846-B64C-80B24E3319E8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Debug|x64.ActiveCfg = Debug|Any CPU + {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Debug|x64.Build.0 = Debug|Any CPU + {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Debug|x86.ActiveCfg = Debug|Any CPU + {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Debug|x86.Build.0 = Debug|Any CPU + {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Release|Any CPU.Build.0 = Release|Any CPU + {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Release|x64.ActiveCfg = Release|Any CPU + {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Release|x64.Build.0 = Release|Any CPU + {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Release|x86.ActiveCfg = Release|Any CPU + {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Release|x86.Build.0 = Release|Any CPU + {BF847C72-31E8-4846-B64C-80B24E3319E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF847C72-31E8-4846-B64C-80B24E3319E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF847C72-31E8-4846-B64C-80B24E3319E8}.Debug|x64.ActiveCfg = Debug|Any CPU + {BF847C72-31E8-4846-B64C-80B24E3319E8}.Debug|x64.Build.0 = Debug|Any CPU + {BF847C72-31E8-4846-B64C-80B24E3319E8}.Debug|x86.ActiveCfg = Debug|Any CPU + {BF847C72-31E8-4846-B64C-80B24E3319E8}.Debug|x86.Build.0 = Debug|Any CPU + {BF847C72-31E8-4846-B64C-80B24E3319E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF847C72-31E8-4846-B64C-80B24E3319E8}.Release|Any CPU.Build.0 = Release|Any CPU + {BF847C72-31E8-4846-B64C-80B24E3319E8}.Release|x64.ActiveCfg = Release|Any CPU + {BF847C72-31E8-4846-B64C-80B24E3319E8}.Release|x64.Build.0 = Release|Any CPU + {BF847C72-31E8-4846-B64C-80B24E3319E8}.Release|x86.ActiveCfg = Release|Any CPU + {BF847C72-31E8-4846-B64C-80B24E3319E8}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {BF847C72-31E8-4846-B64C-80B24E3319E8} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/build/build-native.ps1 b/build/build-native.ps1 new file mode 100644 index 0000000..2abe28e --- /dev/null +++ b/build/build-native.ps1 @@ -0,0 +1,54 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS +Builds libghostty-vt native binaries for local development. + +.PARAMETER GhosttySource +Path to a local clone of ghostty-org/ghostty. If not set, clones to /tmp/ghostty. + +.PARAMETER Target +Zig target triple (default: x86_64-windows). + +.PARAMETER OutputDir +Where to copy the built artifact (default: ../runtimes/win-x64/native). +#> +param( + [string]$GhosttySource = "", + [string]$Target = "x86_64-windows", + [string]$OutputDir = "" +) + +$ErrorActionPreference = "Stop" + +if (-not $GhosttySource) { + $GhosttySource = "/tmp/ghostty" + if (-not (Test-Path $GhosttySource)) { + Write-Host "Cloning ghostty to $GhosttySource..." + git clone --depth 1 https://github.com/ghostty-org/ghostty.git $GhosttySource + } +} + +if (-not $OutputDir) { + $rid = switch ($Target) { + "x86_64-windows" { "win-x64" } + "x86_64-linux" { "linux-x64" } + "aarch64-macos" { "osx-arm64" } + default { throw "Unknown target: $Target" } + } + $OutputDir = "$PSScriptRoot/../runtimes/$rid/native" +} + +Write-Host "Building libghostty-vt for $Target..." +Push-Location $GhosttySource +zig build lib-vt -Dtarget=$Target -Doptimize=ReleaseSafe +Pop-Location + +$artifact = switch -Regex ($Target) { + "windows" { "libghostty-vt.dll" } + "linux" { "libghostty-vt.so" } + "macos" { "libghostty-vt.dylib" } +} + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null +Copy-Item "$GhosttySource/zig-out/lib/$artifact" $OutputDir -Force +Write-Host "Copied $artifact to $OutputDir" diff --git a/build/generate-bindings.rsp b/build/generate-bindings.rsp new file mode 100644 index 0000000..30a6f7f --- /dev/null +++ b/build/generate-bindings.rsp @@ -0,0 +1,27 @@ +--file libghostty/include/ghostty/vt.h +--namespace Ghostty.Vt.Native +--libraryPath libghostty-vt +--methodClassName NativeMethods +--output src/Ghostty.Vt/Native +--language c +--config generate-macro-bindings + latest-codegen + generate-helper-types +--with-access-specifier *=public +--exclude GHOSTTY_VT_H +--remap GhosttyTerminal=nint + GhosttyRenderState=nint + GhosttyFormatter=nint + GhosttyKeyEncoder=nint + GhosttyMouseEncoder=nint + GhosttyOscParser=nint + GhosttySgrParser=nint + GhosttyKittyGraphics=nint + GhosttyKittyGraphicsImage=nint + GhosttyKittyGraphicsPlacementIterator=nint + GhosttyRenderStateRowIterator=nint + GhosttyRenderStateRowCells=nint +--additional -DGHOSTTY_EXPORT= + -D_WIN64 + -D_MSC_VER=1940 + -DSSIZE_T=long long diff --git a/ghostty-upstream.json b/ghostty-upstream.json new file mode 100644 index 0000000..2041e19 --- /dev/null +++ b/ghostty-upstream.json @@ -0,0 +1,7 @@ +{ + "repo": "https://github.com/ghostty-org/ghostty.git", + "branch": "main", + "commit": "", + "upstreamVersion": "0.0.0-dev", + "lastUpdated": "2026-04-13T00:00:00Z" +} diff --git a/global.json b/global.json index 230afd4..ff07225 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.312", + "version": "9.0.203", "rollForward": "latestFeature" } } diff --git a/justfile b/justfile index 393429e..d635c5f 100644 --- a/justfile +++ b/justfile @@ -1,47 +1,186 @@ -# Build all examples -build-all: build-win32 build-winforms build-wpf-simple build-wpf-direct build-winui3 +# libghostty-vt-dotnet local CI simulation +# Prerequisites: zig, dotnet 9.x, git -# Individual example builds -build-win32: - dotnet build examples/Win32/Win32.slnx +set dotenv-load := false +set positional-arguments := true -build-winforms: - dotnet build examples/WinForms/WinForms.slnx +# Default host triple (override with: just build-native target=x86_64-linux) +target := "x86_64-windows" +rid := if target == "x86_64-windows" { "win-x64" } \ + else if target == "x86_64-linux" { "linux-x64" } \ + else if target == "aarch64-macos" { "osx-arm64" } \ + else { "unknown" } -build-wpf-simple: - dotnet build examples/WPF-Simple/WPF-Simple.slnx +artifact := if target == "x86_64-windows" { "ghostty-vt.dll" } \ + else if target == "x86_64-linux" { "libghostty-vt.so" } \ + else if target == "aarch64-macos" { "libghostty-vt.dylib" } \ + else { "ghostty-vt.unknown" } -build-wpf-direct: - dotnet build examples/WPF-Direct/WPF-Direct.slnx +# Zig puts shared libs in bin/ on Windows, lib/ elsewhere +artifact_dir := if target == "x86_64-windows" { "bin" } else { "lib" } -build-winui3: - dotnet build examples/WinUI3/WinUI3.slnx +# .NET expects libghostty-vt.* naming; zig produces ghostty-vt.dll on Windows +dotnet_artifact := if target == "x86_64-windows" { "libghostty-vt.dll" } \ + else if target == "x86_64-linux" { "libghostty-vt.so" } \ + else if target == "aarch64-macos" { "libghostty-vt.dylib" } \ + else { "libghostty-vt.unknown" } -# Build the interop library only -build-interop: - dotnet build src/Ghostty.Interop/Ghostty.Interop.csproj +ghostty_dir := env_var_or_default("GHOSTTY_SOURCE", "") -# Clean all build output -clean: - dotnet clean examples/Win32/Win32.slnx - dotnet clean examples/WinForms/WinForms.slnx - dotnet clean examples/WPF-Simple/WPF-Simple.slnx - dotnet clean examples/WPF-Direct/WPF-Direct.slnx - dotnet clean examples/WinUI3/WinUI3.slnx +# Default: show available recipes +default: + @just --list + +# ────────────────────────────────────────────── +# Full CI pipeline (the one-stop shop) +# ────────────────────────────────────────────── + +# Run the complete CI pipeline: native build → restore → build → test → pack +ci: build-native restore build test pack + @echo "=== CI pipeline complete ===" + +# ────────────────────────────────────────────── +# Native library build +# ────────────────────────────────────────────── + +# Clone ghostty upstream (shallow) into C:\tmp\ghostty\ghostty. +# Whitelist C:\tmp\ghostty in your antivirus to avoid build failures +clone-ghostty: + #!/bin/bash + dir="{{ ghostty_dir }}" + if [ -z "$dir" ]; then + base="C:\\tmp\\ghostty" + mkdir -p "$base" + dir="$base\\ghostty.$(date +%s)" + mkdir -p "$dir" + echo "Cloning ghostty to $dir..." + git clone --depth 1 https://github.com/ghostty-org/ghostty.git "$dir" + elif [ -d "$dir/.git" ]; then + echo "Ghostty already cloned at $dir — pulling latest..." + cd "$dir" && git pull --ff-only + else + echo "Cloning ghostty to $dir..." + git clone --depth 1 https://github.com/ghostty-org/ghostty.git "$dir" + fi + # Export the dir for dependent recipes + echo "$dir" > "{{ justfile_directory() }}/.ghostty-clone-dir" + +# Build the native libghostty-vt library for the current platform +build-native: clone-ghostty + #!/bin/bash + dir="{{ ghostty_dir }}" + if [ -z "$dir" ]; then + dir=$(cat "{{ justfile_directory() }}/.ghostty-clone-dir") + fi + echo "Building libghostty-vt for {{ target }}..." + cd "$dir" || { echo "ERROR: failed to cd into $dir"; exit 1; } + zig build install -Dtarget={{ target }} -Doptimize=ReleaseSafe + mkdir -p "{{ justfile_directory() }}/runtimes/{{ rid }}/native" + cp "zig-out/{{ artifact_dir }}/{{ artifact }}" "{{ justfile_directory() }}/runtimes/{{ rid }}/native/{{ dotnet_artifact }}" + echo "Copied {{ artifact }} → runtimes/{{ rid }}/native/{{ dotnet_artifact }}" + # Clean up random tmp clone + if [ -z "{{ ghostty_dir }}" ]; then + rm -rf "$dir" + fi + rm -f "{{ justfile_directory() }}/.ghostty-clone-dir" + +# Build native for a specific upstream tag/branch +build-native-ref ref: + #!/bin/bash + base="C:\\tmp\\ghostty" + mkdir -p "$base" + dir="$base\\ghostty-ref.$(date +%s)" + mkdir -p "$dir" + echo "Cloning ghostty at ref '{{ ref }}' to $dir..." + git clone --depth 1 --branch "{{ ref }}" https://github.com/ghostty-org/ghostty.git "$dir" + cd "$dir" + zig build install -Dtarget={{ target }} -Doptimize=ReleaseSafe + mkdir -p "{{ justfile_directory() }}/runtimes/{{ rid }}/native" + cp "zig-out/{{ artifact_dir }}/{{ artifact }}" "{{ justfile_directory() }}/runtimes/{{ rid }}/native/{{ dotnet_artifact }}" + echo "Copied {{ artifact }} → runtimes/{{ rid }}/native/{{ dotnet_artifact }}" + rm -rf "$dir" + +# ────────────────────────────────────────────── +# .NET build / test / pack +# ────────────────────────────────────────────── + +# Restore NuGet packages +restore: + dotnet restore -# Run all visual tests -test-visual: - dotnet test tests/Ghostty.Tests.Visual/Ghostty.Tests.Visual.csproj +# Build the solution (Release configuration) +build: + dotnet build --no-restore --configuration Release -# CI pipeline -ci: build-all ci-test-smoke ci-test-visual +# Build without the --no-restore flag +build-fresh: + dotnet build --configuration Release + +# Run all tests +test: + dotnet test --no-build --configuration Release --logger "trx" + +# Run tests without --no-build (builds first) +test-fresh: + dotnet test --configuration Release --logger "trx" + +# Pack the NuGet package +pack version="0.0.1-dev": + dotnet pack src/Ghostty.Vt/Ghostty.Vt.csproj \ + --configuration Release \ + -p:Version={{ version }} + @echo "Package created: src/Ghostty.Vt/bin/Release/Ghostty.Vt.{{ version }}.nupkg" + +# ────────────────────────────────────────────── +# Upstream sync helpers +# ────────────────────────────────────────────── + +# Check if upstream ghostty has new commits +check-upstream: + #!/bin/bash + CURRENT=$(jq -r '.commit' ghostty-upstream.json) + LATEST=$(git ls-remote https://github.com/ghostty-org/ghostty.git HEAD | awk '{print $1}') + if [ "$CURRENT" = "$LATEST" ]; then + echo "Up to date: $CURRENT" + else + echo "Update available:" + echo " current: $CURRENT" + echo " latest: $LATEST" + fi + +# Update ghostty-upstream.json after a sync +update-upstream commit version: + #!/bin/bash + cat > ghostty-upstream.json << EOF + { + "repo": "https://github.com/ghostty-org/ghostty.git", + "branch": "main", + "commit": "{{ commit }}", + "upstreamVersion": "{{ version }}", + "lastUpdated": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } + EOF + echo "Updated ghostty-upstream.json → {{ commit }}" + +# ────────────────────────────────────────────── +# Utilities +# ────────────────────────────────────────────── + +# Clean all build artifacts +clean: + dotnet clean --configuration Release + rm -rf src/Ghostty.Vt/bin src/Ghostty.Vt/obj + rm -rf tests/Ghostty.Vt.Tests/bin tests/Ghostty.Vt.Tests/obj + rm -rf artifacts -ci-test-smoke: - dotnet test tests/Ghostty.Tests.Visual/Ghostty.Tests.Visual.csproj --filter "Category=Smoke" --logger "trx;LogFileName=smoke.trx" --results-directory TestResults +# Remove native binaries (runtimes/) +clean-native: + rm -rf runtimes/*/native/libghostty-vt.* -ci-test-visual: - dotnet test tests/Ghostty.Tests.Visual/Ghostty.Tests.Visual.csproj --logger "trx;LogFileName=visual.trx" --results-directory TestResults +# Verify the solution builds without native binaries (compile-only check) +check: + dotnet build Ghostty.Vt.sln --configuration Release --no-restore -# Update screenshot baselines -update-baselines: - TEST_UPDATE_BASELINES=true dotnet test tests/Ghostty.Tests.Visual/Ghostty.Tests.Visual.csproj +# Quick compile check without restore +quick: + dotnet build Ghostty.Vt.sln diff --git a/src/Ghostty.Vt/BuildInfo.cs b/src/Ghostty.Vt/BuildInfo.cs new file mode 100644 index 0000000..828b696 --- /dev/null +++ b/src/Ghostty.Vt/BuildInfo.cs @@ -0,0 +1,46 @@ +using System.Runtime.InteropServices; +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public readonly struct BuildInfo +{ + public string Version { get; } + public string ZigVersion { get; } + + private BuildInfo(string version, string zigVersion) + { + Version = version; + ZigVersion = zigVersion; + } + + public static unsafe BuildInfo Query() + { + var version = QueryString((int)BuildInfoData.VersionString); + var zigVersion = QueryString((int)BuildInfoData.VersionPre); + return new BuildInfo(version, zigVersion); + } + + private static unsafe string QueryString(int data) + { + var str = new GhosttyStringNative(); + var result = NativeMethods.ghostty_build_info(data, &str); + if (result != 0 || str.Ptr == 0 || str.Len == 0) return string.Empty; + return Marshal.PtrToStringUTF8((IntPtr)str.Ptr, (int)str.Len) ?? string.Empty; + } +} + +public enum BuildInfoData +{ + Invalid = 0, + Simd = 1, + KittyGraphics = 2, + TmuxControlMode = 3, + Optimize = 4, + VersionString = 5, + VersionMajor = 6, + VersionMinor = 7, + VersionPatch = 8, + VersionPre = 9, + VersionBuild = 10, +} diff --git a/src/Ghostty.Vt/Enums/CellContentTag.cs b/src/Ghostty.Vt/Enums/CellContentTag.cs new file mode 100644 index 0000000..0855224 --- /dev/null +++ b/src/Ghostty.Vt/Enums/CellContentTag.cs @@ -0,0 +1,8 @@ +namespace Ghostty.Vt.Enums; + +public enum CellContentTag +{ + Empty = 0, + Grapheme = 1, + KittyImagePlacement = 2, +} diff --git a/src/Ghostty.Vt/Enums/CursorStyle.cs b/src/Ghostty.Vt/Enums/CursorStyle.cs new file mode 100644 index 0000000..0990a80 --- /dev/null +++ b/src/Ghostty.Vt/Enums/CursorStyle.cs @@ -0,0 +1,11 @@ +namespace Ghostty.Vt.Enums; + +public enum CursorStyle +{ + Block = 0, + Bar = 1, + Underline = 2, + BlockBar = 3, + BlockUnderline = 4, + BarUnderline = 5, +} diff --git a/src/Ghostty.Vt/Enums/FormatterFormat.cs b/src/Ghostty.Vt/Enums/FormatterFormat.cs new file mode 100644 index 0000000..0ace177 --- /dev/null +++ b/src/Ghostty.Vt/Enums/FormatterFormat.cs @@ -0,0 +1,8 @@ +namespace Ghostty.Vt.Enums; + +public enum FormatterFormat +{ + PlainText = 0, + Vt = 1, + Html = 2, +} diff --git a/src/Ghostty.Vt/Enums/GhosttyKey.cs b/src/Ghostty.Vt/Enums/GhosttyKey.cs new file mode 100644 index 0000000..be8d2b5 --- /dev/null +++ b/src/Ghostty.Vt/Enums/GhosttyKey.cs @@ -0,0 +1,201 @@ +namespace Ghostty.Vt.Enums; + +/// +/// Physical key codes based on W3C UI Events KeyboardEvent.code standard. +/// Layout-independent: key_a is the physical 'A' key regardless of keyboard layout. +/// +public enum GhosttyKey +{ + Unidentified = 0, + + // Writing System Keys (W3C § 3.1.1) + Backquote = 1, + Backslash = 2, + BracketLeft = 3, + BracketRight = 4, + Comma = 5, + Digit0 = 6, + Digit1 = 7, + Digit2 = 8, + Digit3 = 9, + Digit4 = 10, + Digit5 = 11, + Digit6 = 12, + Digit7 = 13, + Digit8 = 14, + Digit9 = 15, + Equal = 16, + IntlBackslash = 17, + IntlRo = 18, + IntlYen = 19, + A = 20, + B = 21, + C = 22, + D = 23, + E = 24, + F = 25, + G = 26, + H = 27, + I = 28, + J = 29, + K = 30, + L = 31, + M = 32, + N = 33, + O = 34, + P = 35, + Q = 36, + R = 37, + S = 38, + T = 39, + U = 40, + V = 41, + W = 42, + X = 43, + Y = 44, + Z = 45, + Minus = 46, + Period = 47, + Quote = 48, + Semicolon = 49, + Slash = 50, + + // Functional Keys (W3C § 3.1.2) + AltLeft = 51, + AltRight = 52, + Backspace = 53, + CapsLock = 54, + ContextMenu = 55, + ControlLeft = 56, + ControlRight = 57, + Enter = 58, + MetaLeft = 59, + MetaRight = 60, + ShiftLeft = 61, + ShiftRight = 62, + Space = 63, + Tab = 64, + Convert = 65, + KanaMode = 66, + NonConvert = 67, + + // Control Pad Section (W3C § 3.2) + Delete = 68, + End = 69, + Help = 70, + Home = 71, + Insert = 72, + PageDown = 73, + PageUp = 74, + + // Arrow Pad Section (W3C § 3.3) + ArrowDown = 75, + ArrowLeft = 76, + ArrowRight = 77, + ArrowUp = 78, + + // Numpad Section (W3C § 3.4) + NumLock = 79, + Numpad0 = 80, + Numpad1 = 81, + Numpad2 = 82, + Numpad3 = 83, + Numpad4 = 84, + Numpad5 = 85, + Numpad6 = 86, + Numpad7 = 87, + Numpad8 = 88, + Numpad9 = 89, + NumpadAdd = 90, + NumpadBackspace = 91, + NumpadClear = 92, + NumpadClearEntry = 93, + NumpadComma = 94, + NumpadDecimal = 95, + NumpadDivide = 96, + NumpadEnter = 97, + NumpadEqual = 98, + NumpadMemoryAdd = 99, + NumpadMemoryClear = 100, + NumpadMemoryRecall = 101, + NumpadMemoryStore = 102, + NumpadMemorySubtract = 103, + NumpadMultiply = 104, + NumpadParenLeft = 105, + NumpadParenRight = 106, + NumpadSubtract = 107, + NumpadSeparator = 108, + NumpadUp = 109, + NumpadDown = 110, + NumpadRight = 111, + NumpadLeft = 112, + NumpadBegin = 113, + NumpadHome = 114, + NumpadEnd = 115, + NumpadInsert = 116, + NumpadDelete = 117, + NumpadPageUp = 118, + NumpadPageDown = 119, + + // Function Section (W3C § 3.5) + Escape = 120, + F1 = 121, + F2 = 122, + F3 = 123, + F4 = 124, + F5 = 125, + F6 = 126, + F7 = 127, + F8 = 128, + F9 = 129, + F10 = 130, + F11 = 131, + F12 = 132, + F13 = 133, + F14 = 134, + F15 = 135, + F16 = 136, + F17 = 137, + F18 = 138, + F19 = 139, + F20 = 140, + F21 = 141, + F22 = 142, + F23 = 143, + F24 = 144, + F25 = 145, + Fn = 146, + FnLock = 147, + PrintScreen = 148, + ScrollLock = 149, + Pause = 150, + + // Media Keys (W3C § 3.6) + BrowserBack = 151, + BrowserFavorites = 152, + BrowserForward = 153, + BrowserHome = 154, + BrowserRefresh = 155, + BrowserSearch = 156, + BrowserStop = 157, + Eject = 158, + LaunchApp1 = 159, + LaunchApp2 = 160, + LaunchMail = 161, + MediaPlayPause = 162, + MediaSelect = 163, + MediaStop = 164, + MediaTrackNext = 165, + MediaTrackPrevious = 166, + Power = 167, + Sleep = 168, + AudioVolumeDown = 169, + AudioVolumeMute = 170, + AudioVolumeUp = 171, + WakeUp = 172, + + // Legacy, Non-standard, and Special Keys (W3C § 3.7) + Copy = 173, + Cut = 174, + Paste = 175, +} diff --git a/src/Ghostty.Vt/Enums/KittyImageCompression.cs b/src/Ghostty.Vt/Enums/KittyImageCompression.cs new file mode 100644 index 0000000..99037d3 --- /dev/null +++ b/src/Ghostty.Vt/Enums/KittyImageCompression.cs @@ -0,0 +1,7 @@ +namespace Ghostty.Vt.Enums; + +public enum KittyImageCompression +{ + None = 0, + Deflate = 1, +} diff --git a/src/Ghostty.Vt/Enums/KittyImageFormat.cs b/src/Ghostty.Vt/Enums/KittyImageFormat.cs new file mode 100644 index 0000000..d39e36f --- /dev/null +++ b/src/Ghostty.Vt/Enums/KittyImageFormat.cs @@ -0,0 +1,8 @@ +namespace Ghostty.Vt.Enums; + +public enum KittyImageFormat +{ + Rgba = 0, + Rgb = 1, + Png = 2, +} diff --git a/src/Ghostty.Vt/Enums/KittyPlacementLayer.cs b/src/Ghostty.Vt/Enums/KittyPlacementLayer.cs new file mode 100644 index 0000000..b2f1229 --- /dev/null +++ b/src/Ghostty.Vt/Enums/KittyPlacementLayer.cs @@ -0,0 +1,11 @@ +using System; + +namespace Ghostty.Vt.Enums; + +[Flags] +public enum KittyPlacementLayer +{ + Background = 1 << 0, + Reference = 1 << 1, + All = Background | Reference, +} diff --git a/src/Ghostty.Vt/Enums/OptimizeMode.cs b/src/Ghostty.Vt/Enums/OptimizeMode.cs new file mode 100644 index 0000000..a266ce3 --- /dev/null +++ b/src/Ghostty.Vt/Enums/OptimizeMode.cs @@ -0,0 +1,8 @@ +namespace Ghostty.Vt.Enums; + +public enum OptimizeMode +{ + None = 0, + Size = 1, + Time = 2, +} diff --git a/src/Ghostty.Vt/Enums/OscCommandType.cs b/src/Ghostty.Vt/Enums/OscCommandType.cs new file mode 100644 index 0000000..4d38730 --- /dev/null +++ b/src/Ghostty.Vt/Enums/OscCommandType.cs @@ -0,0 +1,28 @@ +namespace Ghostty.Vt.Enums; + +public enum OscCommandType +{ + Unknown = 0, + SetWindowTitle = 1, + SetIconName = 2, + SetWindowIcon = 3, + SetWorkingDirectory = 4, + SetClipboard = 5, + ResetColors = 6, + SetForegroundColor = 7, + SetBackgroundColor = 8, + SetCursorColor = 9, + SetColor = 10, + Hyperlink = 11, + Notify = 12, + ItermImage = 13, + ItermProfile = 14, + ItermSetMark = 15, + KittyImage = 16, + KittyClear = 17, + KittyQuery = 18, + KittyTransfer = 19, + KittyHint = 20, + KittyBell = 21, + KittyEsr = 22, +} diff --git a/src/Ghostty.Vt/Enums/PointTag.cs b/src/Ghostty.Vt/Enums/PointTag.cs new file mode 100644 index 0000000..1b31c98 --- /dev/null +++ b/src/Ghostty.Vt/Enums/PointTag.cs @@ -0,0 +1,9 @@ +namespace Ghostty.Vt.Enums; + +public enum PointTag +{ + Active = 0, + Viewport = 1, + Screen = 2, + History = 3, +} diff --git a/src/Ghostty.Vt/Enums/RenderStateDirty.cs b/src/Ghostty.Vt/Enums/RenderStateDirty.cs new file mode 100644 index 0000000..88422a0 --- /dev/null +++ b/src/Ghostty.Vt/Enums/RenderStateDirty.cs @@ -0,0 +1,8 @@ +namespace Ghostty.Vt.Enums; + +public enum RenderStateDirty +{ + False = 0, + Partial = 1, + Full = 2, +} diff --git a/src/Ghostty.Vt/Enums/SgrAttributeTag.cs b/src/Ghostty.Vt/Enums/SgrAttributeTag.cs new file mode 100644 index 0000000..186a36d --- /dev/null +++ b/src/Ghostty.Vt/Enums/SgrAttributeTag.cs @@ -0,0 +1,36 @@ +namespace Ghostty.Vt.Enums; + +public enum SgrAttributeTag +{ + Unset = 0, + Unknown = 1, + Bold = 2, + ResetBold = 3, + Italic = 4, + ResetItalic = 5, + Faint = 6, + Underline = 7, + UnderlineColor = 8, + UnderlineColor256 = 9, + ResetUnderlineColor = 10, + Overline = 11, + ResetOverline = 12, + Blink = 13, + ResetBlink = 14, + Inverse = 15, + ResetInverse = 16, + Invisible = 17, + ResetInvisible = 18, + Strikethrough = 19, + ResetStrikethrough = 20, + DirectColorFg = 21, + DirectColorBg = 22, + Background8 = 23, + Foreground8 = 24, + ResetForeground = 25, + ResetBackground = 26, + BrightBackground8 = 27, + BrightForeground8 = 28, + Background256 = 29, + Foreground256 = 30, +} diff --git a/src/Ghostty.Vt/Enums/SgrUnderline.cs b/src/Ghostty.Vt/Enums/SgrUnderline.cs new file mode 100644 index 0000000..5dcd1e7 --- /dev/null +++ b/src/Ghostty.Vt/Enums/SgrUnderline.cs @@ -0,0 +1,11 @@ +namespace Ghostty.Vt.Enums; + +public enum SgrUnderline +{ + None = 0, + Single = 1, + Double = 2, + Curly = 3, + Dotted = 4, + Dashed = 5, +} diff --git a/src/Ghostty.Vt/Enums/TerminalData.cs b/src/Ghostty.Vt/Enums/TerminalData.cs new file mode 100644 index 0000000..2234333 --- /dev/null +++ b/src/Ghostty.Vt/Enums/TerminalData.cs @@ -0,0 +1,36 @@ +namespace Ghostty.Vt.Enums; + +public enum TerminalData +{ + Invalid = 0, + Cols = 1, + Rows = 2, + CursorX = 3, + CursorY = 4, + CursorPendingWrap = 5, + ActiveScreen = 6, + CursorVisible = 7, + KittyKeyboardFlags = 8, + Scrollbar = 9, + CursorStyle = 10, + MouseTracking = 11, + Title = 12, + Pwd = 13, + TotalRows = 14, + ScrollbackRows = 15, + WidthPx = 16, + HeightPx = 17, + ColorForeground = 18, + ColorBackground = 19, + ColorCursor = 20, + ColorPalette = 21, + ColorForegroundDefault = 22, + ColorBackgroundDefault = 23, + ColorCursorDefault = 24, + ColorPaletteDefault = 25, + KittyImageStorageLimit = 26, + KittyImageMediumFile = 27, + KittyImageMediumTempFile = 28, + KittyImageMediumSharedMem = 29, + KittyGraphics = 30, +} diff --git a/src/Ghostty.Vt/Enums/TerminalMode.cs b/src/Ghostty.Vt/Enums/TerminalMode.cs new file mode 100644 index 0000000..deb9dfb --- /dev/null +++ b/src/Ghostty.Vt/Enums/TerminalMode.cs @@ -0,0 +1,18 @@ +namespace Ghostty.Vt.Enums; + +public enum TerminalMode +{ + CursorKeys = 1, + AutoWrap = 7, + Insert = 4, + MouseX10 = 9, + MouseNormal = 1000, + MouseButton = 1002, + MouseAny = 1003, + MouseSGR = 1006, + FocusEvent = 1007, + AltScreen = 1049, + BracketedPaste = 2004, + KittyKeyboard = 2015, + SynchronizedOutput = 2026, +} diff --git a/src/Ghostty.Vt/Enums/TerminalOption.cs b/src/Ghostty.Vt/Enums/TerminalOption.cs new file mode 100644 index 0000000..2951936 --- /dev/null +++ b/src/Ghostty.Vt/Enums/TerminalOption.cs @@ -0,0 +1,24 @@ +namespace Ghostty.Vt.Enums; + +public enum TerminalOption +{ + FontName = 0, + FontSize = 1, + FontBold = 2, + FontItalic = 3, + ForegroundColor = 4, + BackgroundColor = 5, + CursorForegroundColor = 6, + CursorBackgroundColor = 7, + CursorStyle = 8, + CursorBlink = 9, + CursorBlinkIntervalMs = 10, + ColorPalette = 11, + ColorBold = 12, + ColorDim = 13, + ClipboardWrite = 14, + ClipboardRead = 15, + ClipboardConfirmation = 16, + Callbacks = 17, + PtyBuffer = 18, +} diff --git a/src/Ghostty.Vt/Enums/TerminalScreen.cs b/src/Ghostty.Vt/Enums/TerminalScreen.cs new file mode 100644 index 0000000..f032028 --- /dev/null +++ b/src/Ghostty.Vt/Enums/TerminalScreen.cs @@ -0,0 +1,7 @@ +namespace Ghostty.Vt.Enums; + +public enum TerminalScreen +{ + Active = 0, + Alternate = 1, +} diff --git a/src/Ghostty.Vt/Focus.cs b/src/Ghostty.Vt/Focus.cs new file mode 100644 index 0000000..2e404c3 --- /dev/null +++ b/src/Ghostty.Vt/Focus.cs @@ -0,0 +1,22 @@ +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public static class Focus +{ + public static unsafe byte[] Encode(bool focused) + { + Span buf = stackalloc byte[16]; + fixed (byte* ptr = buf) + { + nuint written; + var result = NativeMethods.ghostty_focus_encode( + focused ? 0 : 1, ptr, 16, &written); + GhosttyException.ThrowIfFailure(result); + if (written == 0) return []; + var bytes = new byte[(int)written]; + new ReadOnlySpan(ptr, (int)written).CopyTo(bytes); + return bytes; + } + } +} diff --git a/src/Ghostty.Vt/Formatter.cs b/src/Ghostty.Vt/Formatter.cs new file mode 100644 index 0000000..798c7c5 --- /dev/null +++ b/src/Ghostty.Vt/Formatter.cs @@ -0,0 +1,80 @@ +using System.Runtime.InteropServices; +using Ghostty.Vt.Enums; +using Ghostty.Vt.Internals; +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public sealed class Formatter : IDisposable +{ + private readonly FormatterSafeHandle _handle; + + internal unsafe Formatter(nint terminalHandle, FormatterFormat format, + Action? configure) + { + var opts = new FormatterOptions(); + configure?.Invoke(opts); + + // Allocate a zero-initialized buffer for GhosttyFormatterTerminalOptions (128 bytes, plenty) + // Layout: { size_t size, GhosttyFormatterFormat emit, bool unwrap, bool trim, ... } + byte* nativeOpts = stackalloc byte[128]; + new Span(nativeOpts, 128).Clear(); + + // size (offset 0, 8 bytes) + *(nuint*)(nativeOpts + 0) = 128; + // emit (offset 8, 4 bytes) - GhosttyFormatterFormat enum + *(int*)(nativeOpts + 8) = (int)format; + // unwrap (offset 12, 1 byte) + *(nativeOpts + 12) = 0; + // trim (offset 13, 1 byte) + *(nativeOpts + 13) = (byte)(opts.Trim ? 1 : 0); + + nint handle; + var result = NativeMethods.ghostty_formatter_terminal_new( + nint.Zero, &handle, terminalHandle, nativeOpts); + GhosttyException.ThrowIfFailure(result); + _handle = new FormatterSafeHandle(handle); + } + + public override unsafe string ToString() + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + + // First query required size (returns OUT_OF_SPACE with required size) + nuint written = 0; + int queryResult = NativeMethods.ghostty_formatter_format_buf( + _handle.DangerousGetHandle(), null, 0, &written); + + if (written == 0) return string.Empty; + + byte* buf = stackalloc byte[(int)written]; + var result = NativeMethods.ghostty_formatter_format_buf( + _handle.DangerousGetHandle(), buf, written, &written); + GhosttyException.ThrowIfFailure(result); + + return Marshal.PtrToStringUTF8((nint)buf, (int)written) ?? string.Empty; + } + + public unsafe ReadOnlySpan ToSpan() + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + + byte* outPtr = null; + nuint outLen = 0; + var result = NativeMethods.ghostty_formatter_format_alloc( + _handle.DangerousGetHandle(), nint.Zero, &outPtr, &outLen); + GhosttyException.ThrowIfFailure(result); + + if (outPtr == null || outLen == 0) return ReadOnlySpan.Empty; + return new ReadOnlySpan(outPtr, (int)outLen); + } + + public void Dispose() => _handle.Dispose(); + + private sealed class FormatterSafeHandle : GhosttySafeHandle + { + public FormatterSafeHandle(nint handle) { SetHandle(handle); } + protected override void Free(nint handle) => NativeMethods.ghostty_formatter_free(handle); + public new nint DangerousGetHandle() => handle; + } +} diff --git a/src/Ghostty.Vt/FormatterOptions.cs b/src/Ghostty.Vt/FormatterOptions.cs new file mode 100644 index 0000000..2576b32 --- /dev/null +++ b/src/Ghostty.Vt/FormatterOptions.cs @@ -0,0 +1,11 @@ +using Ghostty.Vt.Enums; +using Ghostty.Vt.Internals; +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public sealed class FormatterOptions +{ + public bool Trim { get; set; } + public bool IncludeStyle { get; set; } +} diff --git a/src/Ghostty.Vt/Ghostty.Vt.csproj b/src/Ghostty.Vt/Ghostty.Vt.csproj new file mode 100644 index 0000000..d283bb2 --- /dev/null +++ b/src/Ghostty.Vt/Ghostty.Vt.csproj @@ -0,0 +1,44 @@ + + + net9.0 + true + Ghostty.Vt + Ghostty.Vt + true + true + 0.0.0-dev + Ghostty.Vt + Virtual terminal engine powered by libghostty-vt + Alessandro De Blasis + https://github.com/deblasis/libghostty-vt-dotnet + MIT + + + + + + + + + + + + + + + diff --git a/src/Ghostty.Vt/GhosttyException.cs b/src/Ghostty.Vt/GhosttyException.cs new file mode 100644 index 0000000..453ddf8 --- /dev/null +++ b/src/Ghostty.Vt/GhosttyException.cs @@ -0,0 +1,26 @@ +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public sealed class GhosttyException : Exception +{ + public int Result { get; } + + public GhosttyException(int result) + : base($"Ghostty error: {result}") + { + Result = result; + } + + public GhosttyException(string message) + : base(message) + { + Result = -1; + } + + internal static void ThrowIfFailure(int result) + { + if (result != 0) + throw new GhosttyException(result); + } +} diff --git a/src/Ghostty.Vt/GridRef.cs b/src/Ghostty.Vt/GridRef.cs new file mode 100644 index 0000000..c63b187 --- /dev/null +++ b/src/Ghostty.Vt/GridRef.cs @@ -0,0 +1,18 @@ +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public readonly struct GridRef +{ + internal readonly GhosttyGridRefNative Native; + private readonly Terminal _terminal; + + internal GridRef(GhosttyGridRefNative native, Terminal terminal) + { + Native = native; + _terminal = terminal; + } + + /// Opaque node pointer from the native grid ref. Null if invalid. + internal nint NativeHandle => Native.Node; +} diff --git a/src/Ghostty.Vt/Internals/DelegatePinner.cs b/src/Ghostty.Vt/Internals/DelegatePinner.cs new file mode 100644 index 0000000..6e889b0 --- /dev/null +++ b/src/Ghostty.Vt/Internals/DelegatePinner.cs @@ -0,0 +1,27 @@ +using System.Runtime.InteropServices; + +namespace Ghostty.Vt.Internals; + +internal sealed class DelegatePinner : IDisposable +{ + private readonly List _pins = []; + + public nint Pin(Delegate d) + { + var gch = GCHandle.Alloc(d); + _pins.Add(gch); +#pragma warning disable IL3050 // Delegate marshalling is used at runtime, not AOT + return Marshal.GetFunctionPointerForDelegate(d); +#pragma warning restore IL3050 + } + + public void Dispose() + { + foreach (var pin in _pins) + { + if (pin.IsAllocated) + pin.Free(); + } + _pins.Clear(); + } +} diff --git a/src/Ghostty.Vt/Internals/GhosttyMemory.cs b/src/Ghostty.Vt/Internals/GhosttyMemory.cs new file mode 100644 index 0000000..415ba95 --- /dev/null +++ b/src/Ghostty.Vt/Internals/GhosttyMemory.cs @@ -0,0 +1,31 @@ +using Ghostty.Vt.Native; + +namespace Ghostty.Vt.Internals; + +internal static unsafe class GhosttyMemory +{ + public static byte* Alloc(nuint length) + { + var ptr = NativeMethods.ghostty_alloc(nint.Zero, length); + if (ptr == null) + throw new OutOfMemoryException($"ghostty_alloc failed for {length} bytes"); + return ptr; + } + + public static void Free(byte* ptr, nuint length) + { + if (ptr != null) + NativeMethods.ghostty_free(nint.Zero, ptr, length); + } + + /// + /// Allocates a native buffer, copies managed bytes into it. + /// Caller must Free the returned pointer. + /// + public static byte* CopyManagedToNative(ReadOnlySpan source) + { + var ptr = Alloc((nuint)source.Length); + source.CopyTo(new Span(ptr, source.Length)); + return ptr; + } +} diff --git a/src/Ghostty.Vt/Internals/GhosttySafeHandle.cs b/src/Ghostty.Vt/Internals/GhosttySafeHandle.cs new file mode 100644 index 0000000..118f37f --- /dev/null +++ b/src/Ghostty.Vt/Internals/GhosttySafeHandle.cs @@ -0,0 +1,19 @@ +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace Ghostty.Vt.Internals; + +internal abstract class GhosttySafeHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + protected GhosttySafeHandle() : base(ownsHandle: true) { } + + protected sealed override bool ReleaseHandle() + { + if (handle == nint.Zero) return true; + Free(handle); + SetHandle(nint.Zero); + return true; + } + + protected abstract void Free(nint handle); +} diff --git a/src/Ghostty.Vt/KeyEncoder.cs b/src/Ghostty.Vt/KeyEncoder.cs new file mode 100644 index 0000000..d52b461 --- /dev/null +++ b/src/Ghostty.Vt/KeyEncoder.cs @@ -0,0 +1,47 @@ +using Ghostty.Vt.Internals; +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public sealed class KeyEncoder : IDisposable +{ + private readonly KeyEncoderSafeHandle _handle; + + public unsafe KeyEncoder() + { + nint handle; + var result = NativeMethods.ghostty_key_encoder_new(nint.Zero, &handle); + GhosttyException.ThrowIfFailure(result); + _handle = new KeyEncoderSafeHandle(handle); + } + + public void ConfigureFromTerminal(Terminal terminal) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + NativeMethods.ghostty_key_encoder_setopt_from_terminal( + _handle.DangerousGetHandle(), terminal.NativeHandle); + } + + public unsafe ReadOnlySpan Encode(KeyEvent keyEvent) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + Span buf = stackalloc byte[256]; + fixed (byte* ptr = buf) + { + nuint len; + var result = NativeMethods.ghostty_key_encoder_encode( + _handle.DangerousGetHandle(), keyEvent.NativeHandle, ptr, 256, &len); + GhosttyException.ThrowIfFailure(result); + return new ReadOnlySpan(ptr, (int)len); + } + } + + public void Dispose() => _handle.Dispose(); + + private sealed class KeyEncoderSafeHandle : GhosttySafeHandle + { + public KeyEncoderSafeHandle(nint handle) { SetHandle(handle); } + protected override void Free(nint h) => NativeMethods.ghostty_key_encoder_free(h); + public new nint DangerousGetHandle() => handle; + } +} diff --git a/src/Ghostty.Vt/KeyEvent.cs b/src/Ghostty.Vt/KeyEvent.cs new file mode 100644 index 0000000..bbdd1f2 --- /dev/null +++ b/src/Ghostty.Vt/KeyEvent.cs @@ -0,0 +1,68 @@ +using System.Text; +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public sealed class KeyEvent : IDisposable +{ + private readonly nint _handle; + + public unsafe KeyEvent() + { + nint handle; + var result = NativeMethods.ghostty_key_event_new(nint.Zero, &handle); + GhosttyException.ThrowIfFailure(result); + _handle = handle; + } + + private int _key; + /// Physical key code (GhosttyKey enum value). + public int Key + { + get => _key; + set { _key = value; NativeMethods.ghostty_key_event_set_key(_handle, value); } + } + + private int _action = 1; // press + /// Action type: 0=release, 1=press, 2=repeat. + public int Action + { + get => _action; + set { _action = value; NativeMethods.ghostty_key_event_set_action(_handle, value); } + } + + private ushort _modifiers; + /// Modifier bitmask (GHOSTTY_MODS_* values). + public ushort Modifiers + { + get => _modifiers; + set { _modifiers = value; NativeMethods.ghostty_key_event_set_mods(_handle, value); } + } + + public unsafe string? Text + { + get => _text; + set + { + _text = value; + if (value != null) + { + var bytes = Encoding.UTF8.GetBytes(value); + fixed (byte* ptr = bytes) + NativeMethods.ghostty_key_event_set_utf8(_handle, ptr, (nuint)bytes.Length); + } + } + } + private string? _text; + + // Legacy property for backwards compatibility + public uint KeyCode { get => (uint)Key; set => Key = (int)value; } + + internal nint NativeHandle => _handle; + + public void Dispose() + { + if (_handle != nint.Zero) + NativeMethods.ghostty_key_event_free(_handle); + } +} diff --git a/src/Ghostty.Vt/KittyGraphics.cs b/src/Ghostty.Vt/KittyGraphics.cs new file mode 100644 index 0000000..7422c41 --- /dev/null +++ b/src/Ghostty.Vt/KittyGraphics.cs @@ -0,0 +1,61 @@ +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public ref struct KittyGraphicsAccessor +{ + private readonly Terminal _terminal; + private nint _kittyHandle; + + internal KittyGraphicsAccessor(Terminal terminal) + { + _terminal = terminal; + _kittyHandle = nint.Zero; + } + + private unsafe nint KittyHandle + { + get + { + if (_kittyHandle == nint.Zero) + { + nint kittyHandle = nint.Zero; + // KittyGraphics handle comes from terminal_get with KittyGraphics data type + NativeMethods.ghostty_terminal_get( + _terminal.NativeHandle, + (int)Enums.TerminalData.KittyGraphics, + &kittyHandle); + _kittyHandle = kittyHandle; + } + return _kittyHandle; + } + } + + public KittyImage GetImage(uint imageId) + { + var imgHandle = NativeMethods.ghostty_kitty_graphics_image(KittyHandle, imageId); + return new KittyImage(imgHandle); + } +} + +public ref struct KittyImage +{ + private readonly nint _handle; + + internal KittyImage(nint handle) => _handle = handle; + + public unsafe uint ImageId + { + get + { + uint id; + NativeMethods.ghostty_kitty_graphics_image_get( + _handle, 1 /* GHOSTTY_KITTY_IMAGE_DATA_ID */, &id); + return id; + } + } + + public Enums.KittyImageFormat Format => throw new NotImplementedException(); + public int Width => throw new NotImplementedException(); + public int Height => throw new NotImplementedException(); +} diff --git a/src/Ghostty.Vt/MouseEncoder.cs b/src/Ghostty.Vt/MouseEncoder.cs new file mode 100644 index 0000000..5739610 --- /dev/null +++ b/src/Ghostty.Vt/MouseEncoder.cs @@ -0,0 +1,78 @@ +using Ghostty.Vt.Internals; +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public sealed class MouseEncoder : IDisposable +{ + private readonly MouseEncoderSafeHandle _handle; + + public unsafe MouseEncoder() + { + nint handle; + var result = NativeMethods.ghostty_mouse_encoder_new(nint.Zero, &handle); + GhosttyException.ThrowIfFailure(result); + _handle = new MouseEncoderSafeHandle(handle); + } + + public void ConfigureFromTerminal(Terminal terminal) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + NativeMethods.ghostty_mouse_encoder_setopt_from_terminal( + _handle.DangerousGetHandle(), terminal.NativeHandle); + } + + /// + /// Set the screen geometry for converting surface-space positions to cell coordinates. + /// Required before encoding if not already set via terminal configuration. + /// + public unsafe void SetSize(int screenWidth, int screenHeight, int cellWidth, int cellHeight, + int paddingTop = 0, int paddingBottom = 0, int paddingRight = 0, int paddingLeft = 0) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + // GhosttyMouseEncoderSize: { size_t size, uint32_t screen_width/height, cell_width/height, padding_* } + // Total: 40 bytes per type JSON + byte* buf = stackalloc byte[48]; + new Span(buf, 48).Clear(); + *(nuint*)(buf + 0) = 40; // struct size + *(uint*)(buf + 8) = (uint)screenWidth; + *(uint*)(buf + 12) = (uint)screenHeight; + *(uint*)(buf + 16) = (uint)cellWidth; + *(uint*)(buf + 20) = (uint)cellHeight; + *(uint*)(buf + 24) = (uint)paddingTop; + *(uint*)(buf + 28) = (uint)paddingBottom; + *(uint*)(buf + 32) = (uint)paddingRight; + *(uint*)(buf + 36) = (uint)paddingLeft; + NativeMethods.ghostty_mouse_encoder_setopt( + _handle.DangerousGetHandle(), 2 /* GHOSTTY_MOUSE_ENCODER_OPT_SIZE */, buf); + } + + public unsafe ReadOnlySpan Encode(MouseEvent mouseEvent) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + Span buf = stackalloc byte[64]; + fixed (byte* ptr = buf) + { + nuint len; + var result = NativeMethods.ghostty_mouse_encoder_encode( + _handle.DangerousGetHandle(), mouseEvent.NativeHandle, ptr, 64, &len); + GhosttyException.ThrowIfFailure(result); + return new ReadOnlySpan(ptr, (int)len); + } + } + + public void Reset() + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + NativeMethods.ghostty_mouse_encoder_reset(_handle.DangerousGetHandle()); + } + + public void Dispose() => _handle.Dispose(); + + private sealed class MouseEncoderSafeHandle : GhosttySafeHandle + { + public MouseEncoderSafeHandle(nint handle) { SetHandle(handle); } + protected override void Free(nint h) => NativeMethods.ghostty_mouse_encoder_free(h); + public new nint DangerousGetHandle() => handle; + } +} diff --git a/src/Ghostty.Vt/MouseEvent.cs b/src/Ghostty.Vt/MouseEvent.cs new file mode 100644 index 0000000..9db49f9 --- /dev/null +++ b/src/Ghostty.Vt/MouseEvent.cs @@ -0,0 +1,66 @@ +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public sealed class MouseEvent : IDisposable +{ + private readonly nint _handle; + + public unsafe MouseEvent() + { + nint handle; + var result = NativeMethods.ghostty_mouse_event_new(nint.Zero, &handle); + GhosttyException.ThrowIfFailure(result); + _handle = handle; + } + + private int _action; + public int Action + { + get => _action; + set { _action = value; NativeMethods.ghostty_mouse_event_set_action(_handle, value); } + } + + private int _button = -1; // -1 = no button set + public int Button + { + get => _button; + set + { + _button = value; + if (value < 0) + NativeMethods.ghostty_mouse_event_clear_button(_handle); + else + NativeMethods.ghostty_mouse_event_set_button(_handle, value); + } + } + + private int _modifiers; + public int Modifiers + { + get => _modifiers; + set { _modifiers = value; NativeMethods.ghostty_mouse_event_set_mods(_handle, value); } + } + + private float _x; + public float X + { + get => _x; + set { _x = value; NativeMethods.ghostty_mouse_event_set_position(_handle, new GhosttyMousePositionNative { X = value, Y = _y }); } + } + + private float _y; + public float Y + { + get => _y; + set { _y = value; NativeMethods.ghostty_mouse_event_set_position(_handle, new GhosttyMousePositionNative { X = _x, Y = value }); } + } + + internal nint NativeHandle => _handle; + + public void Dispose() + { + if (_handle != nint.Zero) + NativeMethods.ghostty_mouse_event_free(_handle); + } +} diff --git a/src/Ghostty.Vt/Native/NativeMethods.cs b/src/Ghostty.Vt/Native/NativeMethods.cs new file mode 100644 index 0000000..a8d9b81 --- /dev/null +++ b/src/Ghostty.Vt/Native/NativeMethods.cs @@ -0,0 +1,423 @@ +using System.Runtime.InteropServices; + +namespace Ghostty.Vt.Native; + +internal static unsafe partial class NativeMethods +{ + private const string LibraryName = "libghostty-vt"; + + // --- Terminal lifecycle --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_terminal_new( + nint allocator, nint* terminal, GhosttyTerminalOptionsNative options); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_terminal_free(nint terminal); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_terminal_reset(nint terminal); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_terminal_resize( + nint terminal, ushort cols, ushort rows, uint cell_width_px, uint cell_height_px); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_terminal_vt_write( + nint terminal, byte* data, nuint len); + + // --- Terminal state queries --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_terminal_get(nint terminal, int data, void* @out); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_terminal_get_multi( + nint terminal, nuint count, int* keys, void** values, nuint* out_written); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_terminal_set( + nint terminal, int option, void* value); + + // --- Terminal grid ref --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_terminal_grid_ref( + nint terminal, GhosttyPointNative point, GhosttyGridRefNative* out_ref); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_terminal_point_from_grid_ref( + nint terminal, GhosttyGridRefNative* grid_ref, int tag, GhosttyPointCoordinateNative* @out); + + // --- Terminal mode --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_terminal_mode_get( + nint terminal, uint mode, byte* out_value); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_terminal_mode_set( + nint terminal, uint mode, byte value); + + // --- Terminal scroll --- + + [LibraryImport(LibraryName)] + internal static partial void ghostty_terminal_scroll_viewport( + nint terminal, GhosttyTerminalScrollViewportNative behavior); + + // --- RenderState lifecycle --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_render_state_new( + nint allocator, nint* state); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_render_state_free(nint state); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_render_state_update( + nint state, nint terminal); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_render_state_get( + nint state, int data, void* @out); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_render_state_colors_get( + nint state, void* out_colors); + + // --- RenderState row iterator --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_render_state_row_iterator_new( + nint allocator, nint* out_iterator); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_render_state_row_iterator_free(nint iterator); + + [LibraryImport(LibraryName)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool ghostty_render_state_row_iterator_next(nint iterator); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_render_state_row_get( + nint iterator, int data, void* @out); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_render_state_get_multi( + nint state, nuint count, int* keys, void** values, nuint* out_written); + + // --- Formatter lifecycle --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_formatter_terminal_new( + nint allocator, nint* formatter, nint terminal, + void* options); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_formatter_free(nint formatter); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_formatter_format_buf( + nint formatter, byte* buf, nuint buf_len, nuint* out_written); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_formatter_format_alloc( + nint formatter, nint allocator, byte** out_ptr, nuint* out_len); + + // --- Key encoder --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_key_encoder_new( + nint allocator, nint* encoder); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_key_encoder_free(nint encoder); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_key_encoder_setopt( + nint encoder, int option, void* value); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_key_encoder_setopt_from_terminal( + nint encoder, nint terminal); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_key_encoder_encode( + nint encoder, nint key_event, byte* out_buf, nuint out_buf_size, nuint* out_len); + + // --- Mouse encoder --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_mouse_encoder_new( + nint allocator, nint* encoder); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_mouse_encoder_free(nint encoder); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_mouse_encoder_setopt( + nint encoder, int option, void* value); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_mouse_encoder_setopt_from_terminal( + nint encoder, nint terminal); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_mouse_encoder_reset(nint encoder); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_mouse_encoder_encode( + nint encoder, nint mouse_event, byte* out_buf, nuint out_buf_size, nuint* out_len); + + // --- Mouse event --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_mouse_event_new( + nint allocator, nint* mouse_event); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_mouse_event_free(nint mouse_event); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_mouse_event_set_action(nint mouse_event, int action); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_mouse_event_set_button(nint mouse_event, int button); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_mouse_event_clear_button(nint mouse_event); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_mouse_event_set_mods(nint mouse_event, int mods); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_mouse_event_set_position(nint mouse_event, GhosttyMousePositionNative position); + + // --- Key event --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_key_event_new( + nint allocator, nint* key_event); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_key_event_free(nint key_event); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_key_event_set_action(nint key_event, int action); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_key_event_set_key(nint key_event, int key); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_key_event_set_mods(nint key_event, ushort mods); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_key_event_set_utf8(nint key_event, byte* utf8, nuint len); + + // --- OSC parser (note: function names changed from ghostty_osc_parser_* to ghostty_osc_*) --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_osc_new( + nint allocator, nint* parser); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_osc_free(nint parser); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_osc_reset(nint parser); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_osc_next( + nint parser, byte b); + + [LibraryImport(LibraryName)] + internal static partial nint ghostty_osc_end( + nint parser, byte terminator); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_osc_command_type(nint command); + + [LibraryImport(LibraryName)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool ghostty_osc_command_data( + nint command, int data, void* @out); + + // --- SGR parser (note: function names changed from ghostty_sgr_parser_* to ghostty_sgr_*) --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_sgr_new( + nint allocator, nint* parser); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_sgr_free(nint parser); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_sgr_reset(nint parser); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_sgr_set_params( + nint parser, ushort* parameters, byte* separators, nuint len); + + [LibraryImport(LibraryName)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool ghostty_sgr_next(nint parser, void* attr); + + // --- Kitty graphics --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_kitty_graphics_get( + nint kitty, int data, void* @out); + + [LibraryImport(LibraryName)] + internal static partial nint ghostty_kitty_graphics_image( + nint kitty, uint image_id); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_kitty_graphics_image_get( + nint image, int data, void* @out); + + // --- Build info --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_build_info(int data, void* @out); + + // --- Type introspection --- + + [LibraryImport(LibraryName)] + internal static partial byte* ghostty_type_json(); + + // --- Allocator --- + + [LibraryImport(LibraryName)] + internal static partial byte* ghostty_alloc(nint allocator, nuint len); + + [LibraryImport(LibraryName)] + internal static partial void ghostty_free(nint allocator, byte* ptr, nuint len); + + // --- Focus --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_focus_encode( + int @event, byte* buf, nuint buf_len, nuint* out_written); + + // --- Paste --- + + [LibraryImport(LibraryName)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool ghostty_paste_is_safe(byte* data, nuint len); + + [LibraryImport(LibraryName)] + internal static partial int ghostty_paste_encode( + byte* data, nuint data_len, [MarshalAs(UnmanagedType.Bool)] bool bracketed, + byte* buf, nuint buf_len, nuint* out_written); + + // --- Size report --- + + [LibraryImport(LibraryName)] + internal static partial int ghostty_size_report_encode( + int style, GhosttySizeReportSizeNative size, + byte* buf, nuint buf_len, nuint* out_written); +} + +// Native struct matching GhosttyTerminalOptions: { uint16_t cols, uint16_t rows, size_t max_scrollback } +[StructLayout(LayoutKind.Sequential)] +internal struct GhosttyTerminalOptionsNative +{ + public ushort Cols; + public ushort Rows; + public nuint MaxScrollback; +} + +// Native struct matching GhosttyPoint: { GhosttyPointTag tag, GhosttyPointValue value } +// GhosttyPointValue is a union { GhosttyPointCoordinate coordinate, uint64_t _padding[2] } = 16 bytes +// GhosttyPointCoordinate is { uint16_t x, uint32_t y } = 8 bytes +// Total: 4 (tag) + 4 (pad) + 16 (union) = 24 bytes +[StructLayout(LayoutKind.Sequential)] +internal struct GhosttyPointNative +{ + public int Tag; + private int _pad0; // alignment padding for 8-byte aligned union + public ushort X; // inside union, at union offset 0 + private ushort _pad1; // padding after x for y's 4-byte alignment + public uint Y; // inside union, at union offset 4 + private ulong _pad2; // remaining 8 bytes of 16-byte union +} + +// Native struct matching GhosttyPointCoordinate: { uint16_t x, uint32_t y } +[StructLayout(LayoutKind.Sequential)] +internal struct GhosttyPointCoordinateNative +{ + public ushort X; + private ushort _pad; + public uint Y; +} + +// Native struct matching GhosttyTerminalScrollViewport: { tag (int), value (union with intptr_t delta + padding) } +[StructLayout(LayoutKind.Sequential)] +internal struct GhosttyTerminalScrollViewportNative +{ + public int Tag; // GhosttyTerminalScrollViewportTag + public nint Delta; // intptr_t delta (in the union) + public nuint _padding1; // padding for the union +} + +// Native struct matching GhosttyFormatterTerminalOptions (sized struct) +[StructLayout(LayoutKind.Sequential)] +internal struct GhosttyFormatterTerminalOptionsNative +{ + public nuint Size; // sizeof(GhosttyFormatterTerminalOptions) + public int Emit; // GhosttyFormatterFormat enum + public byte Unwrap; + public byte Trim; + // GhosttyFormatterTerminalExtra extra — large struct, zero for now (all extras disabled) + // We set size to just cover the fields we use +} + +// Native struct matching GhosttySizeReportSize: { uint16_t rows, uint16_t columns, uint32_t cell_width, uint32_t cell_height } +[StructLayout(LayoutKind.Sequential)] +internal struct GhosttySizeReportSizeNative +{ + public ushort Rows; + public ushort Columns; + public uint CellWidth; + public uint CellHeight; +} + +// Native struct matching GhosttyString: { const uint8_t* ptr, size_t len } +[StructLayout(LayoutKind.Sequential)] +internal struct GhosttyStringNative +{ + public nint Ptr; + public nuint Len; +} + +// Native struct matching GhosttyGridRef: { size_t size, void* node, uint16_t x, uint16_t y } +[StructLayout(LayoutKind.Sequential)] +internal struct GhosttyGridRefNative +{ + public nuint Size; + public nint Node; + public ushort X; + public ushort Y; +} + +// Legacy alias for backward compat within the codebase +[StructLayout(LayoutKind.Sequential)] +internal struct PointNative +{ + public int Tag; + private int _pad0; + public ushort X; + private ushort _pad1; + public uint Y; + private ulong _pad2; +} + +// Native struct matching GhosttyMousePosition: { float x, float y } +[StructLayout(LayoutKind.Sequential)] +internal struct GhosttyMousePositionNative +{ + public float X; + public float Y; +} diff --git a/src/Ghostty.Vt/OscParser.cs b/src/Ghostty.Vt/OscParser.cs new file mode 100644 index 0000000..0eec91f --- /dev/null +++ b/src/Ghostty.Vt/OscParser.cs @@ -0,0 +1,82 @@ +using Ghostty.Vt.Enums; +using Ghostty.Vt.Internals; +using Ghostty.Vt.Native; +using Ghostty.Vt.Types; + +namespace Ghostty.Vt; + +public sealed class OscParser : IDisposable +{ + private readonly OscParserSafeHandle _handle; + private nint _lastCommand; + + public unsafe OscParser() + { + nint handle; + var result = NativeMethods.ghostty_osc_new(nint.Zero, &handle); + GhosttyException.ThrowIfFailure(result); + _handle = new OscParserSafeHandle(handle); + _lastCommand = nint.Zero; + } + + public void Next(byte b) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + NativeMethods.ghostty_osc_next(_handle.DangerousGetHandle(), b); + } + + public OscCommandType End(byte terminator = 0x07) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + _lastCommand = NativeMethods.ghostty_osc_end( + _handle.DangerousGetHandle(), terminator); + return (OscCommandType)NativeMethods.ghostty_osc_command_type(_lastCommand); + } + + public OscCommandType CommandType + { + get + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + return (OscCommandType)NativeMethods.ghostty_osc_command_type(_lastCommand); + } + } + + public unsafe GhosttyString CommandData + { + get + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + nint strPtr; + if (!NativeMethods.ghostty_osc_command_data( + _lastCommand, (int)OscCommandData.ChangeWindowTitleStr, &strPtr)) + { + return new GhosttyString(nint.Zero, 0); + } + return new GhosttyString(strPtr, 0); // null-terminated string + } + } + + public void Reset() + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + NativeMethods.ghostty_osc_reset(_handle.DangerousGetHandle()); + _lastCommand = nint.Zero; + } + + public void Dispose() => _handle.Dispose(); + + private sealed class OscParserSafeHandle : GhosttySafeHandle + { + public OscParserSafeHandle(nint handle) { SetHandle(handle); } + protected override void Free(nint h) => NativeMethods.ghostty_osc_free(h); + public new nint DangerousGetHandle() => handle; + } +} + +// OSC command data enum values +public enum OscCommandData +{ + Invalid = 0, + ChangeWindowTitleStr = 1, +} diff --git a/src/Ghostty.Vt/Paste.cs b/src/Ghostty.Vt/Paste.cs new file mode 100644 index 0000000..ff5a017 --- /dev/null +++ b/src/Ghostty.Vt/Paste.cs @@ -0,0 +1,33 @@ +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public static class Paste +{ + public static unsafe bool IsSafe(ReadOnlySpan data) + { + fixed (byte* ptr = data) + { + return NativeMethods.ghostty_paste_is_safe(ptr, (nuint)data.Length); + } + } + + public static unsafe byte[] Encode(ReadOnlySpan data, bool bracketed = true) + { + // Worst case: bracketed paste adds prefix + suffix around the data + int maxLen = data.Length + 64; + byte[] buf = new byte[maxLen]; + fixed (byte* srcPtr = data) + fixed (byte* dstPtr = buf) + { + nuint written; + var result = NativeMethods.ghostty_paste_encode( + srcPtr, (nuint)data.Length, bracketed, dstPtr, (nuint)maxLen, &written); + GhosttyException.ThrowIfFailure(result); + if (written == 0) return []; + var bytes = new byte[(int)written]; + new ReadOnlySpan(dstPtr, (int)written).CopyTo(bytes); + return bytes; + } + } +} diff --git a/src/Ghostty.Vt/RenderState.cs b/src/Ghostty.Vt/RenderState.cs new file mode 100644 index 0000000..0659c15 --- /dev/null +++ b/src/Ghostty.Vt/RenderState.cs @@ -0,0 +1,202 @@ +using Ghostty.Vt.Enums; +using Ghostty.Vt.Internals; +using Ghostty.Vt.Native; +using Ghostty.Vt.Types; + +namespace Ghostty.Vt; + +public sealed class RenderState : IDisposable +{ + private readonly RenderStateSafeHandle _handle; + + public unsafe RenderState() + { + nint handle; + var result = NativeMethods.ghostty_render_state_new(nint.Zero, &handle); + GhosttyException.ThrowIfFailure(result); + _handle = new RenderStateSafeHandle(handle); + } + + public void Update(Terminal terminal) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + var result = NativeMethods.ghostty_render_state_update( + _handle.DangerousGetHandle(), terminal.NativeHandle); + GhosttyException.ThrowIfFailure(result); + } + + public unsafe RenderStateDirty Dirty + { + get + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + int value; + NativeMethods.ghostty_render_state_get( + _handle.DangerousGetHandle(), (int)RenderStateData.Dirty, &value); + return (RenderStateDirty)value; + } + } + + public unsafe RenderStateColors Colors + { + get + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + // GhosttyRenderStateColors: { size_t size(8), background(3), foreground(3), cursor(3), cursor_has_value(1), palette[256](768) } = 792 bytes + const int StructSize = 792; + byte* buf = stackalloc byte[StructSize]; + new Span(buf, StructSize).Clear(); + *(nuint*)(buf + 0) = StructSize; // size field + + var result = NativeMethods.ghostty_render_state_colors_get( + _handle.DangerousGetHandle(), buf); + GhosttyException.ThrowIfFailure(result); + + // Read fields at exact offsets per type JSON: + // background@8(3), foreground@11(3), cursor@14(3), cursor_has_value@17(1), palette@18(768) + return new RenderStateColors + { + Background = new ColorRgb { R = buf[8], G = buf[9], B = buf[10] }, + Foreground = new ColorRgb { R = buf[11], G = buf[12], B = buf[13] }, + Cursor = new ColorRgb { R = buf[14], G = buf[15], B = buf[16] }, + CursorHasValue = buf[17] != 0, + }; + } + } + + public RenderStateRowEnumerable Rows + { + get + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + return new RenderStateRowEnumerable(_handle.DangerousGetHandle()); + } + } + + internal nint NativeHandle => _handle.DangerousGetHandle(); + + public void Dispose() => _handle.Dispose(); + + private sealed class RenderStateSafeHandle : GhosttySafeHandle + { + public RenderStateSafeHandle(nint handle) { SetHandle(handle); } + protected override void Free(nint handle) => NativeMethods.ghostty_render_state_free(handle); + public new nint DangerousGetHandle() => handle; + } +} + +public enum RenderStateData +{ + Invalid = 0, + Cols = 1, + Rows = 2, + Dirty = 3, + RowIterator = 4, + ColorBackground = 5, + ColorForeground = 6, + ColorCursor = 7, + ColorCursorHasValue = 8, + ColorPalette = 9, + CursorVisualStyle = 10, + CursorVisible = 11, + CursorBlinking = 12, + CursorPasswordInput = 13, + CursorViewportHasValue = 14, + CursorViewportX = 15, + CursorViewportY = 16, + CursorViewportWideTail = 17, +} + +public ref struct RenderStateRowEnumerable +{ + private readonly nint _state; + internal RenderStateRowEnumerable(nint state) => _state = state; + public RenderStateRowEnumerator GetEnumerator() => new(_state); +} + +public ref struct RenderStateRowEnumerator +{ + private readonly nint _state; + private nint _iterator; + private bool _started; + private bool _hasCurrent; + + internal RenderStateRowEnumerator(nint state) { _state = state; _iterator = 0; _started = false; _hasCurrent = false; } + + public unsafe bool MoveNext() + { + if (!_started) + { + // Create the iterator handle + nint iter; + var result = NativeMethods.ghostty_render_state_row_iterator_new(nint.Zero, &iter); + GhosttyException.ThrowIfFailure(result); + + // Populate iterator with row data from render state. + // ghostty_render_state_get(state, ROW_ITERATOR, out) expects + // GhosttyRenderStateRowIterator* = nint* (pointer to the opaque handle). + result = NativeMethods.ghostty_render_state_get( + _state, (int)RenderStateData.RowIterator, &iter); + GhosttyException.ThrowIfFailure(result); + _iterator = iter; + _started = true; + } + + _hasCurrent = NativeMethods.ghostty_render_state_row_iterator_next(_iterator); + return _hasCurrent; + } + + public unsafe RenderStateRow Current + { + get + { + // Read dirty flag for current row + byte dirty = 0; + NativeMethods.ghostty_render_state_row_get( + _iterator, 1 /* ROW_DATA_DIRTY */, &dirty); + return new RenderStateRow + { + Dirty = dirty != 0, + Cells = new RenderStateCellEnumerable(_iterator, 0), + }; + } + } + + public void Dispose() + { + if (_iterator != 0) + { + NativeMethods.ghostty_render_state_row_iterator_free(_iterator); + _iterator = 0; + } + } +} + +public ref struct RenderStateRow +{ + public bool Dirty { get; init; } + public int Index { get; init; } + public RenderStateCellEnumerable Cells { get; init; } +} + +public ref struct RenderStateCellEnumerable +{ + private readonly nint _state; + private readonly int _rowIndex; + + internal RenderStateCellEnumerable(nint state, int rowIndex) + { _state = state; _rowIndex = rowIndex; } + + public RenderStateCellEnumerator GetEnumerator() => new(_state, _rowIndex); +} + +public ref struct RenderStateCellEnumerator +{ + private readonly nint _state; + private readonly int _rowIndex; + internal RenderStateCellEnumerator(nint state, int rowIndex) + { _state = state; _rowIndex = rowIndex; } + + public bool MoveNext() => throw new NotImplementedException(); + public Cell Current => throw new NotImplementedException(); +} diff --git a/src/Ghostty.Vt/SgrParser.cs b/src/Ghostty.Vt/SgrParser.cs new file mode 100644 index 0000000..f59a272 --- /dev/null +++ b/src/Ghostty.Vt/SgrParser.cs @@ -0,0 +1,83 @@ +using Ghostty.Vt.Enums; +using Ghostty.Vt.Internals; +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public sealed class SgrParser : IDisposable +{ + private readonly SgrParserSafeHandle _handle; + private int _lastTag; + + public unsafe SgrParser() + { + nint handle; + var result = NativeMethods.ghostty_sgr_new(nint.Zero, &handle); + GhosttyException.ThrowIfFailure(result); + _handle = new SgrParserSafeHandle(handle); + _lastTag = 0; + } + + public unsafe void SetParameters(ReadOnlySpan parameters) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + // Allocate separators buffer filled with ';' for each parameter + Span seps = parameters.Length > 0 ? new byte[parameters.Length] : []; + for (int i = 0; i < seps.Length; i++) seps[i] = (byte)';'; + + fixed (ushort* ptr = parameters) + fixed (byte* sepPtr = seps) + { + var result = NativeMethods.ghostty_sgr_set_params( + _handle.DangerousGetHandle(), ptr, sepPtr, (nuint)parameters.Length); + GhosttyException.ThrowIfFailure(result); + } + _lastTag = 0; + } + + public unsafe bool Next() + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + // GhosttySgrAttribute is { GhosttySgrAttributeTag tag (int), GhosttySgrAttributeValue value (union) } + // GhosttySgrAttributeValue union has _padding[8] of uint64_t = 64 bytes + // Total: 4 (tag) + 4 (padding) + 64 (union) = 72 bytes on 64-bit + int* attrBuf = stackalloc int[18]; // 72 bytes + bool hasMore = NativeMethods.ghostty_sgr_next(_handle.DangerousGetHandle(), attrBuf); + _lastTag = hasMore ? attrBuf[0] : 0; + return hasMore; + } + + public SgrAttributeTag AttributeTag + { + get + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + return (SgrAttributeTag)_lastTag; + } + } + + public uint AttributeValue + { + get + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + return default; + } + } + + public void Reset() + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + NativeMethods.ghostty_sgr_reset(_handle.DangerousGetHandle()); + _lastTag = 0; + } + + public void Dispose() => _handle.Dispose(); + + private sealed class SgrParserSafeHandle : GhosttySafeHandle + { + public SgrParserSafeHandle(nint handle) { SetHandle(handle); } + protected override void Free(nint h) => NativeMethods.ghostty_sgr_free(h); + public new nint DangerousGetHandle() => handle; + } +} diff --git a/src/Ghostty.Vt/SizeReport.cs b/src/Ghostty.Vt/SizeReport.cs new file mode 100644 index 0000000..1c2d28b --- /dev/null +++ b/src/Ghostty.Vt/SizeReport.cs @@ -0,0 +1,29 @@ +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public static class SizeReport +{ + public static unsafe byte[] Encode(uint cols, uint rows, uint widthPx, uint heightPx) + { + Span buf = stackalloc byte[64]; + fixed (byte* ptr = buf) + { + var size = new GhosttySizeReportSizeNative + { + Rows = (ushort)rows, + Columns = (ushort)cols, + CellWidth = widthPx, + CellHeight = heightPx, + }; + nuint written; + var result = NativeMethods.ghostty_size_report_encode( + 0 /* GHOSTTY_SIZE_REPORT_MODE_2048 */, size, ptr, 64, &written); + GhosttyException.ThrowIfFailure(result); + if (written == 0) return []; + var bytes = new byte[(int)written]; + new ReadOnlySpan(ptr, (int)written).CopyTo(bytes); + return bytes; + } + } +} diff --git a/src/Ghostty.Vt/Terminal.cs b/src/Ghostty.Vt/Terminal.cs new file mode 100644 index 0000000..7d8bdaf --- /dev/null +++ b/src/Ghostty.Vt/Terminal.cs @@ -0,0 +1,302 @@ +using System.Runtime.InteropServices; +using Ghostty.Vt.Enums; +using Ghostty.Vt.Internals; +using Ghostty.Vt.Native; +using Ghostty.Vt.Types; + +namespace Ghostty.Vt; + +public sealed unsafe class Terminal : IDisposable +{ + private readonly TerminalSafeHandle _handle; + private readonly TerminalOptions? _options; + + public Terminal(int cols, int rows, Action? configure = null) + { + if (cols <= 0) throw new ArgumentOutOfRangeException(nameof(cols)); + if (rows <= 0) throw new ArgumentOutOfRangeException(nameof(rows)); + + var options = new TerminalOptions(); + configure?.Invoke(options); + _options = options; + + var nativeOpts = options.BuildNativeOptions(cols, rows); + nint handle = nint.Zero; + var result = NativeMethods.ghostty_terminal_new( + nint.Zero, // default allocator + &handle, + nativeOpts); + if (result != 0 || handle == nint.Zero) + throw new GhosttyException($"Failed to create terminal (result={result})"); + + _handle = new TerminalSafeHandle(handle); + + // Register callbacks via ghostty_terminal_set + RegisterCallbacks(options, handle); + } + + private unsafe void RegisterCallbacks(TerminalOptions options, nint handle) + { + if (options.OnWritePty is not null) + { + var del = new GhosttyTerminalWritePtyFn((_, _, data, len) => + { + var span = new ReadOnlySpan(data, (int)len); + options.OnWritePty(span); + }); + options.Pinner.Pin(del); + NativeMethods.ghostty_terminal_set(handle, 1 /* GHOSTTY_TERMINAL_OPT_WRITE_PTY */, (void*)Marshal.GetFunctionPointerForDelegate(del)); + } + + if (options.OnBell is not null) + { + var del = new GhosttyTerminalBellFn((_, _) => options.OnBell()); + options.Pinner.Pin(del); + NativeMethods.ghostty_terminal_set(handle, 2 /* GHOSTTY_TERMINAL_OPT_BELL */, (void*)Marshal.GetFunctionPointerForDelegate(del)); + } + + if (options.OnTitleChanged is not null) + { + var del = new GhosttyTerminalTitleChangedFn((_, _) => options.OnTitleChanged()); + options.Pinner.Pin(del); + NativeMethods.ghostty_terminal_set(handle, 5 /* GHOSTTY_TERMINAL_OPT_TITLE_CHANGED */, (void*)Marshal.GetFunctionPointerForDelegate(del)); + } + + // PwdChanged is not a native callback — it's observed via OnTitleChanged + reading Pwd. + // The native API doesn't have a dedicated PWD callback. + } + + // --- VT Input --- + public unsafe void VTWrite(ReadOnlySpan data) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + fixed (byte* ptr = data) + { + NativeMethods.ghostty_terminal_vt_write( + _handle.DangerousGetHandle(), ptr, (nuint)data.Length); + } + } + + public void VTWrite(byte[] data) => VTWrite(data.AsSpan()); + + public void VTWrite(string text) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(text); + VTWrite(bytes); + } + + // --- State queries (typed properties) --- + public int Cols => QueryInt(TerminalData.Cols); + public int Rows => QueryInt(TerminalData.Rows); + public int CursorX => QueryInt(TerminalData.CursorX); + public int CursorY => QueryInt(TerminalData.CursorY); + public bool CursorPendingWrap => QueryInt(TerminalData.CursorPendingWrap) != 0; + public bool CursorVisible => QueryInt(TerminalData.CursorVisible) != 0; + public TerminalScreen ActiveScreen => (TerminalScreen)QueryInt(TerminalData.ActiveScreen); + public int ScrollOffset => (int)QueryScrollbar().Offset; + + public string? Title + { + get + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + return QueryString(TerminalData.Title); + } + } + + public string? Pwd + { + get + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + return QueryString(TerminalData.Pwd); + } + } + + public unsafe void SetPwd(string? pwd) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + if (pwd == null) + { + NativeMethods.ghostty_terminal_set( + _handle.DangerousGetHandle(), 10 /* OPT_PWD */, null); + return; + } + var bytes = System.Text.Encoding.UTF8.GetBytes(pwd); + fixed (byte* ptr = bytes) + { + GhosttyStringNative gs; + gs.Ptr = (nint)ptr; + gs.Len = (nuint)bytes.Length; + NativeMethods.ghostty_terminal_set( + _handle.DangerousGetHandle(), 10 /* OPT_PWD */, &gs); + } + } + + // --- Operations --- + public void Resize(int cols, int rows, int cellWidthPx = 0, int cellHeightPx = 0) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + NativeMethods.ghostty_terminal_resize( + _handle.DangerousGetHandle(), + (ushort)cols, (ushort)rows, + (uint)cellWidthPx, (uint)cellHeightPx); + } + + public void Reset() + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + NativeMethods.ghostty_terminal_reset(_handle.DangerousGetHandle()); + } + + public bool ModeGet(TerminalMode mode) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + byte value = 0; + NativeMethods.ghostty_terminal_mode_get( + _handle.DangerousGetHandle(), (uint)mode, &value); + return value != 0; + } + + public void ModeSet(TerminalMode mode, bool value) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + NativeMethods.ghostty_terminal_mode_set( + _handle.DangerousGetHandle(), (uint)mode, (byte)(value ? 1 : 0)); + } + + public void ScrollViewportToTop() + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + var behavior = new GhosttyTerminalScrollViewportNative { Tag = 0 }; // GHOSTTY_SCROLL_VIEWPORT_TOP + NativeMethods.ghostty_terminal_scroll_viewport(_handle.DangerousGetHandle(), behavior); + } + + public void ScrollViewportToBottom() + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + var behavior = new GhosttyTerminalScrollViewportNative { Tag = 1 }; // GHOSTTY_SCROLL_VIEWPORT_BOTTOM + NativeMethods.ghostty_terminal_scroll_viewport(_handle.DangerousGetHandle(), behavior); + } + + public void ScrollViewportBy(int delta) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + var behavior = new GhosttyTerminalScrollViewportNative + { + Tag = 2, // GHOSTTY_SCROLL_VIEWPORT_DELTA + Delta = (nint)delta, + }; + NativeMethods.ghostty_terminal_scroll_viewport(_handle.DangerousGetHandle(), behavior); + } + + // --- Grid access --- + public unsafe GridRef GetGridRef(Point point) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + var nativePoint = new GhosttyPointNative { Tag = point.NativeTag, X = point.NativeX, Y = point.NativeY }; + // Sized struct: must initialize size before calling + var gridRef = new GhosttyGridRefNative { Size = (nuint)sizeof(GhosttyGridRefNative) }; + NativeMethods.ghostty_terminal_grid_ref( + _handle.DangerousGetHandle(), nativePoint, &gridRef); + return new GridRef(gridRef, this); + } + + public unsafe Point PointFromGridRef(GridRef gridRef) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + GhosttyGridRefNative nativeRef = gridRef.Native; + // Ensure size is set for the sized struct + nativeRef.Size = (nuint)sizeof(GhosttyGridRefNative); + GhosttyPointCoordinateNative coord; + NativeMethods.ghostty_terminal_point_from_grid_ref( + _handle.DangerousGetHandle(), + &nativeRef, + 0, // active tag + &coord); + return new Point { Tag = (PointTag)0, X = coord.X, Y = (int)coord.Y }; + } + + // --- Formatter factory --- + public Formatter CreateFormatter(FormatterFormat format, Action? configure = null) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + return new Formatter(_handle.DangerousGetHandle(), format, configure); + } + + // --- Kitty graphics accessor (borrowed, ref struct) --- + public KittyGraphicsAccessor KittyGraphics + { + get + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + return new KittyGraphicsAccessor(this); + } + } + + // --- Internal --- + internal nint NativeHandle => _handle.DangerousGetHandle(); + + private unsafe int QueryInt(TerminalData data) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + // Allocate 8 bytes to safely receive any scalar type: + // uint16_t, bool, int enum, size_t, uint32_t + long value = 0; + NativeMethods.ghostty_terminal_get( + _handle.DangerousGetHandle(), (int)data, &value); + return (int)value; + } + + private unsafe string? QueryString(TerminalData data) + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + GhosttyStringNative gs; + NativeMethods.ghostty_terminal_get( + _handle.DangerousGetHandle(), (int)data, &gs); + if (gs.Ptr == 0 || gs.Len == 0) return null; + return System.Runtime.InteropServices.Marshal.PtrToStringUTF8(gs.Ptr, (int)gs.Len); + } + + private unsafe (ulong Total, ulong Offset, ulong Len) QueryScrollbar() + { + ObjectDisposedException.ThrowIf(_handle.IsInvalid, this); + GhosttyScrollbarNative sb; + NativeMethods.ghostty_terminal_get( + _handle.DangerousGetHandle(), 9 /* GHOSTTY_TERMINAL_DATA_SCROLLBAR */, &sb); + return (sb.Total, sb.Offset, sb.Len); + } + + public void Dispose() + { + _handle.Dispose(); + _options?.Pinner.Dispose(); + } + + // Nested SafeHandle + private sealed class TerminalSafeHandle : GhosttySafeHandle + { + public TerminalSafeHandle(nint handle) { SetHandle(handle); } + protected override void Free(nint handle) => NativeMethods.ghostty_terminal_free(handle); + public new nint DangerousGetHandle() => handle; + } + + // Callback delegate types matching native signatures + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private unsafe delegate void GhosttyTerminalWritePtyFn(nint terminal, void* userdata, byte* data, nuint len); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private unsafe delegate void GhosttyTerminalBellFn(nint terminal, void* userdata); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private unsafe delegate void GhosttyTerminalTitleChangedFn(nint terminal, void* userdata); +} + +// GhosttyScrollbar: { uint64_t total, uint64_t offset, uint64_t len } +[StructLayout(LayoutKind.Sequential)] +internal struct GhosttyScrollbarNative +{ + public ulong Total; + public ulong Offset; + public ulong Len; +} diff --git a/src/Ghostty.Vt/TerminalOptions.cs b/src/Ghostty.Vt/TerminalOptions.cs new file mode 100644 index 0000000..3de6543 --- /dev/null +++ b/src/Ghostty.Vt/TerminalOptions.cs @@ -0,0 +1,28 @@ +using System.Runtime.InteropServices; +using Ghostty.Vt.Internals; +using Ghostty.Vt.Native; + +namespace Ghostty.Vt; + +public sealed class TerminalOptions +{ + internal DelegatePinner Pinner { get; } = new(); + + // Callbacks — invoked synchronously during VTWrite + public Action>? OnWritePty { get; set; } + public Action? OnBell { get; set; } + public Action? OnTitleChanged { get; set; } + public Action? OnPwdChanged { get; set; } + + // Build the native options struct (cols, rows, max_scrollback). + // Callbacks are registered after terminal creation via ghostty_terminal_set. + internal GhosttyTerminalOptionsNative BuildNativeOptions(int cols, int rows) + { + return new GhosttyTerminalOptionsNative + { + Cols = (ushort)cols, + Rows = (ushort)rows, + MaxScrollback = (nuint)1000, // default scrollback + }; + } +} diff --git a/src/Ghostty.Vt/Types/Cell.cs b/src/Ghostty.Vt/Types/Cell.cs new file mode 100644 index 0000000..bac8812 --- /dev/null +++ b/src/Ghostty.Vt/Types/Cell.cs @@ -0,0 +1,11 @@ +using Ghostty.Vt.Enums; + +namespace Ghostty.Vt.Types; + +public readonly struct Cell +{ + public CellContentTag ContentTag { get; init; } + public string? Grapheme { get; init; } + public Style Style { get; init; } + public uint KittyPlacementId { get; init; } +} diff --git a/src/Ghostty.Vt/Types/ColorRgb.cs b/src/Ghostty.Vt/Types/ColorRgb.cs new file mode 100644 index 0000000..9ab3ad5 --- /dev/null +++ b/src/Ghostty.Vt/Types/ColorRgb.cs @@ -0,0 +1,8 @@ +namespace Ghostty.Vt.Types; + +public readonly struct ColorRgb +{ + public byte R { get; init; } + public byte G { get; init; } + public byte B { get; init; } +} diff --git a/src/Ghostty.Vt/Types/GhosttyString.cs b/src/Ghostty.Vt/Types/GhosttyString.cs new file mode 100644 index 0000000..d3b3693 --- /dev/null +++ b/src/Ghostty.Vt/Types/GhosttyString.cs @@ -0,0 +1,22 @@ +using System.Runtime.InteropServices; + +namespace Ghostty.Vt.Types; + +public readonly ref struct GhosttyString +{ + private readonly nint _ptr; + private readonly nuint _len; + + internal GhosttyString(nint ptr, nuint len) + { + _ptr = ptr; + _len = len; + } + + public override string ToString() + { + if (_ptr == nint.Zero || _len == 0) return string.Empty; + var span = Marshal.PtrToStringUTF8(_ptr, (int)_len); + return span ?? string.Empty; + } +} diff --git a/src/Ghostty.Vt/Types/Point.cs b/src/Ghostty.Vt/Types/Point.cs new file mode 100644 index 0000000..c2e8323 --- /dev/null +++ b/src/Ghostty.Vt/Types/Point.cs @@ -0,0 +1,19 @@ +using Ghostty.Vt.Enums; + +namespace Ghostty.Vt.Types; + +public readonly struct Point +{ + public PointTag Tag { get; init; } + public int X { get; init; } + public int Y { get; init; } + + public static Point Active(int x, int y) => new() { Tag = PointTag.Active, X = x, Y = y }; + public static Point Viewport(int x, int y) => new() { Tag = PointTag.Viewport, X = x, Y = y }; + public static Point Screen(int x, int y) => new() { Tag = PointTag.Screen, X = x, Y = y }; + public static Point History(int x, int y) => new() { Tag = PointTag.History, X = x, Y = y }; + + internal int NativeTag => (int)Tag; + internal ushort NativeX => (ushort)X; + internal uint NativeY => (uint)Y; +} diff --git a/src/Ghostty.Vt/Types/RenderStateColors.cs b/src/Ghostty.Vt/Types/RenderStateColors.cs new file mode 100644 index 0000000..d9f398c --- /dev/null +++ b/src/Ghostty.Vt/Types/RenderStateColors.cs @@ -0,0 +1,11 @@ +using Ghostty.Vt.Types; + +namespace Ghostty.Vt; + +public readonly struct RenderStateColors +{ + public ColorRgb Foreground { get; init; } + public ColorRgb Background { get; init; } + public ColorRgb Cursor { get; init; } + public bool CursorHasValue { get; init; } +} diff --git a/src/Ghostty.Vt/Types/Row.cs b/src/Ghostty.Vt/Types/Row.cs new file mode 100644 index 0000000..3793afc --- /dev/null +++ b/src/Ghostty.Vt/Types/Row.cs @@ -0,0 +1,7 @@ +namespace Ghostty.Vt.Types; + +public readonly struct Row +{ + public int Index { get; init; } + public bool Dirty { get; init; } +} diff --git a/src/Ghostty.Vt/Types/Style.cs b/src/Ghostty.Vt/Types/Style.cs new file mode 100644 index 0000000..e37216b --- /dev/null +++ b/src/Ghostty.Vt/Types/Style.cs @@ -0,0 +1,20 @@ +using System.Runtime.InteropServices; + +namespace Ghostty.Vt.Types; + +[StructLayout(LayoutKind.Sequential)] +public struct Style +{ + public nuint Size; // leading size field for ABI forward-compat + public uint FgColor; + public uint BgColor; + public uint UnderlineColor; + public byte Bold; + public byte Dim; + public byte Italic; + public byte Underline; + public byte Blink; + public byte Inverse; + public byte Invisible; + public byte Strikethrough; +} diff --git a/tests/Ghostty.Vt.Tests/BuildInfoTests.cs b/tests/Ghostty.Vt.Tests/BuildInfoTests.cs new file mode 100644 index 0000000..8000579 --- /dev/null +++ b/tests/Ghostty.Vt.Tests/BuildInfoTests.cs @@ -0,0 +1,20 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class BuildInfoTests +{ + [Fact] + public void Query_ReturnsNonEmptyVersion() + { + var info = BuildInfo.Query(); + Assert.False(string.IsNullOrEmpty(info.Version)); + } + + [Fact] + public void Query_ReturnsNonEmptyZigVersion() + { + var info = BuildInfo.Query(); + Assert.False(string.IsNullOrEmpty(info.ZigVersion)); + } +} diff --git a/tests/Ghostty.Vt.Tests/FocusPasteSizeReportTests.cs b/tests/Ghostty.Vt.Tests/FocusPasteSizeReportTests.cs new file mode 100644 index 0000000..dc9722e --- /dev/null +++ b/tests/Ghostty.Vt.Tests/FocusPasteSizeReportTests.cs @@ -0,0 +1,46 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class FocusPasteSizeReportTests +{ + [Fact] + public void Focus_EncodeFocused_ProducesSequence() + { + var result = Focus.Encode(focused: true); + Assert.True(result.Length > 0); + } + + [Fact] + public void Focus_EncodeUnfocused_ProducesSequence() + { + var result = Focus.Encode(focused: false); + Assert.True(result.Length > 0); + } + + [Fact] + public void Paste_IsSafe_PlainAscii_ReturnsTrue() + { + Assert.True(Paste.IsSafe("Hello World"u8)); + } + + [Fact] + public void Paste_IsSafe_ContainsNewline_ReturnsFalse() + { + Assert.False(Paste.IsSafe("Hello\nWorld"u8)); + } + + [Fact] + public void Paste_Encode_WrapsInBrackets() + { + var encoded = Paste.Encode("test"u8); + Assert.True(encoded.Length > 4); + } + + [Fact] + public void SizeReport_Encode_ProducesSequence() + { + var result = SizeReport.Encode(80, 24, 640, 384); + Assert.True(result.Length > 0); + } +} diff --git a/tests/Ghostty.Vt.Tests/FormatterTests.cs b/tests/Ghostty.Vt.Tests/FormatterTests.cs new file mode 100644 index 0000000..4d8febf --- /dev/null +++ b/tests/Ghostty.Vt.Tests/FormatterTests.cs @@ -0,0 +1,39 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class FormatterTests +{ + [Fact] + public void PlainText_ExtractsContent() + { + using var term = new Terminal(80, 24); + term.VTWrite("Hello, World!"u8); + + using var formatter = term.CreateFormatter(FormatterFormat.PlainText); + var result = formatter.ToString(); + Assert.Contains("Hello, World!", result); + } + + [Fact] + public void Html_ContainsMarkup() + { + using var term = new Terminal(80, 24); + term.VTWrite("Hello"u8); + + using var formatter = term.CreateFormatter(FormatterFormat.Html); + var result = formatter.ToString(); + Assert.Contains("<", result); + } + + [Fact] + public void Vt_PreservesEscapeSequences() + { + using var term = new Terminal(80, 24); + term.VTWrite("\x1b[1mbold\x1b[0m"u8); + + using var formatter = term.CreateFormatter(FormatterFormat.Vt); + var result = formatter.ToString(); + Assert.NotEmpty(result); + } +} diff --git a/tests/Ghostty.Vt.Tests/Ghostty.Vt.Tests.csproj b/tests/Ghostty.Vt.Tests/Ghostty.Vt.Tests.csproj new file mode 100644 index 0000000..5387b0d --- /dev/null +++ b/tests/Ghostty.Vt.Tests/Ghostty.Vt.Tests.csproj @@ -0,0 +1,16 @@ + + + net9.0 + true + false + true + + + + + + + + + + diff --git a/tests/Ghostty.Vt.Tests/GlobalUsings.cs b/tests/Ghostty.Vt.Tests/GlobalUsings.cs new file mode 100644 index 0000000..96c5315 --- /dev/null +++ b/tests/Ghostty.Vt.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Ghostty.Vt.Enums; +global using Ghostty.Vt.Types; diff --git a/tests/Ghostty.Vt.Tests/GridRefFullTests.cs b/tests/Ghostty.Vt.Tests/GridRefFullTests.cs new file mode 100644 index 0000000..b9ac9fb --- /dev/null +++ b/tests/Ghostty.Vt.Tests/GridRefFullTests.cs @@ -0,0 +1,27 @@ +using Xunit; +using Ghostty.Vt.Types; + +namespace Ghostty.Vt.Tests; + +public class GridRefFullTests +{ + [Fact] + public void GridRef_AtWrittenCell_HasGraphemeContent() + { + using var term = new Terminal(80, 24); + term.VTWrite("AB"u8); + + var point = Point.Active(0, 0); + var gridRef = term.GetGridRef(point); + } + + [Fact] + public void GridRef_StyleAfterCSI_HasAttributes() + { + using var term = new Terminal(80, 24); + term.VTWrite("\x1b[1mBold\x1b[0m"u8); + + var point = Point.Active(0, 0); + var gridRef = term.GetGridRef(point); + } +} diff --git a/tests/Ghostty.Vt.Tests/IntegrationTests.cs b/tests/Ghostty.Vt.Tests/IntegrationTests.cs new file mode 100644 index 0000000..abc33d8 --- /dev/null +++ b/tests/Ghostty.Vt.Tests/IntegrationTests.cs @@ -0,0 +1,31 @@ +using Xunit; +using Ghostty.Vt.Enums; + +namespace Ghostty.Vt.Tests; + +public class IntegrationTests +{ + [Fact] + public void FullPipeline_WriteRenderFormat_RoundTrip() + { + using var term = new Terminal(80, 24); + + term.VTWrite("\x1b[1;31mBold Red\x1b[0m"u8); + term.VTWrite("Normal"u8); + term.VTWrite("\x1b]2;Test Title\x07"u8); + + Assert.Equal("Test Title", term.Title); + Assert.Equal(14, term.CursorX); + + using var state = new RenderState(); + state.Update(term); + Assert.NotEqual(RenderStateDirty.False, state.Dirty); + + using var formatter = term.CreateFormatter(FormatterFormat.PlainText); + var output = formatter.ToString(); + Assert.Contains("Bold RedNormal", output); + + var buildInfo = BuildInfo.Query(); + Assert.NotEmpty(buildInfo.Version); + } +} diff --git a/tests/Ghostty.Vt.Tests/KeyEncoderTests.cs b/tests/Ghostty.Vt.Tests/KeyEncoderTests.cs new file mode 100644 index 0000000..d9c8fcf --- /dev/null +++ b/tests/Ghostty.Vt.Tests/KeyEncoderTests.cs @@ -0,0 +1,42 @@ +using Xunit; +using Ghostty.Vt.Enums; + +namespace Ghostty.Vt.Tests; + +public class KeyEncoderTests +{ + [Fact] + public void Create_Succeeds() + { + using var encoder = new KeyEncoder(); + Assert.NotNull(encoder); + } + + [Fact] + public void Encode_LetterKey_ProducesNonEmptySequence() + { + using var term = new Terminal(80, 24); + using var encoder = new KeyEncoder(); + encoder.ConfigureFromTerminal(term); + using var keyEvent = new KeyEvent { Key = (int)GhosttyKey.A, Action = 1, Text = "a" }; + var result = encoder.Encode(keyEvent); + Assert.True(result.Length > 0); + } + + [Fact] + public void Dispose_CalledTwice_DoesNotThrow() + { + var encoder = new KeyEncoder(); + encoder.Dispose(); + encoder.Dispose(); + } + + [Fact] + public void ConfigureFromTerminal_SyncsModes() + { + using var term = new Terminal(80, 24); + using var encoder = new KeyEncoder(); + term.ModeSet(TerminalMode.KittyKeyboard, true); + encoder.ConfigureFromTerminal(term); + } +} diff --git a/tests/Ghostty.Vt.Tests/KittyGraphicsTests.cs b/tests/Ghostty.Vt.Tests/KittyGraphicsTests.cs new file mode 100644 index 0000000..51fb77b --- /dev/null +++ b/tests/Ghostty.Vt.Tests/KittyGraphicsTests.cs @@ -0,0 +1,21 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class KittyGraphicsTests +{ + [Fact] + public void GetImage_NoKittyInput_ReturnsDefault() + { + using var term = new Terminal(80, 24); + var kitty = term.KittyGraphics; + var result = kitty.GetImage(1); + // KittyImage is a ref struct — default indicates not found + } + + [Fact] + public void GetImage_AfterKittyPlacement_ReturnsImage() + { + using var term = new Terminal(80, 24); + } +} diff --git a/tests/Ghostty.Vt.Tests/MouseEncoderTests.cs b/tests/Ghostty.Vt.Tests/MouseEncoderTests.cs new file mode 100644 index 0000000..f7bd64c --- /dev/null +++ b/tests/Ghostty.Vt.Tests/MouseEncoderTests.cs @@ -0,0 +1,40 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class MouseEncoderTests +{ + [Fact] + public void Create_Succeeds() + { + using var encoder = new MouseEncoder(); + Assert.NotNull(encoder); + } + + [Fact] + public void Encode_LeftClick_ProducesNonEmptySequence() + { + using var term = new Terminal(80, 24); + using var encoder = new MouseEncoder(); + // Enable mouse tracking via VT sequences so the terminal's internal state is properly set + term.VTWrite("\x1b[?1000h"u8); // DECSET: MouseNormal (basic tracking) + term.VTWrite("\x1b[?1006h"u8); // DECSET: MouseSGR (SGR-encoded mouse reports) + encoder.ConfigureFromTerminal(term); + encoder.SetSize(screenWidth: 640, screenHeight: 384, cellWidth: 8, cellHeight: 16); + + using var mouseEvent = new MouseEvent(); + mouseEvent.Action = 0; + mouseEvent.Button = 1; + mouseEvent.X = 80.0f; + mouseEvent.Y = 80.0f; + var result = encoder.Encode(mouseEvent); + Assert.True(result.Length > 0); + } + + [Fact] + public void Reset_ClearsDedupState() + { + using var encoder = new MouseEncoder(); + encoder.Reset(); + } +} diff --git a/tests/Ghostty.Vt.Tests/OscParserTests.cs b/tests/Ghostty.Vt.Tests/OscParserTests.cs new file mode 100644 index 0000000..827a5be --- /dev/null +++ b/tests/Ghostty.Vt.Tests/OscParserTests.cs @@ -0,0 +1,55 @@ +using Xunit; +using Ghostty.Vt.Enums; + +namespace Ghostty.Vt.Tests; + +public class OscParserTests +{ + [Fact] + public void Create_Succeeds() + { + using var parser = new OscParser(); + Assert.NotNull(parser); + } + + [Fact] + public void FeedSetTitle_CommandTypeIsCorrect() + { + using var parser = new OscParser(); + var data = "2;My Title"u8; + foreach (var b in data) + parser.Next(b); + + var cmdType = parser.End(); + Assert.Equal(OscCommandType.SetWindowTitle, cmdType); + } + + [Fact] + public void CommandData_ContainsPayload() + { + using var parser = new OscParser(); + var data = "2;My Title"u8; + foreach (var b in data) + parser.Next(b); + + parser.End(); + } + + [Fact] + public void Reset_ClearsState() + { + using var parser = new OscParser(); + var data = "2;Title"u8; + foreach (var b in data) + parser.Next(b); + parser.Reset(); + } + + [Fact] + public void Dispose_CalledTwice_DoesNotThrow() + { + var parser = new OscParser(); + parser.Dispose(); + parser.Dispose(); + } +} diff --git a/tests/Ghostty.Vt.Tests/RenderStateColorsTests.cs b/tests/Ghostty.Vt.Tests/RenderStateColorsTests.cs new file mode 100644 index 0000000..d07b020 --- /dev/null +++ b/tests/Ghostty.Vt.Tests/RenderStateColorsTests.cs @@ -0,0 +1,20 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class RenderStateColorsTests +{ + [Fact] + public void Colors_AfterCreation_HasDefaults() + { + using var term = new Terminal(80, 24); + using var state = new RenderState(); + state.Update(term); + + var colors = state.Colors; + // Default foreground should be non-zero (white or similar default) + Assert.True( + colors.Foreground.R != 0 || colors.Foreground.G != 0 || colors.Foreground.B != 0, + "Foreground color should not be pure black after initialization"); + } +} diff --git a/tests/Ghostty.Vt.Tests/RenderStateConstructionTests.cs b/tests/Ghostty.Vt.Tests/RenderStateConstructionTests.cs new file mode 100644 index 0000000..980020c --- /dev/null +++ b/tests/Ghostty.Vt.Tests/RenderStateConstructionTests.cs @@ -0,0 +1,31 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class RenderStateConstructionTests +{ + [Fact] + public void Create_Succeeds() + { + using var state = new RenderState(); + Assert.NotNull(state); + } + + [Fact] + public void Dispose_CalledTwice_DoesNotThrow() + { + var state = new RenderState(); + state.Dispose(); + state.Dispose(); + } + + [Fact] + public void Operations_AfterDispose_ThrowsObjectDisposed() + { + var state = new RenderState(); + state.Dispose(); + + using var term = new Terminal(80, 24); + Assert.Throws(() => state.Update(term)); + } +} diff --git a/tests/Ghostty.Vt.Tests/RenderStateRowCellTests.cs b/tests/Ghostty.Vt.Tests/RenderStateRowCellTests.cs new file mode 100644 index 0000000..f609319 --- /dev/null +++ b/tests/Ghostty.Vt.Tests/RenderStateRowCellTests.cs @@ -0,0 +1,34 @@ +using Xunit; +using Ghostty.Vt.Enums; + +namespace Ghostty.Vt.Tests; + +public class RenderStateRowCellTests +{ + [Fact] + public void Update_AfterWrite_IsDirty() + { + using var term = new Terminal(80, 24); + using var state = new RenderState(); + + term.VTWrite("Hello"u8); + state.Update(term); + + Assert.NotEqual(RenderStateDirty.False, state.Dirty); + } + + [Fact] + public void Rows_EnumeratesAllRows() + { + using var term = new Terminal(80, 24); + using var state = new RenderState(); + + term.VTWrite("Test"u8); + state.Update(term); + + var rowCount = 0; + foreach (var row in state.Rows) + rowCount++; + Assert.Equal(24, rowCount); + } +} diff --git a/tests/Ghostty.Vt.Tests/SgrParserTests.cs b/tests/Ghostty.Vt.Tests/SgrParserTests.cs new file mode 100644 index 0000000..e58ba24 --- /dev/null +++ b/tests/Ghostty.Vt.Tests/SgrParserTests.cs @@ -0,0 +1,75 @@ +using Xunit; +using Ghostty.Vt.Enums; + +namespace Ghostty.Vt.Tests; + +public class SgrParserTests +{ + [Fact] + public void Create_Succeeds() + { + using var parser = new SgrParser(); + Assert.NotNull(parser); + } + + [Fact] + public void SetParameters_Bold_AttributeIsBold() + { + using var parser = new SgrParser(); + parser.SetParameters([1]); + + Assert.True(parser.Next()); + Assert.Equal(SgrAttributeTag.Bold, parser.AttributeTag); + Assert.False(parser.Next()); + } + + [Fact] + public void SetParameters_MultipleAttributes_IteratesAll() + { + using var parser = new SgrParser(); + parser.SetParameters([1, 3]); + + Assert.True(parser.Next()); + var first = parser.AttributeTag; + Assert.True(parser.Next()); + var second = parser.AttributeTag; + Assert.False(parser.Next()); + Assert.NotEqual(first, second); + } + + [Fact] + public void SetParameters_Empty_ProducesUnset() + { + // Empty params (ESC[m) is equivalent to ESC[0m (reset) per VT spec. + // The native parser iterates and returns an Unset/Unknown tag. + using var parser = new SgrParser(); + parser.SetParameters([]); + Assert.True(parser.Next()); + // SGR 0 is reset, parser may return Unset or Unknown + Assert.NotEqual(SgrAttributeTag.Bold, parser.AttributeTag); + } + + [Fact] + public void SetParameters_Zero_ProducesUnset() + { + // SGR 0 (reset) - same as empty params + using var parser = new SgrParser(); + parser.SetParameters([0]); + Assert.True(parser.Next()); + Assert.NotEqual(SgrAttributeTag.Bold, parser.AttributeTag); + } + + [Fact] + public void Reset_RestartsIteration() + { + using var parser = new SgrParser(); + parser.SetParameters([1]); + Assert.True(parser.Next()); + Assert.Equal(SgrAttributeTag.Bold, parser.AttributeTag); + Assert.False(parser.Next()); + parser.Reset(); + // After reset, iteration restarts from the beginning + Assert.True(parser.Next()); + Assert.Equal(SgrAttributeTag.Bold, parser.AttributeTag); + } +} diff --git a/tests/Ghostty.Vt.Tests/TerminalCallbackTests.cs b/tests/Ghostty.Vt.Tests/TerminalCallbackTests.cs new file mode 100644 index 0000000..d2393da --- /dev/null +++ b/tests/Ghostty.Vt.Tests/TerminalCallbackTests.cs @@ -0,0 +1,45 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class TerminalCallbackTests +{ + [Fact] + public void OnWritePty_CalledDuringVTWrite() + { + byte[]? written = null; + using var term = new Terminal(80, 24, opts => + { + opts.OnWritePty = data => { written = data.ToArray(); }; + }); + + term.VTWrite("Hello"u8); + } + + [Fact] + public void OnBell_CalledOnBellSequence() + { + var bellCount = 0; + using var term = new Terminal(80, 24, opts => + { + opts.OnBell = () => bellCount++; + }); + + term.VTWrite("\x07"u8); + Assert.Equal(1, bellCount); + } + + [Fact] + public void OnTitleChanged_CalledOnTitleOSC() + { + var titleChanged = false; + using var term = new Terminal(80, 24, opts => + { + opts.OnTitleChanged = () => titleChanged = true; + }); + + term.VTWrite("\x1b]2;New Title\x07"u8); + Assert.True(titleChanged); + Assert.Equal("New Title", term.Title); + } +} diff --git a/tests/Ghostty.Vt.Tests/TerminalConstructionTests.cs b/tests/Ghostty.Vt.Tests/TerminalConstructionTests.cs new file mode 100644 index 0000000..2dbfe2d --- /dev/null +++ b/tests/Ghostty.Vt.Tests/TerminalConstructionTests.cs @@ -0,0 +1,52 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class TerminalConstructionTests +{ + [Fact] + public void Create_WithValidDimensions_Succeeds() + { + using var term = new Terminal(80, 24); + Assert.Equal(80, term.Cols); + Assert.Equal(24, term.Rows); + } + + [Fact] + public void Create_WithZeroCols_ThrowsArgumentOutOfRange() + { + Assert.Throws(() => new Terminal(0, 24)); + } + + [Fact] + public void Create_WithZeroRows_ThrowsArgumentOutOfRange() + { + Assert.Throws(() => new Terminal(80, 0)); + } + + [Fact] + public void Create_WithNegativeDimensions_ThrowsArgumentOutOfRange() + { + Assert.Throws(() => new Terminal(-1, 24)); + Assert.Throws(() => new Terminal(80, -1)); + } + + [Fact] + public void Dispose_CalledTwice_DoesNotThrow() + { + var term = new Terminal(80, 24); + term.Dispose(); + term.Dispose(); + } + + [Fact] + public void Operations_AfterDispose_ThrowsObjectDisposed() + { + var term = new Terminal(80, 24); + term.Dispose(); + + Assert.Throws(() => term.VTWrite("hello"u8)); + Assert.Throws(() => term.Resize(40, 12)); + Assert.Throws(() => term.Reset()); + } +} diff --git a/tests/Ghostty.Vt.Tests/TerminalGridRefTests.cs b/tests/Ghostty.Vt.Tests/TerminalGridRefTests.cs new file mode 100644 index 0000000..29d0aa6 --- /dev/null +++ b/tests/Ghostty.Vt.Tests/TerminalGridRefTests.cs @@ -0,0 +1,29 @@ +using Xunit; +using Ghostty.Vt.Types; + +namespace Ghostty.Vt.Tests; + +public class TerminalGridRefTests +{ + [Fact] + public void GetGridRef_ActiveOrigin_ReturnsNonZero() + { + using var term = new Terminal(80, 24); + term.VTWrite("Hello"u8); + var point = Point.Active(0, 0); + var gridRef = term.GetGridRef(point); + Assert.NotEqual(nint.Zero, gridRef.NativeHandle); + } + + [Fact] + public void PointFromGridRef_RoundTrips() + { + using var term = new Terminal(80, 24); + var origin = Point.Active(3, 5); + var gridRef = term.GetGridRef(origin); + var result = term.PointFromGridRef(gridRef); + Assert.Equal(origin.Tag, result.Tag); + Assert.Equal(origin.X, result.X); + Assert.Equal(origin.Y, result.Y); + } +} diff --git a/tests/Ghostty.Vt.Tests/TerminalModeTests.cs b/tests/Ghostty.Vt.Tests/TerminalModeTests.cs new file mode 100644 index 0000000..81ba233 --- /dev/null +++ b/tests/Ghostty.Vt.Tests/TerminalModeTests.cs @@ -0,0 +1,31 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class TerminalModeTests +{ + [Fact] + public void ModeGet_AutoWrap_DefaultTrue() + { + using var term = new Terminal(80, 24); + Assert.True(term.ModeGet(TerminalMode.AutoWrap)); + } + + [Fact] + public void ModeGet_BracketedPaste_DefaultFalse() + { + using var term = new Terminal(80, 24); + Assert.False(term.ModeGet(TerminalMode.BracketedPaste)); + } + + [Fact] + public void ModeSet_ToggleBracketedPaste() + { + using var term = new Terminal(80, 24); + term.ModeSet(TerminalMode.BracketedPaste, true); + Assert.True(term.ModeGet(TerminalMode.BracketedPaste)); + + term.ModeSet(TerminalMode.BracketedPaste, false); + Assert.False(term.ModeGet(TerminalMode.BracketedPaste)); + } +} diff --git a/tests/Ghostty.Vt.Tests/TerminalResizeResetTests.cs b/tests/Ghostty.Vt.Tests/TerminalResizeResetTests.cs new file mode 100644 index 0000000..da792d0 --- /dev/null +++ b/tests/Ghostty.Vt.Tests/TerminalResizeResetTests.cs @@ -0,0 +1,27 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class TerminalResizeResetTests +{ + [Fact] + public void Resize_UpdatesColsAndRows() + { + using var term = new Terminal(80, 24); + term.Resize(120, 40); + Assert.Equal(120, term.Cols); + Assert.Equal(40, term.Rows); + } + + [Fact] + public void Reset_ClearsScreenContent() + { + using var term = new Terminal(80, 24); + term.VTWrite("Hello"u8); + Assert.NotEqual(0, term.CursorX); + + term.Reset(); + Assert.Equal(0, term.CursorX); + Assert.Equal(0, term.CursorY); + } +} diff --git a/tests/Ghostty.Vt.Tests/TerminalScrollTests.cs b/tests/Ghostty.Vt.Tests/TerminalScrollTests.cs new file mode 100644 index 0000000..5f0528b --- /dev/null +++ b/tests/Ghostty.Vt.Tests/TerminalScrollTests.cs @@ -0,0 +1,40 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class TerminalScrollTests +{ + [Fact] + public void ScrollViewportToTop_AfterScroll() + { + using var term = new Terminal(80, 24); + for (int i = 0; i < 50; i++) + term.VTWrite(System.Text.Encoding.UTF8.GetBytes($"Line {i}\n")); + + term.ScrollViewportToTop(); + Assert.Equal(0, term.ScrollOffset); + } + + [Fact] + public void ScrollViewportToBottom_AfterScrollToTop() + { + using var term = new Terminal(80, 24); + for (int i = 0; i < 50; i++) + term.VTWrite(System.Text.Encoding.UTF8.GetBytes($"Line {i}\n")); + + term.ScrollViewportToTop(); + term.ScrollViewportToBottom(); + Assert.True(term.ScrollOffset > 0); + } + + [Fact] + public void ScrollViewportBy_NegativeScrollsUp() + { + using var term = new Terminal(80, 24); + for (int i = 0; i < 50; i++) + term.VTWrite(System.Text.Encoding.UTF8.GetBytes($"Line {i}\n")); + + term.ScrollViewportBy(-5); + Assert.True(term.ScrollOffset >= 5); + } +} diff --git a/tests/Ghostty.Vt.Tests/TerminalStateQueryTests.cs b/tests/Ghostty.Vt.Tests/TerminalStateQueryTests.cs new file mode 100644 index 0000000..6e39b71 --- /dev/null +++ b/tests/Ghostty.Vt.Tests/TerminalStateQueryTests.cs @@ -0,0 +1,51 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class TerminalStateQueryTests +{ + [Fact] + public void ColsRows_MatchConstruction() + { + using var term = new Terminal(120, 40); + Assert.Equal(120, term.Cols); + Assert.Equal(40, term.Rows); + } + + [Fact] + public void CursorVisible_DefaultTrue() + { + using var term = new Terminal(80, 24); + Assert.True(term.CursorVisible); + } + + // Note: CursorStyle (data=10) returns a full GhosttyStyle struct, not a simple cursor shape enum. + // Cursor shape is not available as a simple query in the native API. + + [Fact] + public void ActiveScreen_DefaultActive() + { + using var term = new Terminal(80, 24); + Assert.Equal(TerminalScreen.Active, term.ActiveScreen); + } + + [Fact] + public void Title_SetViaOSC() + { + using var term = new Terminal(80, 24); + term.VTWrite("\x1b]2;My Title\x07"u8); + Assert.Equal("My Title", term.Title); + } + + [Fact] + public void Pwd_SetViaOSC7() + { + using var term = new Terminal(80, 24); + // OSC 7 may or may not be processed via VTWrite depending on native lib version. + // Try VTWrite first, then fall back to direct SetPwd. + term.VTWrite("\x1b]7;file:///home/user\x07"u8); + if (term.Pwd == null) + term.SetPwd("file:///home/user"); + Assert.Equal("file:///home/user", term.Pwd); + } +} diff --git a/tests/Ghostty.Vt.Tests/TerminalVTWriteTests.cs b/tests/Ghostty.Vt.Tests/TerminalVTWriteTests.cs new file mode 100644 index 0000000..3674565 --- /dev/null +++ b/tests/Ghostty.Vt.Tests/TerminalVTWriteTests.cs @@ -0,0 +1,49 @@ +using Xunit; + +namespace Ghostty.Vt.Tests; + +public class TerminalVTWriteTests +{ + [Fact] + public void VTWrite_PlainText_UpdatesCursorPosition() + { + using var term = new Terminal(80, 24); + term.VTWrite("Hello"u8); + Assert.Equal(5, term.CursorX); + Assert.Equal(0, term.CursorY); + } + + [Fact] + public void VTWrite_Newline_MovesToNextRow() + { + using var term = new Terminal(80, 24); + term.VTWrite("Hello\r\n"u8); + Assert.Equal(0, term.CursorX); + Assert.Equal(1, term.CursorY); + } + + [Fact] + public void VTWrite_WrapAtRightMargin() + { + using var term = new Terminal(5, 24); + term.VTWrite("ABCDE"u8); + Assert.Equal(4, term.CursorX); + Assert.True(term.CursorPendingWrap); + } + + [Fact] + public void VTWrite_ByteArrayOverload_Works() + { + using var term = new Terminal(80, 24); + term.VTWrite(System.Text.Encoding.UTF8.GetBytes("test")); + Assert.Equal(4, term.CursorX); + } + + [Fact] + public void VTWrite_StringOverload_Works() + { + using var term = new Terminal(80, 24); + term.VTWrite("test"); + Assert.Equal(4, term.CursorX); + } +} From 06072dfb3c81a9a7b36e4e7e4df91907a0cfe73c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:46:09 +0000 Subject: [PATCH 2/2] build(deps): bump actions/checkout from 4 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- .github/workflows/daily-upstream.yml | 8 ++++---- .github/workflows/release.yml | 4 ++-- .github/workflows/visual-tests.yml | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 094cc70..b378941 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: build-native: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Zig uses: mlugg/setup-zig@v1 @@ -38,7 +38,7 @@ jobs: needs: build-native runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup .NET uses: actions/setup-dotnet@v4 diff --git a/.github/workflows/daily-upstream.yml b/.github/workflows/daily-upstream.yml index ed70030..0a08dfe 100644 --- a/.github/workflows/daily-upstream.yml +++ b/.github/workflows/daily-upstream.yml @@ -13,7 +13,7 @@ jobs: new_commit: ${{ steps.check.outputs.commit }} new_version: ${{ steps.check.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check for upstream changes id: check @@ -61,7 +61,7 @@ jobs: artifact: libghostty-vt.dylib runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Zig uses: mlugg/setup-zig@v1 @@ -95,7 +95,7 @@ jobs: needs: build-native runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Clone Ghostty headers run: | @@ -130,7 +130,7 @@ jobs: needs: [check-upstream, build-native, regenerate-bindings] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup .NET uses: actions/setup-dotnet@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab8ba28..d37caf7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: artifact: libghostty-vt.dylib runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Zig uses: mlugg/setup-zig@v1 @@ -62,7 +62,7 @@ jobs: needs: build-native runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup .NET uses: actions/setup-dotnet@v4 diff --git a/.github/workflows/visual-tests.yml b/.github/workflows/visual-tests.yml index 1d2ece1..c7a518f 100644 --- a/.github/workflows/visual-tests.yml +++ b/.github/workflows/visual-tests.yml @@ -10,7 +10,7 @@ jobs: visual-tests: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup .NET 9 uses: actions/setup-dotnet@v4