Merge branch 'main' into localize-nautilus-script

This commit is contained in:
David Matos
2026-02-15 11:01:11 +01:00
113 changed files with 15372 additions and 5375 deletions

View File

@@ -1,6 +1,6 @@
root = true
[*.{sh,bash,elv}]
[*.{sh,bash,elv,nu}]
indent_size = 2
indent_style = space

View File

@@ -1,4 +1,6 @@
#!/bin/sh
#!/usr/bin/env bash
set -euxo pipefail
old_pot=$(mktemp)
cp po/com.mitchellh.ghostty.pot "$old_pot"

View File

@@ -15,7 +15,7 @@ jobs:
name: Milestone Update
steps:
- name: Set Milestone for PR
uses: hustcer/milestone-action@dcd6c3742acc1846929c054251c64cccd555a00d # v3.0
uses: hustcer/milestone-action@ebed8d5daafd855a600d7e665c1b130f06d24130 # v3.1
if: github.event.pull_request.merged == true
with:
action: bind-pr # `bind-pr` is the default action
@@ -24,7 +24,7 @@ jobs:
# Bind milestone to closed issue that has a merged PR fix
- name: Set Milestone for Issue
uses: hustcer/milestone-action@dcd6c3742acc1846929c054251c64cccd555a00d # v3.0
uses: hustcer/milestone-action@ebed8d5daafd855a600d7e665c1b130f06d24130 # v3.1
if: github.event.issue.state == 'closed'
with:
action: bind-issue

View File

@@ -39,9 +39,9 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -50,7 +50,7 @@ jobs:
uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"

View File

@@ -56,7 +56,7 @@ jobs:
fi
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Important so that build number generation works
fetch-depth: 0
@@ -80,10 +80,10 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -93,7 +93,7 @@ jobs:
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -132,12 +132,12 @@ jobs:
GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -306,7 +306,7 @@ jobs:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download macOS Artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0

View File

@@ -4,7 +4,11 @@ on:
types: [completed]
branches: [main]
workflow_dispatch: {}
workflow_dispatch:
inputs:
pr:
type: number
required: false
name: Release Tip
@@ -29,14 +33,14 @@ jobs:
commit: ${{ steps.extract_build_info.outputs.commit }}
commit_long: ${{ steps.extract_build_info.outputs.commit_long }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Important so that build number generation works
fetch-depth: 0
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -66,7 +70,7 @@ jobs:
needs: [setup, build-macos]
if: needs.setup.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Tip Tag
run: |
git config user.name "github-actions[bot]"
@@ -81,7 +85,7 @@ jobs:
env:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install sentry-cli
run: |
@@ -104,7 +108,7 @@ jobs:
env:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install sentry-cli
run: |
@@ -127,7 +131,7 @@ jobs:
env:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install sentry-cli
run: |
@@ -159,9 +163,9 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -169,7 +173,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -217,7 +221,7 @@ jobs:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Important so that build number generation works
fetch-depth: 0
@@ -226,7 +230,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -424,12 +428,21 @@ jobs:
source-dir: blob
destination-dir: ./
- name: Echo Release URLs
- name: Show and Save Release URLs
run: |
echo "Release URLs:"
echo " App Bundle: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/ghostty-macos-universal.zip"
echo " Debug Symbols: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/ghostty-macos-universal-dsym.zip"
echo " DMG: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/Ghostty.dmg"
cat << EOF | tee release-urls.txt
Release URLs:
App Bundle: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/ghostty-macos-universal.zip
Debug Symbols: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/ghostty-macos-universal-dsym.zip
DMG: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/Ghostty.dmg
EOF
- name: Upload Release URLs
uses: actions/upload-artifact@47309c993abb98030a35d55ef7ff34b7fa1074b5 # v6.0
with:
name: release-urls-${{ inputs.pr || '0' }}
path: release-urls.txt
retention-days: 2
build-macos-debug-slow:
needs: [setup]
@@ -451,7 +464,7 @@ jobs:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Important so that build number generation works
fetch-depth: 0
@@ -460,7 +473,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -635,7 +648,7 @@ jobs:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Important so that build number generation works
fetch-depth: 0
@@ -644,7 +657,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"

View File

@@ -38,7 +38,7 @@ jobs:
tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix

View File

@@ -74,10 +74,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -87,7 +87,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -117,10 +117,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -130,7 +130,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -150,10 +150,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -163,7 +163,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -184,10 +184,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -197,7 +197,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -228,10 +228,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -241,7 +241,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -264,10 +264,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -277,7 +277,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -293,10 +293,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -306,7 +306,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -326,10 +326,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -339,7 +339,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -372,10 +372,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -385,7 +385,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -410,7 +410,7 @@ jobs:
needs: [build-dist, build-snap]
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Trigger Snap workflow
run: |
@@ -428,7 +428,7 @@ jobs:
needs: [build-dist, build-flatpak]
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Trigger Flatpak workflow
run: |
@@ -445,13 +445,13 @@ jobs:
needs: test
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -488,13 +488,13 @@ jobs:
needs: test
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -511,11 +511,11 @@ jobs:
- name: Test All
run: |
nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=freetype
nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_freetype
- name: Build All
run: |
nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=freetype
nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_freetype
build-windows:
runs-on: windows-2022
@@ -525,7 +525,7 @@ jobs:
needs: test
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# This could be from a script if we wanted to but inlining here for now
# in one place.
@@ -596,7 +596,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get required Zig version
id: zig
@@ -604,7 +604,7 @@ jobs:
echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -614,7 +614,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -643,10 +643,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -656,7 +656,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -691,10 +691,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -704,7 +704,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -726,10 +726,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -739,7 +739,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -753,13 +753,13 @@ jobs:
needs: test
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -790,10 +790,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -803,7 +803,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -820,9 +820,9 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -830,7 +830,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -850,9 +850,9 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -860,7 +860,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -879,9 +879,9 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -889,7 +889,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -906,9 +906,9 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -916,7 +916,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -933,9 +933,9 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -943,7 +943,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -960,9 +960,9 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -970,7 +970,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -992,9 +992,9 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -1002,7 +1002,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1019,9 +1019,9 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -1029,7 +1029,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1053,10 +1053,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -1066,7 +1066,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1085,7 +1085,7 @@ jobs:
uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10
- name: Configure Namespace powered Buildx
uses: namespacelabs/nscloud-setup-buildx-action@a7e525416136ee2842da3c800e7067b72a27200e # v0.0.21
uses: namespacelabs/nscloud-setup-buildx-action@f5814dcf37a16cce0624d5bec2ab879654294aa0 # v0.0.22
- name: Download Source Tarball Artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
@@ -1098,7 +1098,7 @@ jobs:
tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz
- name: Build and push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: dist
file: dist/src/build/docker/debian/Dockerfile
@@ -1115,10 +1115,10 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -1128,7 +1128,7 @@ jobs:
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1154,7 +1154,7 @@ jobs:
# timeout-minutes: 10
# steps:
# - name: Checkout Ghostty
# uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
#
# - name: Start SSH
# run: |

View File

@@ -17,12 +17,12 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1
with:
path: |
/nix
@@ -32,7 +32,7 @@ jobs:
uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -79,7 +79,7 @@ jobs:
run: nix build .#ghostty
- name: Create pull request
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
title: Update iTerm2 colorschemes
base: main

69
AI_POLICY.md Normal file
View File

@@ -0,0 +1,69 @@
# AI Usage Policy
The Ghostty project has strict rules for AI usage:
- **All AI usage in any form must be disclosed.** You must state
the tool you used (e.g. Claude Code, Cursor, Amp) along with
the extent that the work was AI-assisted.
- **Pull requests created in any way by AI can only be for accepted issues.**
Drive-by pull requests that do not reference an accepted issue will be
closed. If AI isn't disclosed but a maintainer suspects its use, the
PR will be closed. If you want to share code for a non-accepted issue,
open a discussion or attach it to an existing discussion.
- **Pull requests created by AI must have been fully verified with
human use.** AI must not create hypothetically correct code that
hasn't been tested. Importantly, you must not allow AI to write
code for platforms or environments you don't have access to manually
test on.
- **Issues and discussions can use AI assistance but must have a full
human-in-the-loop.** This means that any content generated with AI
must have been reviewed _and edited_ by a human before submission.
AI is very good at being overly verbose and including noise that
distracts from the main point. Humans must do their research and
trim this down.
- **No AI-generated media is allowed (art, images, videos, audio, etc.).**
Text and code are the only acceptable AI-generated content, per the
other rules in this policy.
- **Bad AI drivers will be banned and ridiculed in public.** You've
been warned. We love to help junior developers learn and grow, but
if you're interested in that then don't use AI, and we'll help you.
I'm sorry that bad AI drivers have ruined this for you.
These rules apply only to outside contributions to Ghostty. Maintainers
are exempt from these rules and may use AI tools at their discretion;
they've proven themselves trustworthy to apply good judgment.
## There are Humans Here
Please remember that Ghostty is maintained by humans.
Every discussion, issue, and pull request is read and reviewed by
humans (and sometimes machines, too). It is a boundary point at which
people interact with each other and the work done. It is rude and
disrespectful to approach this boundary with low-effort, unqualified
work, since it puts the burden of validation on the maintainer.
In a perfect world, AI would produce high-quality, accurate work
every time. But today, that reality depends on the driver of the AI.
And today, most drivers of AI are just not good enough. So, until either
the people get better, the AI gets better, or both, we have to have
strict rules to protect maintainers.
## AI is Welcome Here
Ghostty is written with plenty of AI assistance, and many maintainers embrace
AI tools as a productive tool in their workflow. As a project, we welcome
AI as a tool!
**Our reason for the strict AI policy is not due to an anti-AI stance**, but
instead due to the number of highly unqualified people using AI. It's the
people, not the tools, that are the problem.
I include this section to be transparent about the project's usage about
AI for people who may disagree with it, and to address the misconception
that this policy is anti-AI in nature.

View File

@@ -185,6 +185,7 @@
/po/he_IL.UTF-8.po @ghostty-org/he_IL
/po/it_IT.UTF-8.po @ghostty-org/it_IT
/po/lt_LT.UTF-8.po @ghostty-org/lt_LT
/po/lv_LV.UTF-8.po @ghostty-org/lv_LV
/po/zh_TW.UTF-8.po @ghostty-org/zh_TW
/po/hr_HR.UTF-8.po @ghostty-org/hr_HR

View File

@@ -13,91 +13,10 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well.
> time to fixing bugs, maintaining features, and reviewing code, I do kindly
> ask you spend a few minutes reading this document. Thank you. ❤️
## AI Assistance Notice
## AI Usage
> [!IMPORTANT]
>
> The Ghostty project allows AI-**assisted** _code contributions_, which
> must be properly disclosed in the pull request.
If you are using any kind of AI assistance while contributing to Ghostty,
**this must be disclosed in the pull request**, along with the extent to
which AI assistance was used (e.g. docs only vs. code generation).
The submitter must have also tested the pull request on all impacted
platforms, and it's **highly discouraged** to code for an unfamiliar platform
with AI assistance alone: if you only have a macOS machine, do **not** ask AI
to write the equivalent GTK code, and vice versa — someone else with more
expertise will eventually get to it and do it for you.
> [!WARNING]
> **Note that AI _assistance_ does not equal AI _generation_**. We require
> a significant amount of human accountability, involvement and interaction
> even within AI-assisted contributions. Contributors are required to be able
> to understand the AI-assisted output, reason with it and answer critical
> questions about it. Should a PR see no visible human accountability and
> involvement, or it is so broken that it requires significant rework to be
> acceptable, **we reserve the right to close it without hesitation**.
**In addition, we currently restrict AI assistance to code changes only.**
No AI-generated media, e.g. artwork, icons, videos and other assets is
allowed, as it goes against the methodology and ethos behind Ghostty.
While AI-assisted code can help with productive prototyping, creative
inspiration and even automated bugfinding, we have currently found zero
benefit to AI-generated assets. Instead, we are far more interested and
invested in funding professional work done by human designers and artists.
If you intend to submit AI-generated assets to Ghostty, sorry,
we are not interested.
Likewise, all community interactions, including all comments on issues and
discussions and all PR titles and descriptions **must be composed by a human**.
Community moderators and Ghostty maintainers reserve the right to mark
AI-generated responses as spam or disruptive content, and ban users who have
been repeatedly caught relying entirely on LLMs during interactions.
> [!NOTE]
> If your English isn't the best and you are currently relying on an LLM to
> translate your responses, don't fret — usually we maintainers will be able
> to understand your messages well enough. We'd like to encourage real humans
> to interact with each other more, and the positive impact of genuine,
> responsive yet imperfect human interaction more than makes up for any
> language barrier.
>
> Please write your responses yourself, to the best of your ability.
> If you do feel the need to polish your sentences, however, please use
> dedicated translation software rather than an LLM.
>
> We greatly appreciate it. Thank you. ❤️
Minor exceptions to this policy include trivial AI-generated tab completion
functionality, as it usually does not impact the quality of the code and
do not need to be disclosed, and commit titles and messages, which are often
generated by AI coding agents.
An example disclosure:
> This PR was written primarily by Claude Code.
Or a more detailed disclosure:
> I consulted ChatGPT to understand the codebase but the solution
> was fully authored manually by myself.
An example of a **problematic** disclosure (not having tested all platforms):
> I used Amp to code both macOS and GTK UIs, but I have not yet tested
> the GTK UI as I don't have a Linux setup.
Failure to disclose this is first and foremost rude to the human operators
on the other end of the pull request, but it also makes it difficult to
determine how much scrutiny to apply to the contribution.
In a perfect world, AI assistance would produce equal or higher quality
work than any human. That isn't the world we live in today, and in most cases
it's generating slop. I say this despite being a fan of and using them
successfully myself (with heavy supervision)!
Please be respectful to maintainers and disclose AI assistance.
The Ghostty project has strict rules for AI usage. Please see
the [AI Usage Policy](AI_POLICY.md). **This is very important.**
## Quick Guide
@@ -451,7 +370,7 @@ requirements for macOS are detailed below.
### Linux
1. Check out the Ghostty source and change to the directory.
2. Run `nix run .#check.<system>.<test-name>.driver`. `<system>` should be
2. Run `nix run .#checks.<system>.<test-name>.driver`. `<system>` should be
`x86_64-linux` or `aarch64-linux` (even on macOS, this launches a Linux
VM, not a macOS one). `<test-name>` should be one of the tests defined in
`nix/tests.nix`. The test will build and then launch. Depending on the speed

View File

@@ -116,8 +116,8 @@
// Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
.hash = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7",
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz",
.hash = "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z",
.lazy = true,
},
},

6
build.zig.zon.json generated
View File

@@ -54,10 +54,10 @@
"url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
"hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs="
},
"N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7": {
"N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z": {
"name": "iterm2_themes",
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
"hash": "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU="
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz",
"hash": "sha256-xN+3iQaN3uIJ/BzkgFxLojgHqeuz1htNcVjcjWR7Qjg="
},
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
"name": "jetbrains_mono",

6
build.zig.zon.nix generated
View File

@@ -171,11 +171,11 @@ in
};
}
{
name = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7";
name = "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z";
path = fetchZigArtifact {
name = "iterm2_themes";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz";
hash = "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU=";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz";
hash = "sha256-xN+3iQaN3uIJ/BzkgFxLojgHqeuz1htNcVjcjWR7Qjg=";
};
}
{

2
build.zig.zon.txt generated
View File

@@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
https://deps.files.ghostty.org/gettext-0.24.tar.gz
https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz
https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz

14
flake.lock generated
View File

@@ -41,11 +41,11 @@
]
},
"locked": {
"lastModified": 1768068402,
"narHash": "sha256-bAXnnJZKJiF7Xr6eNW6+PhBf1lg2P1aFUO9+xgWkXfA=",
"lastModified": 1770586272,
"narHash": "sha256-Ucci8mu8QfxwzyfER2DQDbvW9t1BnTUJhBmY7ybralo=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "8bc5473b6bc2b6e1529a9c4040411e1199c43b4c",
"rev": "b1f916ba052341edc1f80d4b2399f1092a4873ca",
"type": "github"
},
"original": {
@@ -56,11 +56,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1768032153,
"narHash": "sha256-zvxtwlM8ZlulmZKyYCQAPpkm5dngSEnnHjmjV7Teloc=",
"rev": "3146c6aa9995e7351a398e17470e15305e6e18ff",
"lastModified": 1770537093,
"narHash": "sha256-XV30uo8tXuxdzuV8l3sojmlPRLd/8tpMsOp4lNzLGUo=",
"rev": "fef9403a3e4d31b0a23f0bacebbec52c248fbb51",
"type": "tarball",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.05pre925418.3146c6aa9995/nixexprs.tar.xz"
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.05pre942631.fef9403a3e4d/nixexprs.tar.xz"
},
"original": {
"type": "tarball",

171
flake.nix
View File

@@ -49,92 +49,101 @@
zon2nix,
home-manager,
...
}:
builtins.foldl' nixpkgs.lib.recursiveUpdate {} (
builtins.map (
system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
devShells.${system}.default = pkgs.callPackage ./nix/devShell.nix {
zig = zig.packages.${system}."0.15.2";
wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {};
zon2nix = zon2nix;
}: let
inherit (nixpkgs) lib legacyPackages;
python3 = pkgs.python3.override {
self = pkgs.python3;
packageOverrides = pyfinal: pyprev: {
blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {};
ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {};
};
};
# Our supported systems are the same supported systems as the Zig binaries.
platforms = lib.attrNames zig.packages;
# It's not always possible to build Ghostty with Nix for each system,
# one such example being macOS due to missing Swift 6 and xcodebuild
# support in the Nix ecosystem. Therefore for things like package outputs
# we need to limit the attributes we expose.
buildablePlatforms = lib.filter (p: !(lib.systems.elaborate p).isDarwin) platforms;
forAllPlatforms = f: lib.genAttrs platforms (s: f legacyPackages.${s});
forBuildablePlatforms = f: lib.genAttrs buildablePlatforms (s: f legacyPackages.${s});
mkPkgArgs = optimize: {
inherit optimize;
revision = self.shortRev or self.dirtyShortRev or "dirty";
};
in {
devShells = forAllPlatforms (pkgs: {
default = pkgs.callPackage ./nix/devShell.nix {
zig = zig.packages.${pkgs.stdenv.hostPlatform.system}."0.15.2";
wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {};
zon2nix = zon2nix;
python3 = pkgs.python3.override {
self = pkgs.python3;
packageOverrides = pyfinal: pyprev: {
blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {};
ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {};
};
packages.${system} = let
mkArgs = optimize: {
inherit optimize;
revision = self.shortRev or self.dirtyShortRev or "dirty";
};
in rec {
deps = pkgs.callPackage ./build.zig.zon.nix {};
ghostty-debug = pkgs.callPackage ./nix/package.nix (mkArgs "Debug");
ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
ghostty = ghostty-releasefast;
default = ghostty;
};
formatter.${system} = pkgs.alejandra;
checks.${system} = import ./nix/tests.nix {
inherit home-manager nixpkgs self system;
};
apps.${system} = let
runVM = (
module: let
vm = import ./nix/vm/create.nix {
inherit system module nixpkgs;
overlay = self.overlays.debug;
};
program = pkgs.writeShellScript "run-ghostty-vm" ''
SHARED_DIR=$(pwd)
export SHARED_DIR
${pkgs.lib.getExe vm.config.system.build.vm} "$@"
'';
in {
type = "app";
program = "${program}";
meta = {
description = "start a vm from ${toString module}";
};
}
);
in {
wayland-cinnamon = runVM ./nix/vm/wayland-cinnamon.nix;
wayland-gnome = runVM ./nix/vm/wayland-gnome.nix;
wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix;
x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix;
x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix;
x11-xfce = runVM ./nix/vm/x11-xfce.nix;
};
}
# Our supported systems are the same supported systems as the Zig binaries.
) (builtins.attrNames zig.packages)
)
// {
overlays = {
default = self.overlays.releasefast;
releasefast = final: prev: {
ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-releasefast;
};
debug = final: prev: {
ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-debug;
};
};
});
packages =
forAllPlatforms (pkgs: {
# Deps are needed for environmental setup on macOS
deps = pkgs.callPackage ./build.zig.zon.nix {};
})
// forBuildablePlatforms (pkgs: rec {
ghostty-debug = pkgs.callPackage ./nix/package.nix (mkPkgArgs "Debug");
ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseSafe");
ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkPkgArgs "ReleaseFast");
ghostty = ghostty-releasefast;
default = ghostty;
});
formatter = forAllPlatforms (pkgs: pkgs.alejandra);
apps = forBuildablePlatforms (pkgs: let
runVM = module: let
vm = import ./nix/vm/create.nix {
inherit (pkgs.stdenv.hostPlatform) system;
inherit module nixpkgs;
overlay = self.overlays.debug;
};
program = pkgs.writeShellScript "run-ghostty-vm" ''
SHARED_DIR=$(pwd)
export SHARED_DIR
${pkgs.lib.getExe vm.config.system.build.vm} "$@"
'';
in {
type = "app";
program = "${program}";
meta.description = "start a vm from ${toString module}";
};
in {
wayland-cinnamon = runVM ./nix/vm/wayland-cinnamon.nix;
wayland-gnome = runVM ./nix/vm/wayland-gnome.nix;
wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix;
x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix;
x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix;
x11-xfce = runVM ./nix/vm/x11-xfce.nix;
});
checks = forAllPlatforms (pkgs:
import ./nix/tests.nix {
inherit home-manager nixpkgs self;
inherit (pkgs.stdenv.hostPlatform) system;
});
overlays = {
default = self.overlays.releasefast;
releasefast = final: prev: {
ghostty = final.callPackage ./nix/package.nix (mkPkgArgs "ReleaseFast");
};
debug = final: prev: {
ghostty = final.callPackage ./nix/package.nix (mkPkgArgs "Debug");
};
};
};
nixConfig = {
extra-substituters = ["https://ghostty.cachix.org"];

View File

@@ -67,9 +67,9 @@
},
{
"type": "archive",
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
"dest": "vendor/p/N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7",
"sha256": "348a85d762aa5e122b3fe2f206d83a5ad907c4d51d58ecb388076af12e3946b5"
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz",
"dest": "vendor/p/N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z",
"sha256": "c4dfb789068ddee209fc1ce4805c4ba23807a9ebb3d61b4d7158dc8d647b4238"
},
{
"type": "archive",

View File

@@ -63,24 +63,26 @@ typedef enum {
GHOSTTY_OSC_COMMAND_INVALID = 0,
GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1,
GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2,
GHOSTTY_OSC_COMMAND_PROMPT_START = 3,
GHOSTTY_OSC_COMMAND_PROMPT_END = 4,
GHOSTTY_OSC_COMMAND_END_OF_INPUT = 5,
GHOSTTY_OSC_COMMAND_END_OF_COMMAND = 6,
GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 7,
GHOSTTY_OSC_COMMAND_REPORT_PWD = 8,
GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 9,
GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 10,
GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 11,
GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 12,
GHOSTTY_OSC_COMMAND_HYPERLINK_START = 13,
GHOSTTY_OSC_COMMAND_HYPERLINK_END = 14,
GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 15,
GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 16,
GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 17,
GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 18,
GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 19,
GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20,
GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3,
GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4,
GHOSTTY_OSC_COMMAND_REPORT_PWD = 5,
GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6,
GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7,
GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8,
GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9,
GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10,
GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11,
GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12,
GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13,
GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14,
GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15,
GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16,
GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17,
GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18,
GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19,
GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20,
GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21,
GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22,
} GhosttyOscCommandType;
/**

View File

@@ -623,6 +623,7 @@
INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information.";
INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone.";
INFOPLIST_KEY_NSAudioCaptureUsageDescription = "A program running within Ghostty would like to access your system's audio.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders.";

View File

@@ -476,7 +476,7 @@ class AppDelegate: NSObject,
// profile/rc files for the shell, which is super important on macOS
// due to things like Homebrew. Instead, we set the command to
// `<filename>; exit` which is what Terminal and iTerm2 do.
config.initialInput = "\(filename); exit\n"
config.initialInput = "\(filename.shellQuoted()); exit\n"
// For commands executed directly, we want to ensure we wait after exit
// because in most cases scripts don't block on exit and we don't want

View File

@@ -68,7 +68,7 @@ struct NewTerminalIntent: AppIntent {
// We don't run command as "command" and instead use "initialInput" so
// that we can get all the login scripts to setup things like PATH.
if let command {
config.initialInput = "\(command); exit\n"
config.initialInput = "\(command.shellQuoted()); exit\n"
}
// If we were given a working directory then open that directory

View File

@@ -120,14 +120,16 @@ struct TerminalCommandPaletteView: View {
/// Custom commands from the command-palette-entry configuration.
private var terminalOptions: [CommandOption] {
guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] }
return appDelegate.ghostty.config.commandPaletteEntries.map { c in
CommandOption(
title: c.title,
description: c.description
) {
onAction(c.action)
return appDelegate.ghostty.config.commandPaletteEntries
.filter(\.isSupported)
.map { c in
CommandOption(
title: c.title,
description: c.description
) {
onAction(c.action)
}
}
}
}
/// Commands for jumping to other terminal surfaces.

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24506" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24506"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@@ -17,7 +17,7 @@
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
<rect key="screenRect" x="0.0" y="0.0" width="3008" height="1661"/>
<rect key="screenRect" x="0.0" y="0.0" width="1512" height="949"/>
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24506" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24506"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@@ -17,7 +17,7 @@
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
<rect key="screenRect" x="0.0" y="0.0" width="3008" height="1661"/>
<rect key="screenRect" x="0.0" y="0.0" width="1512" height="949"/>
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>

View File

@@ -508,13 +508,13 @@ extension Ghostty {
return gotoWindow(app, target: target, direction: action.action.goto_window)
case GHOSTTY_ACTION_RESIZE_SPLIT:
resizeSplit(app, target: target, resize: action.action.resize_split)
return resizeSplit(app, target: target, resize: action.action.resize_split)
case GHOSTTY_ACTION_EQUALIZE_SPLITS:
equalizeSplits(app, target: target)
case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM:
toggleSplitZoom(app, target: target)
return toggleSplitZoom(app, target: target)
case GHOSTTY_ACTION_INSPECTOR:
controlInspector(app, target: target, mode: action.action.inspector)
@@ -1247,16 +1247,21 @@ extension Ghostty {
private static func resizeSplit(
_ app: ghostty_app_t,
target: ghostty_target_s,
resize: ghostty_action_resize_split_s) {
resize: ghostty_action_resize_split_s) -> Bool {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("resize split does nothing with an app target")
return
return false
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let resizeDirection = SplitResizeDirection.from(direction: resize.direction) else { return }
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
// If the window has no splits, the action is not performable
guard controller.surfaceTree.isSplit else { return false }
guard let resizeDirection = SplitResizeDirection.from(direction: resize.direction) else { return false }
NotificationCenter.default.post(
name: Notification.didResizeSplit,
object: surfaceView,
@@ -1265,9 +1270,11 @@ extension Ghostty {
Notification.ResizeSplitAmountKey: resize.amount,
]
)
return true
default:
assertionFailure()
return false
}
}
@@ -1295,23 +1302,30 @@ extension Ghostty {
private static func toggleSplitZoom(
_ app: ghostty_app_t,
target: ghostty_target_s) {
target: ghostty_target_s) -> Bool {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("toggle split zoom does nothing with an app target")
return
return false
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
// If the window has no splits, the action is not performable
guard controller.surfaceTree.isSplit else { return false }
NotificationCenter.default.post(
name: Notification.didToggleSplitZoom,
object: surfaceView
)
return true
default:
assertionFailure()
return false
}
}
@@ -1321,7 +1335,7 @@ extension Ghostty {
mode: ghostty_action_inspector_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("toggle split zoom does nothing with an app target")
Ghostty.logger.warning("toggle inspector does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:

View File

@@ -0,0 +1,100 @@
import GhosttyKit
import Metal
extension Ghostty {
/// Represents the inspector for a surface within Ghostty.
///
/// Wraps a `ghostty_inspector_t`
final class Inspector: Sendable {
private let inspector: ghostty_inspector_t
/// Read the underlying C value for this inspector. This is unsafe because the value will be
/// freed when the Inspector class is deinitialized.
var unsafeCValue: ghostty_inspector_t {
inspector
}
/// Initialize from the C structure.
init(cInspector: ghostty_inspector_t) {
self.inspector = cInspector
}
/// Set the focus state of the inspector.
@MainActor
func setFocus(_ focused: Bool) {
ghostty_inspector_set_focus(inspector, focused)
}
/// Set the content scale of the inspector.
@MainActor
func setContentScale(x: Double, y: Double) {
ghostty_inspector_set_content_scale(inspector, x, y)
}
/// Set the size of the inspector.
@MainActor
func setSize(width: UInt32, height: UInt32) {
ghostty_inspector_set_size(inspector, width, height)
}
/// Send a mouse button event to the inspector.
@MainActor
func mouseButton(
_ state: ghostty_input_mouse_state_e,
button: ghostty_input_mouse_button_e,
mods: ghostty_input_mods_e
) {
ghostty_inspector_mouse_button(inspector, state, button, mods)
}
/// Send a mouse position event to the inspector.
@MainActor
func mousePos(x: Double, y: Double) {
ghostty_inspector_mouse_pos(inspector, x, y)
}
/// Send a mouse scroll event to the inspector.
@MainActor
func mouseScroll(x: Double, y: Double, mods: ghostty_input_scroll_mods_t) {
ghostty_inspector_mouse_scroll(inspector, x, y, mods)
}
/// Send a key event to the inspector.
@MainActor
func key(
_ action: ghostty_input_action_e,
key: ghostty_input_key_e,
mods: ghostty_input_mods_e
) {
ghostty_inspector_key(inspector, action, key, mods)
}
/// Send text to the inspector.
@MainActor
func text(_ text: String) {
text.withCString { ptr in
ghostty_inspector_text(inspector, ptr)
}
}
/// Initialize Metal rendering for the inspector.
@MainActor
func metalInit(device: MTLDevice) -> Bool {
let devicePtr = Unmanaged.passRetained(device).toOpaque()
return ghostty_inspector_metal_init(inspector, devicePtr)
}
/// Render the inspector using Metal.
@MainActor
func metalRender(
commandBuffer: MTLCommandBuffer,
descriptor: MTLRenderPassDescriptor
) {
ghostty_inspector_metal_render(
inspector,
Unmanaged.passRetained(commandBuffer).toOpaque(),
Unmanaged.passRetained(descriptor).toOpaque()
)
}
}
}

View File

@@ -98,7 +98,7 @@ extension Ghostty {
didSet { surfaceViewDidChange() }
}
private var inspector: ghostty_inspector_t? {
private var inspector: Ghostty.Inspector? {
guard let surfaceView = self.surfaceView else { return nil }
return surfaceView.inspector
}
@@ -120,9 +120,9 @@ extension Ghostty {
self.commandQueue = commandQueue
super.init(frame: frame, device: device)
// This makes it so renders only happen when we request
self.enableSetNeedsDisplay = true
self.isPaused = true
// Use timed updates mode. This is required for the inspector.
self.isPaused = false
self.preferredFramesPerSecond = 30
// After initializing the parent we can set our own properties
self.device = MTLCreateSystemDefaultDevice()
@@ -130,6 +130,13 @@ extension Ghostty {
// Setup our tracking areas for mouse events
updateTrackingAreas()
// Observe occlusion state to pause rendering when not visible
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidChangeOcclusionState),
name: NSWindow.didChangeOcclusionStateNotification,
object: nil)
}
required init(coder: NSCoder) {
@@ -141,28 +148,19 @@ extension Ghostty {
NotificationCenter.default.removeObserver(self)
}
@objc private func windowDidChangeOcclusionState(_ notification: NSNotification) {
guard let window = notification.object as? NSWindow,
window == self.window else { return }
// Pause rendering when our window isn't visible.
isPaused = !window.occlusionState.contains(.visible)
}
// MARK: Internal Inspector Funcs
private func surfaceViewDidChange() {
let center = NotificationCenter.default
center.removeObserver(self)
guard let surfaceView = self.surfaceView else { return }
guard let inspector = self.inspector else { return }
guard let device = self.device else { return }
let devicePtr = Unmanaged.passRetained(device).toOpaque()
ghostty_inspector_metal_init(inspector, devicePtr)
// Register an observer for render requests
center.addObserver(
self,
selector: #selector(didRequestRender),
name: Ghostty.Notification.inspectorNeedsDisplay,
object: surfaceView)
}
@objc private func didRequestRender(notification: SwiftUI.Notification) {
self.needsDisplay = true
_ = inspector.metalInit(device: device)
}
private func updateSize() {
@@ -172,10 +170,10 @@ extension Ghostty {
let fbFrame = self.convertToBacking(self.frame)
let xScale = fbFrame.size.width / self.frame.size.width
let yScale = fbFrame.size.height / self.frame.size.height
ghostty_inspector_set_content_scale(inspector, xScale, yScale)
inspector.setContentScale(x: xScale, y: yScale)
// When our scale factor changes, so does our fb size so we send that too
ghostty_inspector_set_size(inspector, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height))
inspector.setSize(width: UInt32(fbFrame.size.width), height: UInt32(fbFrame.size.height))
}
// MARK: NSView
@@ -184,7 +182,7 @@ extension Ghostty {
let result = super.becomeFirstResponder()
if (result) {
if let inspector = self.inspector {
ghostty_inspector_set_focus(inspector, true)
inspector.setFocus(true)
}
}
return result
@@ -194,7 +192,7 @@ extension Ghostty {
let result = super.resignFirstResponder()
if (result) {
if let inspector = self.inspector {
ghostty_inspector_set_focus(inspector, false)
inspector.setFocus(false)
}
}
return result
@@ -229,25 +227,25 @@ extension Ghostty {
override func mouseDown(with event: NSEvent) {
guard let inspector = self.inspector else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_mouse_button(inspector, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods)
inspector.mouseButton(GHOSTTY_MOUSE_PRESS, button: GHOSTTY_MOUSE_LEFT, mods: mods)
}
override func mouseUp(with event: NSEvent) {
guard let inspector = self.inspector else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_mouse_button(inspector, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods)
inspector.mouseButton(GHOSTTY_MOUSE_RELEASE, button: GHOSTTY_MOUSE_LEFT, mods: mods)
}
override func rightMouseDown(with event: NSEvent) {
guard let inspector = self.inspector else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_mouse_button(inspector, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods)
inspector.mouseButton(GHOSTTY_MOUSE_PRESS, button: GHOSTTY_MOUSE_RIGHT, mods: mods)
}
override func rightMouseUp(with event: NSEvent) {
guard let inspector = self.inspector else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_mouse_button(inspector, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods)
inspector.mouseButton(GHOSTTY_MOUSE_RELEASE, button: GHOSTTY_MOUSE_RIGHT, mods: mods)
}
override func mouseMoved(with event: NSEvent) {
@@ -255,7 +253,7 @@ extension Ghostty {
// Convert window position to view position. Note (0, 0) is bottom left.
let pos = self.convert(event.locationInWindow, from: nil)
ghostty_inspector_mouse_pos(inspector, pos.x, frame.height - pos.y)
inspector.mousePos(x: pos.x, y: frame.height - pos.y)
}
@@ -269,16 +267,10 @@ extension Ghostty {
// Builds up the "input.ScrollMods" bitmask
var mods: Int32 = 0
var x = event.scrollingDeltaX
var y = event.scrollingDeltaY
let x = event.scrollingDeltaX
let y = event.scrollingDeltaY
if event.hasPreciseScrollingDeltas {
mods = 1
// We do a 2x speed multiplier. This is subjective, it "feels" better to me.
x *= 2;
y *= 2;
// TODO(mitchellh): do we have to scale the x/y here by window scale factor?
}
// Determine our momentum value
@@ -303,7 +295,7 @@ extension Ghostty {
// Pack our momentum value into the mods bitmask
mods |= Int32(momentum.rawValue) << 1
ghostty_inspector_mouse_scroll(inspector, x, y, mods)
inspector.mouseScroll(x: x, y: y, mods: mods)
}
override func keyDown(with event: NSEvent) {
@@ -342,7 +334,7 @@ extension Ghostty {
guard let inspector = self.inspector else { return }
guard let key = Ghostty.Input.Key(keyCode: event.keyCode) else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_key(inspector, action, key.cKey, mods)
inspector.key(action, key: key.cKey, mods: mods)
}
// MARK: NSTextInputClient
@@ -412,9 +404,7 @@ extension Ghostty {
let len = chars.utf8CString.count
if (len == 0) { return }
chars.withCString { ptr in
ghostty_inspector_text(inspector, ptr)
}
inspector.text(chars)
}
override func doCommand(by selector: Selector) {
@@ -441,11 +431,7 @@ extension Ghostty {
updateSize()
// Render
ghostty_inspector_metal_render(
inspector,
Unmanaged.passRetained(commandBuffer).toOpaque(),
Unmanaged.passRetained(descriptor).toOpaque()
)
inspector.metalRender(commandBuffer: commandBuffer, descriptor: descriptor)
guard let drawable = self.currentDrawable else { return }
commandBuffer.present(drawable)

View File

@@ -190,7 +190,12 @@ extension Ghostty {
SurfaceSearchOverlay(
surfaceView: surfaceView,
searchState: searchState,
onClose: { surfaceView.searchState = nil }
onClose: {
#if canImport(AppKit)
Ghostty.moveFocus(to: surfaceView)
#endif
surfaceView.searchState = nil
}
)
}
@@ -432,7 +437,6 @@ extension Ghostty {
#if canImport(AppKit)
.onExitCommand {
if searchState.needle.isEmpty {
Ghostty.moveFocus(to: surfaceView)
onClose()
} else {
Ghostty.moveFocus(to: surfaceView)

View File

@@ -173,10 +173,11 @@ extension Ghostty {
}
// Returns the inspector instance for this surface, or nil if the
// surface has been closed.
var inspector: ghostty_inspector_t? {
// surface has been closed or no inspector is active.
var inspector: Ghostty.Inspector? {
guard let surface = self.surface else { return nil }
return ghostty_surface_inspector(surface)
guard let cInspector = ghostty_surface_inspector(surface) else { return nil }
return Ghostty.Inspector(cInspector: cInspector)
}
// True if the inspector should be visible

View File

@@ -26,4 +26,12 @@ extension String {
return self
}
#endif
private static let shellUnsafe = /[^\w@%+=:,.\/-]/
/// Returns a shell-escaped version of the string, like Python's shlex.quote.
func shellQuoted() -> String {
guard self.isEmpty || self.contains(Self.shellUnsafe) else { return self };
return "'" + self.replacingOccurrences(of: "'", with: #"'"'"'"#) + "'"
}
}

View File

@@ -0,0 +1,19 @@
import Testing
@testable import Ghostty
struct StringExtensionTests {
@Test(arguments: [
("", "''"),
("filename", "filename"),
("abcABC123@%_-+=:,./", "abcABC123@%_-+=:,./"),
("file name", "'file name'"),
("file$name", "'file$name'"),
("file!name", "'file!name'"),
("file\\name", "'file\\name'"),
("it's", "'it'\"'\"'s'"),
("file$'name'", "'file$'\"'\"'name'\"'\"''"),
])
func shellQuoted(input: String, expected: String) {
#expect(input.shellQuoted() == expected)
}
}

View File

@@ -35,10 +35,10 @@
pkgs.libadwaita
]
++ lib.optionals (stdenv.hostPlatform.isLinux && enableX11) [
pkgs.xorg.libX11
pkgs.xorg.libXcursor
pkgs.xorg.libXi
pkgs.xorg.libXrandr
pkgs.libx11
pkgs.libxcursor
pkgs.libxi
pkgs.libxrandr
]
++ lib.optionals (stdenv.hostPlatform.isLinux && enableWayland) [
pkgs.gtk4-layer-shell

View File

@@ -77,7 +77,6 @@ in
buildInputs = buildInputs;
dontConfigure = true;
dontStrip = !strip;
GI_TYPELIB_PATH = gi_typelib_path;

View File

@@ -91,6 +91,7 @@
};
programs.ssh = {
enable = true;
enableDefaultConfig = false;
extraOptionOverrides = {
StrictHostKeyChecking = "accept-new";
UserKnownHostsFile = "/dev/null";

View File

@@ -49,6 +49,7 @@ pub fn build(b: *std.Build) !void {
var flags: std.ArrayList([]const u8) = .empty;
defer flags.deinit(b.allocator);
try flags.appendSlice(b.allocator, &.{
"-DIMGUI_HAS_DOCK=1",
"-DIMGUI_USE_WCHAR32=1",
"-DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1",
});

View File

@@ -5,6 +5,8 @@ pub const c = @cImport({
// during import time to get the right types. Without this
// you get stack size mismatches on some structs.
@cDefine("IMGUI_USE_WCHAR32", "1");
@cDefine("IMGUI_HAS_DOCK", "1");
@cInclude("dcimgui.h");
});
@@ -25,11 +27,39 @@ pub extern fn ImGui_ImplOSX_Init(*anyopaque) callconv(.c) bool;
pub extern fn ImGui_ImplOSX_Shutdown() callconv(.c) void;
pub extern fn ImGui_ImplOSX_NewFrame(*anyopaque) callconv(.c) void;
// Internal API functions from dcimgui_internal.h
// Internal API types and functions from dcimgui_internal.h
// We declare these manually because the internal header contains bitfields
// that Zig's cImport cannot translate.
pub const ImGuiDockNodeFlagsPrivate = struct {
pub const DockSpace: c.ImGuiDockNodeFlags = 1 << 10;
pub const CentralNode: c.ImGuiDockNodeFlags = 1 << 11;
pub const NoTabBar: c.ImGuiDockNodeFlags = 1 << 12;
pub const HiddenTabBar: c.ImGuiDockNodeFlags = 1 << 13;
pub const NoWindowMenuButton: c.ImGuiDockNodeFlags = 1 << 14;
pub const NoCloseButton: c.ImGuiDockNodeFlags = 1 << 15;
pub const NoResizeX: c.ImGuiDockNodeFlags = 1 << 16;
pub const NoResizeY: c.ImGuiDockNodeFlags = 1 << 17;
pub const DockedWindowsInFocusRoute: c.ImGuiDockNodeFlags = 1 << 18;
pub const NoDockingSplitOther: c.ImGuiDockNodeFlags = 1 << 19;
pub const NoDockingOverMe: c.ImGuiDockNodeFlags = 1 << 20;
pub const NoDockingOverOther: c.ImGuiDockNodeFlags = 1 << 21;
pub const NoDockingOverEmpty: c.ImGuiDockNodeFlags = 1 << 22;
};
pub extern fn ImGui_DockBuilderDockWindow(window_name: [*:0]const u8, node_id: c.ImGuiID) callconv(.c) void;
pub extern fn ImGui_DockBuilderGetNode(node_id: c.ImGuiID) callconv(.c) ?*anyopaque;
pub extern fn ImGui_DockBuilderGetCentralNode(node_id: c.ImGuiID) callconv(.c) ?*anyopaque;
pub extern fn ImGui_DockBuilderAddNode() callconv(.c) c.ImGuiID;
pub extern fn ImGui_DockBuilderAddNodeEx(node_id: c.ImGuiID, flags: c.ImGuiDockNodeFlags) callconv(.c) c.ImGuiID;
pub extern fn ImGui_DockBuilderRemoveNode(node_id: c.ImGuiID) callconv(.c) void;
pub extern fn ImGui_DockBuilderRemoveNodeDockedWindows(node_id: c.ImGuiID) callconv(.c) void;
pub extern fn ImGui_DockBuilderRemoveNodeDockedWindowsEx(node_id: c.ImGuiID, clear_settings_refs: bool) callconv(.c) void;
pub extern fn ImGui_DockBuilderRemoveNodeChildNodes(node_id: c.ImGuiID) callconv(.c) void;
pub extern fn ImGui_DockBuilderSetNodePos(node_id: c.ImGuiID, pos: c.ImVec2) callconv(.c) void;
pub extern fn ImGui_DockBuilderSetNodeSize(node_id: c.ImGuiID, size: c.ImVec2) callconv(.c) void;
pub extern fn ImGui_DockBuilderSplitNode(node_id: c.ImGuiID, split_dir: c.ImGuiDir, size_ratio_for_node_at_dir: f32, out_id_at_dir: *c.ImGuiID, out_id_at_opposite_dir: *c.ImGuiID) callconv(.c) c.ImGuiID;
pub extern fn ImGui_DockBuilderCopyDockSpace(src_dockspace_id: c.ImGuiID, dst_dockspace_id: c.ImGuiID, in_window_remap_pairs: *c.ImVector_const_charPtr) callconv(.c) void;
pub extern fn ImGui_DockBuilderCopyNode(src_node_id: c.ImGuiID, dst_node_id: c.ImGuiID, out_node_remap_pairs: *c.ImVector_ImGuiID) callconv(.c) void;
pub extern fn ImGui_DockBuilderCopyWindowSettings(src_name: [*:0]const u8, dst_name: [*:0]const u8) callconv(.c) void;
pub extern fn ImGui_DockBuilderFinish(node_id: c.ImGuiID) callconv(.c) void;
// Extension functions from ext.cpp

View File

@@ -238,6 +238,12 @@ pub const Buffer = struct {
pub fn guessSegmentProperties(self: Buffer) void {
c.hb_buffer_guess_segment_properties(self.handle);
}
/// Sets the cluster level of a buffer. The `ClusterLevel` dictates one
/// aspect of how HarfBuzz will treat non-base characters during shaping.
pub fn setClusterLevel(self: Buffer, level: ClusterLevel) void {
c.hb_buffer_set_cluster_level(self.handle, @intFromEnum(level));
}
};
/// The type of hb_buffer_t contents.
@@ -252,6 +258,40 @@ pub const ContentType = enum(u2) {
glyphs = c.HB_BUFFER_CONTENT_TYPE_GLYPHS,
};
/// Data type for holding HarfBuzz's clustering behavior options. The cluster
/// level dictates one aspect of how HarfBuzz will treat non-base characters
/// during shaping.
pub const ClusterLevel = enum(u2) {
/// In `monotone_graphemes`, non-base characters are merged into the
/// cluster of the base character that precedes them. There is also cluster
/// merging every time the clusters will otherwise become non-monotone.
/// This is the default cluster level.
monotone_graphemes = c.HB_BUFFER_CLUSTER_LEVEL_MONOTONE_GRAPHEMES,
/// In `monotone_characters`, non-base characters are initially assigned
/// their own cluster values, which are not merged into preceding base
/// clusters. This allows HarfBuzz to perform additional operations like
/// reorder sequences of adjacent marks. The output is still monotone, but
/// the cluster values are more granular.
monotone_characters = c.HB_BUFFER_CLUSTER_LEVEL_MONOTONE_CHARACTERS,
/// In `characters`, non-base characters are assigned their own cluster
/// values, which are not merged into preceding base clusters. Moreover,
/// the cluster values are not merged into monotone order. This is the most
/// granular cluster level, and it is useful for clients that need to know
/// the exact cluster values of each character, but is harder to use for
/// clients, since clusters might appear in any order.
characters = c.HB_BUFFER_CLUSTER_LEVEL_CHARACTERS,
/// In `graphemes`, non-base characters are merged into the cluster of the
/// base character that precedes them. This is similar to the Unicode
/// Grapheme Cluster algorithm, but it is not exactly the same. The output
/// is not forced to be monotone. This is useful for clients that want to
/// use HarfBuzz as a cheap implementation of the Unicode Grapheme Cluster
/// algorithm.
graphemes = c.HB_BUFFER_CLUSTER_LEVEL_GRAPHEMES,
};
/// The hb_glyph_info_t is the structure that holds information about the
/// glyphs and their relation to input text.
pub const GlyphInfo = extern struct {

View File

@@ -13,6 +13,7 @@ pub const coretext = @import("coretext.zig");
pub const MemoryMode = blob.MemoryMode;
pub const Blob = blob.Blob;
pub const Buffer = buffer.Buffer;
pub const GlyphPosition = buffer.GlyphPosition;
pub const Direction = common.Direction;
pub const Script = common.Script;
pub const Language = common.Language;

339
po/lv_LV.UTF-8.po Normal file
View File

@@ -0,0 +1,339 @@
# Latvian translations for com.mitchellh.ghostty package.
# Copyright (C) 2026 "Mitchell Hashimoto, Ghostty contributors"
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Ēriks Remess <eriks@remess.lv>, 2026.
#
msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-05 10:23+0800\n"
"PO-Revision-Date: 2026-02-09 03:24+0200\n"
"Last-Translator: Ēriks Remess <eriks@remess.lv>\n"
"Language-Team: Latvian\n"
"Language: LV\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n!=0 ? 1 : 2);\n"
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:201
msgid "Authorize Clipboard Access"
msgstr "Atļaut piekļuvi starpliktuvei"
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:17
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:17
msgid "Deny"
msgstr "Noraidīt"
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:18
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:18
msgid "Allow"
msgstr "Atļaut"
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:92
msgid "Remember choice for this split"
msgstr "Atcerēties izvēli šim sadalījumam"
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:93
msgid "Reload configuration to show this prompt again"
msgstr "Pārlādējiet konfigurāciju, lai šo uzvedni rādītu atkal"
#: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7
#: src/apprt/gtk/ui/1.5/surface-title-dialog.blp:9
msgid "Cancel"
msgstr "Atcelt"
#: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8
#: src/apprt/gtk/ui/1.2/search-overlay.blp:85
#: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17
msgid "Close"
msgstr "Aizvērt"
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6
msgid "Configuration Errors"
msgstr "Konfigurācijas kļūdas"
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7
msgid ""
"One or more configuration errors were found. Please review the errors below, "
"and either reload your configuration or ignore these errors."
msgstr ""
"Tika atrasta viena vai vairākas konfigurācijas kļūdas. Lūdzu, pārskatiet "
"zemāk redzamās kļūdas un pārlādējiet konfigurāciju vai ignorējiet tās."
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10
msgid "Ignore"
msgstr "Ignorēt"
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11
#: src/apprt/gtk/ui/1.2/surface.blp:361 src/apprt/gtk/ui/1.5/window.blp:293
msgid "Reload Configuration"
msgstr "Pārlādēt konfigurāciju"
#: src/apprt/gtk/ui/1.2/debug-warning.blp:7
#: src/apprt/gtk/ui/1.3/debug-warning.blp:6
msgid ""
"⚠️ You're running a debug build of Ghostty! Performance will be degraded."
msgstr "⚠️ Jūs izmantojat Ghostty atkļūdošanas būvējumu! Veiktspēja būs zemāka."
#: src/apprt/gtk/ui/1.5/inspector-window.blp:5
msgid "Ghostty: Terminal Inspector"
msgstr "Ghostty: Termināļa inspektors"
#: src/apprt/gtk/ui/1.2/search-overlay.blp:29
msgid "Find…"
msgstr "Meklēt…"
#: src/apprt/gtk/ui/1.2/search-overlay.blp:64
msgid "Previous Match"
msgstr "Iepriekšējā atbilstība"
#: src/apprt/gtk/ui/1.2/search-overlay.blp:74
msgid "Next Match"
msgstr "Nākamā atbilstība"
#: src/apprt/gtk/ui/1.2/surface.blp:6
msgid "Oh, no."
msgstr "Ak, nē."
#: src/apprt/gtk/ui/1.2/surface.blp:7
msgid "Unable to acquire an OpenGL context for rendering."
msgstr "Neizdevās iegūt OpenGL kontekstu attēlošanai."
#: src/apprt/gtk/ui/1.2/surface.blp:97
msgid ""
"This terminal is in read-only mode. You can still view, select, and scroll "
"through the content, but no input events will be sent to the running "
"application."
msgstr ""
"Šis terminālis ir tikai lasīšanas režīmā. Jūs joprojām varat skatīt, atlasīt "
"un ritināt saturu, taču ievade netiks sūtīta palaistajai lietotnei."
#: src/apprt/gtk/ui/1.2/surface.blp:107
msgid "Read-only"
msgstr "Tikai lasīšanai"
#: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:198
msgid "Copy"
msgstr "Kopēt"
#: src/apprt/gtk/ui/1.2/surface.blp:265 src/apprt/gtk/ui/1.5/window.blp:203
msgid "Paste"
msgstr "Ielīmēt"
#: src/apprt/gtk/ui/1.2/surface.blp:270
msgid "Notify on Next Command Finish"
msgstr "Paziņot, kad nākamā komanda būs izpildīta"
#: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:266
msgid "Clear"
msgstr "Notīrīt"
#: src/apprt/gtk/ui/1.2/surface.blp:282 src/apprt/gtk/ui/1.5/window.blp:271
msgid "Reset"
msgstr "Atiestatīt"
#: src/apprt/gtk/ui/1.2/surface.blp:289 src/apprt/gtk/ui/1.5/window.blp:235
msgid "Split"
msgstr "Sadalīt"
#: src/apprt/gtk/ui/1.2/surface.blp:292 src/apprt/gtk/ui/1.5/window.blp:238
msgid "Change Title…"
msgstr "Mainīt virsrakstu…"
#: src/apprt/gtk/ui/1.2/surface.blp:297 src/apprt/gtk/ui/1.5/window.blp:175
#: src/apprt/gtk/ui/1.5/window.blp:243
msgid "Split Up"
msgstr "Sadalīt uz augšu"
#: src/apprt/gtk/ui/1.2/surface.blp:303 src/apprt/gtk/ui/1.5/window.blp:180
#: src/apprt/gtk/ui/1.5/window.blp:248
msgid "Split Down"
msgstr "Sadalīt uz leju"
#: src/apprt/gtk/ui/1.2/surface.blp:309 src/apprt/gtk/ui/1.5/window.blp:185
#: src/apprt/gtk/ui/1.5/window.blp:253
msgid "Split Left"
msgstr "Sadalīt pa kreisi"
#: src/apprt/gtk/ui/1.2/surface.blp:315 src/apprt/gtk/ui/1.5/window.blp:190
#: src/apprt/gtk/ui/1.5/window.blp:258
msgid "Split Right"
msgstr "Sadalīt pa labi"
#: src/apprt/gtk/ui/1.2/surface.blp:322
msgid "Tab"
msgstr "Cilne"
#: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:57
#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:222
msgid "New Tab"
msgstr "Jauna cilne"
#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:227
msgid "Close Tab"
msgstr "Aizvērt cilni"
#: src/apprt/gtk/ui/1.2/surface.blp:337
msgid "Window"
msgstr "Logs"
#: src/apprt/gtk/ui/1.2/surface.blp:340 src/apprt/gtk/ui/1.5/window.blp:210
msgid "New Window"
msgstr "Jauns logs"
#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:215
msgid "Close Window"
msgstr "Aizvērt logu"
#: src/apprt/gtk/ui/1.2/surface.blp:353
msgid "Config"
msgstr "Konfigurācija"
#: src/apprt/gtk/ui/1.2/surface.blp:356 src/apprt/gtk/ui/1.5/window.blp:288
msgid "Open Configuration"
msgstr "Atvērt konfigurāciju"
#: src/apprt/gtk/ui/1.5/surface-title-dialog.blp:5
msgid "Change Terminal Title"
msgstr "Mainīt termināļa virsrakstu"
#: src/apprt/gtk/ui/1.5/surface-title-dialog.blp:6
msgid "Leave blank to restore the default title."
msgstr "Atstāj tukšu, lai atjaunotu noklusēto virsrakstu."
#: src/apprt/gtk/ui/1.5/surface-title-dialog.blp:10
msgid "OK"
msgstr "Labi"
#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108
msgid "New Split"
msgstr "Jauns sadalījums"
#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126
msgid "View Open Tabs"
msgstr "Skatīt atvērtās cilnes"
#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140
msgid "Main Menu"
msgstr "Galvenā izvēlne"
#: src/apprt/gtk/ui/1.5/window.blp:278
msgid "Command Palette"
msgstr "Komandu palete"
#: src/apprt/gtk/ui/1.5/window.blp:283
msgid "Terminal Inspector"
msgstr "Termināļa inspektors"
#: src/apprt/gtk/ui/1.5/window.blp:300 src/apprt/gtk/class/window.zig:1714
msgid "About Ghostty"
msgstr "Par Ghostty"
#: src/apprt/gtk/ui/1.5/window.blp:305
msgid "Quit"
msgstr "Iziet"
#: src/apprt/gtk/ui/1.5/command-palette.blp:17
msgid "Execute a command…"
msgstr "Izpildīt komandu…"
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198
msgid ""
"An application is attempting to write to the clipboard. The current "
"clipboard contents are shown below."
msgstr ""
"Lietotne mēģina rakstīt starpliktuvē. Zemāk ir redzams pašreizējais "
"starpliktuves saturs."
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:202
msgid ""
"An application is attempting to read from the clipboard. The current "
"clipboard contents are shown below."
msgstr ""
"Lietotne mēģina lasīt no starpliktuves. Zemāk ir redzams pašreizējais "
"starpliktuves saturs."
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:205
msgid "Warning: Potentially Unsafe Paste"
msgstr "Brīdinājums: potenciāli nedroša ielīmēšana"
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:206
msgid ""
"Pasting this text into the terminal may be dangerous as it looks like some "
"commands may be executed."
msgstr ""
"Šī teksta ielīmēšana terminālī var būt bīstama, jo izskatās, ka var tikt "
"izpildītas komandas."
#: src/apprt/gtk/class/close_confirmation_dialog.zig:184
msgid "Quit Ghostty?"
msgstr "Iziet no Ghostty?"
#: src/apprt/gtk/class/close_confirmation_dialog.zig:185
msgid "Close Tab?"
msgstr "Aizvērt cilni?"
#: src/apprt/gtk/class/close_confirmation_dialog.zig:186
msgid "Close Window?"
msgstr "Aizvērt logu?"
#: src/apprt/gtk/class/close_confirmation_dialog.zig:187
msgid "Close Split?"
msgstr "Aizvērt sadalījumu?"
#: src/apprt/gtk/class/close_confirmation_dialog.zig:193
msgid "All terminal sessions will be terminated."
msgstr "Visas termināļa sesijas tiks pārtrauktas."
#: src/apprt/gtk/class/close_confirmation_dialog.zig:194
msgid "All terminal sessions in this tab will be terminated."
msgstr "Visas termināļa sesijas šajā cilnē tiks pārtrauktas."
#: src/apprt/gtk/class/close_confirmation_dialog.zig:195
msgid "All terminal sessions in this window will be terminated."
msgstr "Visas termināļa sesijas šajā logā tiks pārtrauktas."
#: src/apprt/gtk/class/close_confirmation_dialog.zig:196
msgid "The currently running process in this split will be terminated."
msgstr "Pašlaik palaistais process šajā sadalījumā tiks pārtraukts."
#: src/apprt/gtk/class/surface.zig:1108
msgid "Command Finished"
msgstr "Komanda izpildīta"
#: src/apprt/gtk/class/surface.zig:1109
msgid "Command Succeeded"
msgstr "Komanda izdevās"
#: src/apprt/gtk/class/surface.zig:1110
msgid "Command Failed"
msgstr "Komanda neizdevās"
#: src/apprt/gtk/class/surface_child_exited.zig:109
msgid "Command succeeded"
msgstr "Komanda izdevās"
#: src/apprt/gtk/class/surface_child_exited.zig:113
msgid "Command failed"
msgstr "Komanda neizdevās"
#: src/apprt/gtk/class/window.zig:1001
msgid "Reloaded the configuration"
msgstr "Konfigurācija pārlādēta"
#: src/apprt/gtk/class/window.zig:1553
msgid "Copied to clipboard"
msgstr "Nokopēts starpliktuvē"
#: src/apprt/gtk/class/window.zig:1555
msgid "Cleared clipboard"
msgstr "Starpliktuve notīrīta"
#: src/apprt/gtk/class/window.zig:1695
msgid "Ghostty Developers"
msgstr "Ghostty izstrādātāji"

View File

@@ -237,14 +237,19 @@ pub fn needsConfirmQuit(self: *const App) bool {
/// Drain the mailbox.
fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
while (self.mailbox.pop()) |message| {
log.debug("mailbox message={s}", .{@tagName(message)});
if (comptime std.log.logEnabled(.debug, .app)) {
switch (message) {
// these tend to be way too verbose for normal debugging
.redraw_surface => {},
else => log.debug("mailbox message={t}", .{message}),
}
}
switch (message) {
.open_config => try self.performAction(rt_app, .open_config),
.new_window => |msg| try self.newWindow(rt_app, msg),
.close => |surface| self.closeSurface(surface),
.surface_message => |msg| try self.surfaceMessage(msg.surface, msg.message),
.redraw_surface => |surface| try self.redrawSurface(rt_app, surface),
.redraw_inspector => |surface| self.redrawInspector(rt_app, surface),
// If we're quitting, then we set the quit flag and stop
// draining the mailbox immediately. This lets us defer
@@ -283,11 +288,6 @@ fn redrawSurface(
);
}
fn redrawInspector(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) void {
if (!self.hasRtSurface(surface)) return;
rt_app.redrawInspector(surface);
}
/// Create a new window
pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void {
const target: apprt.Target = target: {
@@ -559,10 +559,6 @@ pub const Message = union(enum) {
/// message if it needs to.
redraw_surface: *apprt.Surface,
/// Redraw the inspector. This is called whenever some non-OS event
/// causes the inspector to need to be redrawn.
redraw_inspector: *apprt.Surface,
const NewWindow = struct {
/// The parent surface
parent: ?*Surface = null,

View File

@@ -803,7 +803,7 @@ pub fn deinit(self: *Surface) void {
self.io.deinit();
if (self.inspector) |v| {
v.deinit();
v.deinit(self.alloc);
self.alloc.destroy(v);
}
@@ -879,8 +879,10 @@ pub fn activateInspector(self: *Surface) !void {
// Setup the inspector
const ptr = try self.alloc.create(inspectorpkg.Inspector);
errdefer self.alloc.destroy(ptr);
ptr.* = try inspectorpkg.Inspector.init(self);
ptr.* = try inspectorpkg.Inspector.init(self.alloc);
errdefer ptr.deinit(self.alloc);
self.inspector = ptr;
errdefer self.inspector = null;
// Put the inspector onto the render state
{
@@ -912,7 +914,7 @@ pub fn deactivateInspector(self: *Surface) void {
self.queueIo(.{ .inspector = false }, .unlocked);
// Deinit the inspector
insp.deinit();
insp.deinit(self.alloc);
self.alloc.destroy(insp);
self.inspector = null;
}
@@ -2618,7 +2620,7 @@ pub fn keyCallback(
defer crash.sentry.thread_state = null;
// Setup our inspector event if we have an inspector.
var insp_ev: ?inspectorpkg.key.Event = if (self.inspector != null) ev: {
var insp_ev: ?inspectorpkg.KeyEvent = if (self.inspector != null) ev: {
var copy = event;
copy.utf8 = "";
if (event.utf8.len > 0) copy.utf8 = try self.alloc.dupe(u8, event.utf8);
@@ -2635,7 +2637,7 @@ pub fn keyCallback(
break :ev;
};
if (insp.recordKeyEvent(ev)) {
if (insp.recordKeyEvent(self.alloc, ev)) {
self.queueRender() catch {};
} else |err| {
log.warn("error adding key event to inspector err={}", .{err});
@@ -2798,7 +2800,7 @@ pub fn keyCallback(
fn maybeHandleBinding(
self: *Surface,
event: input.KeyEvent,
insp_ev: ?*inspectorpkg.key.Event,
insp_ev: ?*inspectorpkg.KeyEvent,
) !?InputEffect {
switch (event.action) {
// Release events never trigger a binding but we need to check if
@@ -3131,7 +3133,7 @@ fn endKeySequence(
fn encodeKey(
self: *Surface,
event: input.KeyEvent,
insp_ev: ?*inspectorpkg.key.Event,
insp_ev: ?*inspectorpkg.KeyEvent,
) !?termio.Message.WriteReq {
const write_req: termio.Message.WriteReq = req: {
// Build our encoding options, which requires the lock.
@@ -3872,36 +3874,8 @@ pub fn mouseButtonCallback(
// log.debug("mouse action={} button={} mods={}", .{ action, button, mods });
// If we have an inspector, we always queue a render
if (self.inspector) |insp| {
if (self.inspector != null) {
defer self.queueRender() catch {};
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
// If the inspector is requesting a cell, then we intercept
// left mouse clicks and send them to the inspector.
if (insp.cell == .requested and
button == .left and
action == .press)
{
const pos = try self.rt_surface.getCursorPos();
const point = self.posToViewport(pos.x, pos.y);
const screen: *terminal.Screen = self.renderer_state.terminal.screens.active;
const p = screen.pages.pin(.{ .viewport = point }) orelse {
log.warn("failed to get pin for clicked point", .{});
return false;
};
insp.cell.select(
self.alloc,
p,
point.x,
point.y,
) catch |err| {
log.warn("error selecting cell for inspector err={}", .{err});
};
return false;
}
}
// Always record our latest mouse state
@@ -3995,6 +3969,15 @@ pub fn mouseButtonCallback(
log.warn("error processing links err={}", .{err});
}
}
// Handle prompt clicking. If we released our mouse on a prompt
// and we support some kind of click events, then we need to
// move to it.
if (self.maybePromptClick()) |handled| {
if (handled) return true;
} else |err| {
log.warn("error processing prompt click err={}", .{err});
}
}
// Report mouse events if enabled
@@ -4036,25 +4019,6 @@ pub fn mouseButtonCallback(
}
}
// For left button click release we check if we are moving our cursor.
if (button == .left and
action == .release and
mods.alt)
click_move: {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
// If we have a selection then we do not do click to move because
// it means that we moved our cursor while pressing the mouse button.
if (self.io.terminal.screens.active.selection != null) break :click_move;
// Moving always resets the click count so that we don't highlight.
self.mouse.left_click_count = 0;
const pin = self.mouse.left_click_pin orelse break :click_move;
try self.clickMoveCursor(pin.*);
return true;
}
// For left button clicks we always record some information for
// selection/highlighting purposes.
if (button == .left and action == .press) click: {
@@ -4214,10 +4178,6 @@ pub fn mouseButtonCallback(
.y = pt_viewport.y,
},
}) orelse {
// Weird... our viewport x/y that we just converted isn't
// found in our pages. This is probably a bug but we don't
// want to crash in releases because its harmless. So, we
// only assert in debug mode.
if (comptime std.debug.runtime_safety) unreachable;
break :sel;
};
@@ -4304,58 +4264,118 @@ pub fn mouseButtonCallback(
return false;
}
/// Performs the "click-to-move" logic to move the cursor to the given
/// screen point if possible. This works by converting the path to the
/// given point into a series of arrow key inputs.
fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
// If click-to-move is disabled then we're done.
if (!self.config.cursor_click_to_move) return;
fn maybePromptClick(self: *Surface) !bool {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
const t: *terminal.Terminal = self.renderer_state.terminal;
const screen: *terminal.Screen = t.screens.active;
const t = &self.io.terminal;
// If our screen doesn't handle any prompt clicks, then we never
// do anything.
if (screen.semantic_prompt.click == .none) return false;
// Click to move cursor only works on the primary screen where prompts
// exist. This means that alt screen multiplexers like tmux will not
// support this feature. It is just too messy.
if (t.screens.active_key != .primary) return;
// If our cursor isn't currently at a prompt then we don't handle
// prompt clicks because we can't move if we're not in a prompt!
if (!t.cursorIsAtPrompt()) return false;
// This flag is only set if we've seen at least one semantic prompt
// OSC sequence. If we've never seen that sequence, we can't possibly
// move the cursor so we can fast path out of here.
if (!t.flags.shell_redraws_prompt) return;
// If we have a selection currently, then releasing the mouse
// completes the selection and we don't do prompt moving. I don't
// love this logic, I think it should be generalized to "if the
// mouse release was on a different cell than the mouse press" but
// our mouse state at the time of writing this doesn't support that.
if (screen.selection != null) return false;
// Get our path
const from = t.screens.active.cursor.page_pin.*;
const path = t.screens.active.promptPath(from, to);
log.debug("click-to-move-cursor from={} to={} path={}", .{ from, to, path });
// If we aren't moving at all, fast path out of here.
if (path.x == 0 and path.y == 0) return;
// Convert our path to arrow key inputs. Yes, that is how this works.
// Yes, that is pretty sad. Yes, this could backfire in various ways.
// But its the best we can do.
// We do Y first because it prevents any weird wrap behavior.
if (path.y != 0) {
const arrow = if (path.y < 0) arrow: {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOA" else "\x1b[A";
} else arrow: {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B";
// Get the pin for our mouse click.
const pos = try self.rt_surface.getCursorPos();
const pos_vp = self.posToViewport(pos.x, pos.y);
const click_pin: terminal.Pin = pin: {
const pin = screen.pages.pin(.{
.viewport = .{
.x = pos_vp.x,
.y = pos_vp.y,
},
}) orelse {
// See mouseButtonCallback for explanation
if (comptime std.debug.runtime_safety) unreachable;
return false;
};
for (0..@abs(path.y)) |_| {
self.queueIo(.{ .write_stable = arrow }, .locked);
}
}
if (path.x != 0) {
const arrow = if (path.x < 0) arrow: {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOD" else "\x1b[D";
} else arrow: {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C";
break :pin pin;
};
// Get our cursor's most current prompt.
const prompt_pin: terminal.Pin = prompt_pin: {
var it = screen.cursor.page_pin.promptIterator(
.left_up,
null,
);
break :prompt_pin it.next() orelse {
// This shouldn't be possible because we asserted we're at
// a prompt above, so we MUST find some prompt in a left_up search.
log.warn("cursor is at prompt but no prompt found", .{});
if (comptime std.debug.runtime_safety) unreachable;
return false;
};
for (0..@abs(path.x)) |_| {
self.queueIo(.{ .write_stable = arrow }, .locked);
}
};
// If our mouse click is before the prompt, we don't move.
// We DO ALLOW clicks AFTER the prompt, specifically with Kitty's
// click_events=1 since we rely on the shell to validate out of
// bounds clicks. This matches Kitty's logic as best I can tell.
if (click_pin.before(prompt_pin)) return false;
// At this point we've established:
// - Screen supports prompt clicks
// - Cursor is at a prompt
// - Click is at or below our prompt
switch (screen.semantic_prompt.click) {
// Guarded at the start of this function
.none => unreachable,
.click_events => {
// For the event, we always send a left-click press event.
// This matches what Kitty sends.
var data: termio.Message.WriteReq.Small.Array = undefined;
const resp = try std.fmt.bufPrint(
&data,
"\x1B[<0;{d};{d}M",
.{ pos_vp.x + 1, pos_vp.y + 1 },
);
// Not that noisy since this only happens on prompt clicks.
log.debug(
"sending click_events=1 event=ESC{s}",
.{resp[1..]},
);
// Ask our IO thread to write the data
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(resp.len),
} }, .locked);
},
.cl => {
const left_arrow = if (t.modes.get(.cursor_keys)) "\x1bOD" else "\x1b[D";
const right_arrow = if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C";
const move = screen.promptClickMove(click_pin);
for (0..move.left) |_| {
self.queueIo(
.{ .write_stable = left_arrow },
.locked,
);
}
for (0..move.right) |_| {
self.queueIo(
.{ .write_stable = right_arrow },
.locked,
);
}
},
}
return true;
}
const Link = struct {

View File

@@ -1061,7 +1061,7 @@ pub const Inspector = struct {
render: {
const surface = &self.surface.core_surface;
const inspector = surface.inspector orelse break :render;
inspector.render();
inspector.render(surface);
}
// Render
@@ -1133,15 +1133,18 @@ pub const Inspector = struct {
yoff: f64,
mods: input.ScrollMods,
) void {
_ = mods;
self.queueRender();
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
// For precision scrolling (trackpads), the values are in pixels which
// scroll way too fast. Scale them down to approximate discrete wheel
// notches. imgui expects 1.0 to scroll ~5 lines of text.
const scale: f64 = if (mods.precision) 0.1 else 1.0;
cimgui.c.ImGuiIO_AddMouseWheelEvent(
io,
@floatCast(xoff),
@floatCast(yoff),
@floatCast(xoff * scale),
@floatCast(yoff * scale),
);
}
@@ -1202,10 +1205,11 @@ pub const Inspector = struct {
// Determine our delta time
const now = try std.time.Instant.now();
io.DeltaTime = if (self.instant) |prev| delta: {
const since_ns = now.since(prev);
const since_s: f32 = @floatFromInt(since_ns / std.time.ns_per_s);
const since_ns: f64 = @floatFromInt(now.since(prev));
const ns_per_s: f64 = @floatFromInt(std.time.ns_per_s);
const since_s: f32 = @floatCast(since_ns / ns_per_s);
break :delta @max(0.00001, since_s);
} else (1 / 60);
} else (1.0 / 60.0);
self.instant = now;
}
};

View File

@@ -213,6 +213,11 @@ pub const Application = extern struct {
/// Providers for loading custom stylesheets defined by user
custom_css_providers: std.ArrayListUnmanaged(*gtk.CssProvider) = .empty,
/// A copy of the LANG environment variable that was provided to Ghostty
/// by the system. If this is null, the LANG environment variable did
/// not exist in Ghostty's environment variable.
saved_language: ?[:0]const u8 = null,
pub var offset: c_int = 0;
};
@@ -249,15 +254,6 @@ pub const Application = extern struct {
gtk_version.logVersion();
adw_version.logVersion();
// Set gettext global domain to be our app so that our unqualified
// translations map to our translations.
internal_os.i18n.initGlobalDomain() catch |err| {
// Failures shuldn't stop application startup. Our app may
// not translate correctly but it should still work. In the
// future we may want to add this to the GUI to show.
log.warn("i18n initialization failed error={}", .{err});
};
// Load our configuration.
var config = CoreConfig.load(alloc) catch |err| err: {
// If we fail to load the configuration, then we should log
@@ -275,6 +271,27 @@ pub const Application = extern struct {
};
defer config.deinit();
const saved_language: ?[:0]const u8 = saved_language: {
const old_language = old_language: {
const result = (internal_os.getenv(alloc, "LANG") catch break :old_language null) orelse break :old_language null;
defer result.deinit(alloc);
break :old_language alloc.dupeZ(u8, result.value) catch break :old_language null;
};
if (config.language) |language| _ = internal_os.setenv("LANG", language);
break :saved_language old_language;
};
// Set gettext global domain to be our app so that our unqualified
// translations map to our translations.
internal_os.i18n.initGlobalDomain() catch |err| {
// Failures shuldn't stop application startup. Our app may
// not translate correctly but it should still work. In the
// future we may want to add this to the GUI to show.
log.warn("i18n initialization failed error={}", .{err});
};
// Setup our GTK init env vars
setGtkEnv(&config) catch |err| switch (err) {
error.NoSpaceLeft => {
@@ -374,7 +391,7 @@ pub const Application = extern struct {
// Setup our private state. More setup is done in the init
// callback that GObject calls, but we can't pass this data through
// to there (and we don't need it there directly) so this is here.
const priv = self.private();
const priv: *Private = self.private();
priv.* = .{
.rt_app = rt_app,
.core_app = core_app,
@@ -383,6 +400,7 @@ pub const Application = extern struct {
.css_provider = css_provider,
.custom_css_providers = .empty,
.global_shortcuts = gobject.ext.newInstance(GlobalShortcuts, .{}),
.saved_language = saved_language,
};
// Signals
@@ -415,11 +433,12 @@ pub const Application = extern struct {
/// ensures that our memory is cleaned up properly.
pub fn deinit(self: *Self) void {
const alloc = self.allocator();
const priv = self.private();
const priv: *Private = self.private();
priv.config.unref();
priv.winproto.deinit(alloc);
priv.global_shortcuts.unref();
if (priv.transient_cgroup_base) |base| alloc.free(base);
if (priv.saved_language) |language| alloc.free(language);
if (gdk.Display.getDefault()) |display| {
gtk.StyleContext.removeProviderForDisplay(
display,
@@ -445,6 +464,12 @@ pub const Application = extern struct {
return self.private().core_app.alloc;
}
/// Get the original language that Ghostty was launched with. This returns a
/// pointer to internal memory so it must be copied by callers.
pub fn savedLanguage(self: *Self) ?[:0]const u8 {
return self.private().saved_language;
}
/// Run the application. This is a replacement for `gio.Application.run`
/// because we want more tight control over our event loop so we can
/// integrate it with libghostty.
@@ -733,6 +758,7 @@ pub const Application = extern struct {
.toggle_split_zoom => return Action.toggleSplitZoom(target),
.show_on_screen_keyboard => return Action.showOnScreenKeyboard(target),
.command_finished => return Action.commandFinished(target, value),
.readonly => return Action.setReadonly(target, value),
.start_search => Action.startSearch(target, value),
.end_search => Action.endSearch(target),
@@ -753,7 +779,6 @@ pub const Application = extern struct {
.check_for_updates,
.undo,
.redo,
.readonly,
=> {
log.warn("unimplemented action={}", .{action});
return false;
@@ -2400,10 +2425,14 @@ const Action = struct {
SplitTree,
surface.as(gtk.Widget),
) orelse {
log.warn("surface is not in a split tree, ignoring goto_split", .{});
log.warn("surface is not in a split tree, ignoring resize_split", .{});
return false;
};
// If the tree has no splits (only one leaf), this action is not performable.
// This allows the key event to pass through to the terminal.
if (!tree.getIsSplit()) return false;
return tree.resize(
switch (value.direction) {
.up => .up,
@@ -2550,6 +2579,18 @@ const Action = struct {
.surface => |core| {
// TODO: pass surface ID when we have that
const surface = core.rt_surface.surface;
const tree = ext.getAncestor(
SplitTree,
surface.as(gtk.Widget),
) orelse {
log.warn("surface is not in a split tree, ignoring toggle_split_zoom", .{});
return false;
};
// If the tree has no splits (only one leaf), this action is not performable.
// This allows the key event to pass through to the terminal.
if (!tree.getIsSplit()) return false;
return surface.as(gtk.Widget).activateAction("split-tree.zoom", null) != 0;
},
}
@@ -2662,6 +2703,15 @@ const Action = struct {
}
}
pub fn setReadonly(target: apprt.Target, value: apprt.Action.Value(.readonly)) bool {
switch (target) {
.app => return false,
.surface => |surface| {
return surface.rt_surface.gobj().setReadonly(value);
},
}
}
pub fn keySequence(target: apprt.Target, value: apprt.Action.Value(.key_sequence)) bool {
switch (target) {
.app => {

View File

@@ -63,6 +63,12 @@ pub const ImguiWidget = extern struct {
/// Our previous instant used to calculate delta time for animations.
instant: ?std.time.Instant = null,
/// Tick callback ID for timed updates.
tick_callback_id: c_uint = 0,
/// Last render time for throttling to 30 FPS.
last_render_time: ?std.time.Instant = null,
pub var offset: c_int = 0;
};
@@ -131,21 +137,17 @@ pub const ImguiWidget = extern struct {
/// Initialize the frame. Expects that the context is already current.
fn newFrame(self: *Self) void {
// If we can't determine the time since the last frame we default to
// 1/60th of a second.
const default_delta_time = 1 / 60;
const priv = self.private();
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
// Determine our delta time
const now = std.time.Instant.now() catch unreachable;
io.DeltaTime = if (priv.instant) |prev| delta: {
const since_ns = now.since(prev);
const since_s: f32 = @floatFromInt(since_ns / std.time.ns_per_s);
const since_ns: f64 = @floatFromInt(now.since(prev));
const ns_per_s: f64 = @floatFromInt(std.time.ns_per_s);
const since_s: f32 = @floatCast(since_ns / ns_per_s);
break :delta @max(0.00001, since_s);
} else default_delta_time;
} else (1.0 / 60.0);
priv.instant = now;
}
@@ -235,11 +237,26 @@ pub const ImguiWidget = extern struct {
// Call the virtual method to setup the UI.
self.setup();
// Add a tick callback to drive timed updates via the frame clock.
priv.tick_callback_id = self.as(gtk.Widget).addTickCallback(
tickCallback,
null,
null,
);
}
/// Handle a request to unrealize the GLArea
fn glAreaUnrealize(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void {
assert(self.private().ig_context != null);
const priv = self.private();
assert(priv.ig_context != null);
// Remove the tick callback if it was registered.
if (priv.tick_callback_id != 0) {
self.as(gtk.Widget).removeTickCallback(priv.tick_callback_id);
priv.tick_callback_id = 0;
}
self.setCurrentContext() catch return;
cimgui.ImGui_ImplOpenGL3_Shutdown();
}
@@ -269,6 +286,10 @@ pub const ImguiWidget = extern struct {
fn glAreaRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Self) callconv(.c) c_int {
self.setCurrentContext() catch return @intFromBool(false);
// Update last render time for tick callback throttling.
const priv = self.private();
priv.last_render_time = std.time.Instant.now() catch null;
// Setup our frame. We render twice because some ImGui behaviors
// take multiple renders to process. I don't know how to make this
// more efficient.
@@ -415,6 +436,34 @@ pub const ImguiWidget = extern struct {
cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes);
}
/// Tick callback for timed updates. This drives periodic redraws.
/// Redraws are limited to 30 FPS max since our imgui widgets don't
/// usually need higher frame rates than that.
fn tickCallback(
widget: *gtk.Widget,
_: *gdk.FrameClock,
_: ?*anyopaque,
) callconv(.c) c_int {
const self: *Self = gobject.ext.cast(Self, widget) orelse return 0;
const priv = self.private();
const now = std.time.Instant.now() catch {
self.queueRender();
return 1;
};
// Throttle to 30 FPS (~33ms between frames)
const frame_time_ns: u64 = std.time.ns_per_s / 30;
const should_render = if (priv.last_render_time) |last|
now.since(last) >= frame_time_ns
else
true;
if (should_render) self.queueRender();
return 1; // Continue the tick callback
}
//---------------------------------------------------------------
// Default virtual method handlers

View File

@@ -89,7 +89,7 @@ pub const InspectorWidget = extern struct {
const surface = priv.surface orelse return;
const core_surface = surface.core() orelse return;
const inspector = core_surface.inspector orelse return;
inspector.render();
inspector.render(core_surface);
}
//---------------------------------------------------------------

View File

@@ -561,7 +561,7 @@ pub const SplitTree = extern struct {
));
}
fn getIsSplit(self: *Self) bool {
pub fn getIsSplit(self: *Self) bool {
const tree: *const Surface.Tree = self.private().tree orelse &.empty;
if (tree.isEmpty()) return false;

View File

@@ -400,6 +400,25 @@ pub const Surface = extern struct {
},
);
};
pub const readonly = struct {
pub const name = "readonly";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.typedAccessor(
Self,
bool,
.{
.getter = getReadonly,
},
),
},
);
};
};
pub const signals = struct {
@@ -678,6 +697,13 @@ pub const Surface = extern struct {
/// Whether primary paste (middle-click paste) is enabled.
gtk_enable_primary_paste: bool = true,
/// How much pending horizontal scroll do we have?
pending_horizontal_scroll: f64 = 0.0,
/// Timer to reset the amount of horizontal scroll if the user
/// stops scrolling.
pending_horizontal_scroll_reset: ?c_uint = null,
pub var offset: c_int = 0;
};
@@ -1106,6 +1132,20 @@ pub const Surface = extern struct {
return true;
}
/// Get the readonly state from the core surface.
pub fn getReadonly(self: *Self) bool {
const priv: *Private = self.private();
const surface = priv.core_surface orelse return false;
return surface.readonly;
}
/// Notify anyone interested that the readonly status has changed.
pub fn setReadonly(self: *Self, _: apprt.Action.Value(.readonly)) bool {
self.as(gobject.Object).notifyByPspec(properties.readonly.impl.param_spec);
return true;
}
/// Key press event (press or release).
///
/// At a high level, we want to construct an `input.KeyEvent` and
@@ -1555,10 +1595,17 @@ pub const Surface = extern struct {
}
pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap {
const alloc = Application.default().allocator();
const app = Application.default();
const alloc = app.allocator();
var env = try internal_os.getEnvMap(alloc);
errdefer env.deinit();
if (app.savedLanguage()) |language| {
try env.put("LANG", language);
} else {
env.remove("LANG");
}
// Don't leak these GTK environment variables to child processes.
env.remove("GDK_DEBUG");
env.remove("GDK_DISABLE");
@@ -1847,6 +1894,13 @@ pub const Surface = extern struct {
priv.idle_rechild = null;
}
if (priv.pending_horizontal_scroll_reset) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove pending horizontal scroll reset source", .{});
}
priv.pending_horizontal_scroll_reset = null;
}
// This works around a GTK double-free bug where if you bind
// to a top-level template child, it frees twice if the widget is
// also the root child of the template. By unsetting the child here,
@@ -2846,27 +2900,27 @@ pub const Surface = extern struct {
}
}
fn ecMouseScrollPrecisionBegin(
fn ecMouseScrollVerticalPrecisionBegin(
_: *gtk.EventControllerScroll,
self: *Self,
) callconv(.c) void {
self.private().precision_scroll = true;
}
fn ecMouseScrollPrecisionEnd(
fn ecMouseScrollVerticalPrecisionEnd(
_: *gtk.EventControllerScroll,
self: *Self,
) callconv(.c) void {
self.private().precision_scroll = false;
}
fn ecMouseScroll(
fn ecMouseScrollVertical(
_: *gtk.EventControllerScroll,
x: f64,
y: f64,
self: *Self,
) callconv(.c) c_int {
const priv = self.private();
const priv: *Private = self.private();
const surface = priv.core_surface orelse return 0;
// Multiply precision scrolls by 10 to get a better response from
@@ -2893,6 +2947,57 @@ pub const Surface = extern struct {
return 1;
}
fn ecMouseScrollHorizontal(
ec: *gtk.EventControllerScroll,
x: f64,
_: f64,
self: *Self,
) callconv(.c) c_int {
const priv: *Private = self.private();
switch (ec.getUnit()) {
.surface => {},
.wheel => return @intFromBool(false),
else => return @intFromBool(false),
}
priv.pending_horizontal_scroll += x;
if (@abs(priv.pending_horizontal_scroll) < 120) {
if (priv.pending_horizontal_scroll_reset) |v| {
_ = glib.Source.remove(v);
priv.pending_horizontal_scroll_reset = null;
}
priv.pending_horizontal_scroll_reset = glib.timeoutAdd(500, ecMouseScrollHorizontalReset, self);
return @intFromBool(true);
}
_ = self.as(gtk.Widget).activateAction(
if (priv.pending_horizontal_scroll < 0.0)
"tab.next-page"
else
"tab.previous-page",
null,
);
if (priv.pending_horizontal_scroll_reset) |v| {
_ = glib.Source.remove(v);
priv.pending_horizontal_scroll_reset = null;
}
priv.pending_horizontal_scroll = 0.0;
return @intFromBool(true);
}
fn ecMouseScrollHorizontalReset(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse return @intFromBool(glib.SOURCE_REMOVE)));
const priv: *Private = self.private();
priv.pending_horizontal_scroll = 0.0;
priv.pending_horizontal_scroll_reset = null;
return @intFromBool(glib.SOURCE_REMOVE);
}
fn imPreeditStart(
_: *gtk.IMMulticontext,
self: *Self,
@@ -3431,9 +3536,10 @@ pub const Surface = extern struct {
class.bindTemplateCallback("mouse_up", &gcMouseUp);
class.bindTemplateCallback("mouse_motion", &ecMouseMotion);
class.bindTemplateCallback("mouse_leave", &ecMouseLeave);
class.bindTemplateCallback("scroll", &ecMouseScroll);
class.bindTemplateCallback("scroll_begin", &ecMouseScrollPrecisionBegin);
class.bindTemplateCallback("scroll_end", &ecMouseScrollPrecisionEnd);
class.bindTemplateCallback("scroll_vertical", &ecMouseScrollVertical);
class.bindTemplateCallback("scroll_vertical_begin", &ecMouseScrollVerticalPrecisionBegin);
class.bindTemplateCallback("scroll_vertical_end", &ecMouseScrollVerticalPrecisionEnd);
class.bindTemplateCallback("scroll_horizontal", &ecMouseScrollHorizontal);
class.bindTemplateCallback("drop", &dtDrop);
class.bindTemplateCallback("gl_realize", &glareaRealize);
class.bindTemplateCallback("gl_unrealize", &glareaUnrealize);
@@ -3480,6 +3586,7 @@ pub const Surface = extern struct {
properties.@"title-override".impl,
properties.zoom.impl,
properties.@"is-split".impl,
properties.readonly.impl,
// For Gtk.Scrollable
properties.hadjustment.impl,

View File

@@ -202,6 +202,8 @@ pub const Tab = extern struct {
const actions = [_]ext.actions.Action(Self){
.init("close", actionClose, s_param_type),
.init("ring-bell", actionRingBell, null),
.init("next-page", actionNextPage, null),
.init("previous-page", actionPreviousPage, null),
};
_ = ext.actions.addAsGroup(Self, self, "tab", &actions);
@@ -235,12 +237,17 @@ pub const Tab = extern struct {
return tree.getNeedsConfirmQuit();
}
/// Get the tab page holding this tab, if any.
fn getTabPage(self: *Self) ?*adw.TabPage {
const tab_view = ext.getAncestor(
/// Get the tab view holding this tab, if any.
fn getTabView(self: *Self) ?*adw.TabView {
return ext.getAncestor(
adw.TabView,
self.as(gtk.Widget),
) orelse return null;
);
}
/// Get the tab page holding this tab, if any.
fn getTabPage(self: *Self) ?*adw.TabPage {
const tab_view = self.getTabView() orelse return null;
return tab_view.getPage(self.as(gtk.Widget));
}
@@ -325,11 +332,7 @@ pub const Tab = extern struct {
var str: ?[*:0]const u8 = null;
param.get("&s", &str);
const tab_view = ext.getAncestor(
adw.TabView,
self.as(gtk.Widget),
) orelse return;
const tab_view = self.getTabView() orelse return;
const page = tab_view.getPage(self.as(gtk.Widget));
const mode = std.meta.stringToEnum(
@@ -372,6 +375,26 @@ pub const Tab = extern struct {
page.setNeedsAttention(@intFromBool(true));
}
/// Select the next tab page.
fn actionNextPage(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Self,
) callconv(.c) void {
const tab_view = self.getTabView() orelse return;
_ = tab_view.selectNextPage();
}
/// Select the previous tab page.
fn actionPreviousPage(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Self,
) callconv(.c) void {
const tab_view = self.getTabView() orelse return;
_ = tab_view.selectPreviousPage();
}
fn closureComputedTitle(
_: *Self,
config_: ?*Config,

View File

@@ -134,6 +134,16 @@ label.resize-overlay {
border-style: solid;
}
.surface .readonly_overlay {
/* Should be the equivalent of the following SwiftUI color: */
/* Color(hue: 0.08, saturation: 0.5, brightness: 0.8) */
color: hsl(25 50 75);
padding: 8px 8px 8px 8px;
margin: 8px 8px 8px 8px;
border-radius: 6px 6px 6px 6px;
outline-style: solid;
outline-width: 1px;
}
/*
* Command Palette
*/

View File

@@ -53,10 +53,15 @@ Overlay terminal_page {
}
EventControllerScroll {
scroll => $scroll();
scroll-begin => $scroll_begin();
scroll-end => $scroll_end();
flags: both_axes;
scroll => $scroll_vertical();
scroll-begin => $scroll_vertical_begin();
scroll-end => $scroll_vertical_end();
flags: vertical;
}
EventControllerScroll {
scroll => $scroll_horizontal();
flags: horizontal;
}
EventControllerMotion {
@@ -71,6 +76,39 @@ Overlay terminal_page {
}
};
[overlay]
Revealer {
reveal-child: bind template.readonly;
transition-type: crossfade;
transition-duration: 500;
// Revealers take up the full size, we need this to not capture events.
can-focus: false;
can-target: false;
focusable: false;
Box readonly_overlay {
styles [
"readonly_overlay",
]
// TODO: the tooltip doesn't actually work, but keep it here for now so
// that we can get the tooltip text translated.
has-tooltip: true;
tooltip-text: _("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.");
halign: end;
valign: start;
spacing: 6;
Image {
icon-name: "changes-prevent-symbolic";
}
Label {
label: _("Read-only");
}
}
}
[overlay]
ProgressBar progress_bar_overlay {
styles [

View File

@@ -19,14 +19,22 @@ branch: []const u8,
pub fn detect(b: *std.Build) !Version {
// Execute a bunch of git commands to determine the automatic version.
var code: u8 = 0;
const branch: []const u8 = b.runAllowFail(
&[_][]const u8{ "git", "-C", b.build_root.path orelse ".", "rev-parse", "--abbrev-ref", "HEAD" },
&code,
.Ignore,
) catch |err| switch (err) {
error.FileNotFound => return error.GitNotFound,
error.ExitCodeFailure => return error.GitNotRepository,
else => return err,
const branch: []const u8 = b: {
const tmp: []u8 = b.runAllowFail(
&[_][]const u8{ "git", "-C", b.build_root.path orelse ".", "rev-parse", "--abbrev-ref", "HEAD" },
&code,
.Ignore,
) catch |err| switch (err) {
error.FileNotFound => return error.GitNotFound,
error.ExitCodeFailure => return error.GitNotRepository,
else => return err,
};
// Replace any '/' with '-' as including slashes will mess up building
// the dist tarball - the tarball uses the branch as part of the
// name and including slashes means that the tarball will end up in
// subdirectories instead of where it's supposed to be.
std.mem.replaceScalar(u8, tmp, '/', '-');
break :b tmp;
};
const short_hash = short_hash: {

View File

@@ -94,6 +94,27 @@ pub const compatibility = std.StaticStringMap(
.{ "macos-dock-drop-behavior", compatMacOSDockDropBehavior },
});
/// Set Ghostty's graphical user interface language to a language other than the
/// system default language. The language must be fully specified, including the
/// encoding. For example:
///
/// language = de_DE.UTF-8
///
/// will force the strings in Ghostty's graphical user interface to be in German
/// rather than the system default.
///
/// This will not affect the language used by programs run _within_ Ghostty.
/// Those will continue to use the default system language. There are also many
/// non-GUI elements in Ghostty that are not translated - this setting will have
/// no effect on those.
///
/// Warning: This setting cannot be reloaded at runtime. To change the language
/// you must fully restart Ghostty.
///
/// GTK only.
/// Available since 1.3.0.
language: ?[:0]const u8 = null,
/// The font families to use.
///
/// You can generate the list of valid values using the CLI:

View File

@@ -26,7 +26,7 @@ pub const regex =
"(?:" ++ url_schemes ++
\\)(?:
++ ipv6_url_pattern ++
\\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])|(?:\.\.\/|\.\/|\/)(?:(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?|(?![\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]+)*(?: +(?= *$))?)
\\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])|(?:\.\.\/|\.\/|(?<!\w)\/)(?:(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?|(?![\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]+)*(?: +(?= *$))?)|[\w][\w\-.]*\/(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?
;
const url_schemes =
\\https?://|mailto:|ftp://|file:|ssh:|git://|ssh://|tel:|magnet:|ipfs://|ipns://|gemini://|gopher://|news:
@@ -270,6 +270,27 @@ test "url regex" {
.input = "/tmp/test folder/file.txt",
.expect = "/tmp/test folder/file.txt",
},
// Bare relative file paths (no ./ or ../ prefix)
.{
.input = "src/config/url.zig",
.expect = "src/config/url.zig",
},
.{
.input = "app/folder/file.rb:1",
.expect = "app/folder/file.rb:1",
},
.{
.input = "modified: src/config/url.zig",
.expect = "src/config/url.zig",
},
.{
.input = "lib/ghostty/terminal.zig:42:10",
.expect = "lib/ghostty/terminal.zig:42:10",
},
.{
.input = "some-pkg/src/file.txt more text",
.expect = "some-pkg/src/file.txt",
},
};
for (cases) |case| {
@@ -284,4 +305,17 @@ test "url regex" {
const match = case.input[@intCast(reg.starts()[0])..@intCast(reg.ends()[0])];
try testing.expectEqualStrings(case.expect, match);
}
// Bare relative paths without any dot should not match as file paths
const no_match_cases = [_][]const u8{
"input/output",
"foo/bar",
};
for (no_match_cases) |input| {
var result = re.search(input, .{});
if (result) |*reg| {
reg.deinit();
return error.TestUnexpectedResult;
} else |_| {}
}
}

View File

@@ -170,6 +170,19 @@ pub fn SplitTree(comptime V: type) type {
return self.nodes.len == 0;
}
/// Returns true if this tree has more than one split (i.e., the root
/// is a split node). This is useful for determining if actions like
/// resize_split or toggle_split_zoom are performable.
pub fn isSplit(self: *const Self) bool {
// An empty tree is not split.
if (self.isEmpty()) return false;
// The root node is at index 0. If it's a split, we have multiple splits.
return switch (self.nodes[0]) {
.split => true,
.leaf => false,
};
}
/// An iterator over all the views in the tree.
pub fn iterator(
self: *const Self,
@@ -760,9 +773,9 @@ pub fn SplitTree(comptime V: type) type {
/// Resize the nearest split matching the layout by the given ratio.
/// Positive is right and down.
///
/// The ratio is a value between 0 and 1 representing the percentage
/// to move the divider in the given direction. The percentage is
/// of the entire grid size, not just the specific split size.
/// The ratio is a signed delta representing the percentage to move
/// the divider. The percentage is of the entire grid size, not just
/// the specific split size.
/// We use the entire grid size because that's what Ghostty's
/// `resize_split` keybind does, because it maps to a general human
/// understanding of moving a split relative to the entire window
@@ -781,7 +794,7 @@ pub fn SplitTree(comptime V: type) type {
layout: Split.Layout,
ratio: f16,
) Allocator.Error!Self {
assert(ratio >= 0 and ratio <= 1);
assert(ratio >= -1 and ratio <= 1);
assert(!std.math.isNan(ratio));
assert(!std.math.isInf(ratio));
@@ -1326,6 +1339,36 @@ const TestView = struct {
}
};
test "SplitTree: isSplit" {
const testing = std.testing;
const alloc = testing.allocator;
// Empty tree should not be split
var empty: TestTree = .empty;
defer empty.deinit();
try testing.expect(!empty.isSplit());
// Single node tree should not be split
var v1: TestView = .{ .label = "A" };
var single: TestTree = try TestTree.init(alloc, &v1);
defer single.deinit();
try testing.expect(!single.isSplit());
// Split tree should be split
var v2: TestView = .{ .label = "B" };
var tree2: TestTree = try TestTree.init(alloc, &v2);
defer tree2.deinit();
var split = try single.split(
alloc,
.root,
.right,
0.5,
&tree2,
);
defer split.deinit();
try testing.expect(split.isSplit());
}
test "SplitTree: empty tree" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -2007,6 +2050,32 @@ test "SplitTree: resize" {
\\
);
}
// Resize the other direction (negative ratio)
{
var resized = try split.resize(
alloc,
at: {
var it = split.iterator();
break :at while (it.next()) |entry| {
if (std.mem.eql(u8, entry.view.label, "B")) {
break entry.handle;
}
} else return error.NotFound;
},
.horizontal, // resize left
-0.25,
);
defer resized.deinit();
const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(resized, .formatDiagram)});
defer alloc.free(str);
try testing.expectEqualStrings(str,
\\+---++-------------+
\\| A || B |
\\+---++-------------+
\\
);
}
}
test "SplitTree: clone empty tree" {

View File

@@ -20,6 +20,7 @@ const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const testing = std.testing;
const fastmem = @import("../fastmem.zig");
const tripwire = @import("../tripwire.zig");
const log = std.log.scoped(.atlas);
@@ -91,7 +92,15 @@ pub const Region = extern struct {
/// TODO: figure out optimal prealloc based on real world usage
const node_prealloc: usize = 64;
pub const init_tw = tripwire.module(enum {
alloc_data,
alloc_nodes,
}, init);
pub fn init(alloc: Allocator, size: u32, format: Format) Allocator.Error!Atlas {
const tw = init_tw;
try tw.check(.alloc_data);
var result = Atlas{
.data = try alloc.alloc(u8, size * size * format.depth()),
.size = size,
@@ -101,6 +110,7 @@ pub fn init(alloc: Allocator, size: u32, format: Format) Allocator.Error!Atlas {
errdefer result.deinit(alloc);
// Prealloc some nodes.
try tw.check(.alloc_nodes);
result.nodes = try .initCapacity(alloc, node_prealloc);
// This sets up our initial state
@@ -115,6 +125,10 @@ pub fn deinit(self: *Atlas, alloc: Allocator) void {
self.* = undefined;
}
pub const reserve_tw = tripwire.module(enum {
insert_node,
}, reserve);
/// Reserve a region within the atlas with the given width and height.
///
/// May allocate to add a new rectangle into the internal list of rectangles.
@@ -125,6 +139,8 @@ pub fn reserve(
width: u32,
height: u32,
) (Allocator.Error || Error)!Region {
const tw = reserve_tw;
// x, y are populated within :best_idx below
var region: Region = .{ .x = 0, .y = 0, .width = width, .height = height };
@@ -162,11 +178,13 @@ pub fn reserve(
};
// Insert our new node for this rectangle at the exact best index
try tw.check(.insert_node);
try self.nodes.insert(alloc, best_idx, .{
.x = region.x,
.y = region.y + height,
.width = width,
});
errdefer comptime unreachable;
// Optimize our rectangles
var i: usize = best_idx + 1;
@@ -287,15 +305,24 @@ pub fn setFromLarger(
_ = self.modified.fetchAdd(1, .monotonic);
}
pub const grow_tw = tripwire.module(enum {
ensure_node_capacity,
alloc_data,
}, grow);
// Grow the texture to the new size, preserving all previously written data.
pub fn grow(self: *Atlas, alloc: Allocator, size_new: u32) Allocator.Error!void {
const tw = grow_tw;
assert(size_new >= self.size);
if (size_new == self.size) return;
// We reserve space ahead of time for the new node, so that we
// won't have to handle any errors after allocating our new data.
try tw.check(.ensure_node_capacity);
try self.nodes.ensureUnusedCapacity(alloc, 1);
try tw.check(.alloc_data);
const data_new = try alloc.alloc(
u8,
size_new * size_new * self.format.depth(),
@@ -355,7 +382,7 @@ pub fn clear(self: *Atlas) void {
/// swapped because PPM expects RGB. This would be
/// easy enough to fix so next time someone needs
/// to debug a color atlas they should fix it.
pub fn dump(self: Atlas, writer: *std.Io.Writer) !void {
pub fn dump(self: Atlas, writer: *std.Io.Writer) std.Io.Writer.Error!void {
try writer.print(
\\P{c}
\\{d} {d}
@@ -795,3 +822,68 @@ test "grow OOM" {
try testing.expectEqual(@as(u8, 3), atlas.data[9]);
try testing.expectEqual(@as(u8, 4), atlas.data[10]);
}
test "init error" {
// Test every failure point in `init` and ensure that we don't
// leak memory (testing.allocator verifies) since we're exiting early.
for (std.meta.tags(init_tw.FailPoint)) |tag| {
const tw = init_tw;
defer tw.end(.reset) catch unreachable;
tw.errorAlways(tag, error.OutOfMemory);
try testing.expectError(
error.OutOfMemory,
init(testing.allocator, 32, .grayscale),
);
}
}
test "reserve error" {
// Test every failure point in `reserve` and ensure that we don't
// leak memory (testing.allocator verifies) since we're exiting early.
for (std.meta.tags(reserve_tw.FailPoint)) |tag| {
const tw = reserve_tw;
defer tw.end(.reset) catch unreachable;
var atlas = try init(testing.allocator, 32, .grayscale);
defer atlas.deinit(testing.allocator);
tw.errorAlways(tag, error.OutOfMemory);
try testing.expectError(
error.OutOfMemory,
atlas.reserve(testing.allocator, 2, 2),
);
}
}
test "grow error" {
// Test every failure point in `grow` and ensure that we don't
// leak memory (testing.allocator verifies) since we're exiting early.
for (std.meta.tags(grow_tw.FailPoint)) |tag| {
const tw = grow_tw;
defer tw.end(.reset) catch unreachable;
var atlas = try init(testing.allocator, 4, .grayscale);
defer atlas.deinit(testing.allocator);
// Write some data to verify it's preserved after failed grow
const reg = try atlas.reserve(testing.allocator, 2, 2);
atlas.set(reg, &[_]u8{ 1, 2, 3, 4 });
const old_modified = atlas.modified.load(.monotonic);
const old_resized = atlas.resized.load(.monotonic);
tw.errorAlways(tag, error.OutOfMemory);
try testing.expectError(
error.OutOfMemory,
atlas.grow(testing.allocator, atlas.size + 1),
);
// Verify atlas state is unchanged after failed grow
try testing.expectEqual(old_modified, atlas.modified.load(.monotonic));
try testing.expectEqual(old_resized, atlas.resized.load(.monotonic));
try testing.expectEqual(@as(u8, 1), atlas.data[5]);
try testing.expectEqual(@as(u8, 2), atlas.data[6]);
try testing.expectEqual(@as(u8, 3), atlas.data[9]);
try testing.expectEqual(@as(u8, 4), atlas.data[10]);
}
}

View File

@@ -20,6 +20,7 @@ const SharedGrid = @This();
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const tripwire = @import("../tripwire.zig");
const Allocator = std.mem.Allocator;
const renderer = @import("../renderer.zig");
const font = @import("main.zig");
@@ -61,6 +62,12 @@ metrics: Metrics,
/// to review call sites to ensure they are using the lock correctly.
lock: std.Thread.RwLock,
pub const init_tw = tripwire.module(enum {
codepoints_capacity,
glyphs_capacity,
reload_metrics,
}, init);
/// Initialize the grid.
///
/// The resolver must have a collection that supports deferred loading
@@ -74,6 +81,8 @@ pub fn init(
alloc: Allocator,
resolver: CodepointResolver,
) !SharedGrid {
const tw = init_tw;
// We need to support loading options since we use the size data
assert(resolver.collection.load_options != null);
@@ -92,10 +101,15 @@ pub fn init(
// We set an initial capacity that can fit a good number of characters.
// This number was picked empirically based on my own terminal usage.
try tw.check(.codepoints_capacity);
try result.codepoints.ensureTotalCapacity(alloc, 128);
errdefer result.codepoints.deinit(alloc);
try tw.check(.glyphs_capacity);
try result.glyphs.ensureTotalCapacity(alloc, 128);
errdefer result.glyphs.deinit(alloc);
// Initialize our metrics.
try tw.check(.reload_metrics);
try result.reloadMetrics();
return result;
@@ -232,6 +246,10 @@ pub fn renderCodepoint(
return try self.renderGlyph(alloc, index, glyph_index, opts);
}
pub const renderGlyph_tw = tripwire.module(enum {
get_presentation,
}, renderGlyph);
/// Render a glyph index. This automatically determines the correct texture
/// atlas to use and caches the result.
pub fn renderGlyph(
@@ -241,6 +259,8 @@ pub fn renderGlyph(
glyph_index: u32,
opts: RenderOptions,
) !Render {
const tw = renderGlyph_tw;
const key: GlyphKey = .{ .index = index, .glyph = glyph_index, .opts = opts };
// Fast path: the cache has the value. This is almost always true and
@@ -257,8 +277,10 @@ pub fn renderGlyph(
const gop = try self.glyphs.getOrPut(alloc, key);
if (gop.found_existing) return gop.value_ptr.*;
errdefer self.glyphs.removeByPtr(gop.key_ptr);
// Get the presentation to determine what atlas to use
try tw.check(.get_presentation);
const p = try self.resolver.getPresentation(index, glyph_index);
const atlas: *font.Atlas = switch (p) {
.text => &self.atlas_grayscale,
@@ -426,3 +448,93 @@ test getIndex {
try testing.expectEqual(@as(Collection.Index.IndexInt, 0), idx.idx);
}
}
test "renderGlyph error after cache insert rolls back cache entry" {
// This test verifies that when renderGlyph fails after inserting a cache
// entry (via getOrPut), the errdefer properly removes the entry, preventing
// corrupted/uninitialized data from remaining in the cache.
const testing = std.testing;
const alloc = testing.allocator;
var lib = try Library.init(alloc);
defer lib.deinit();
var grid = try testGrid(.normal, alloc, lib);
defer grid.deinit(alloc);
// Get the font index for 'A'
const idx = (try grid.getIndex(alloc, 'A', .regular, null)).?;
// Get the glyph index for 'A'
const glyph_index = glyph_index: {
grid.lock.lockShared();
defer grid.lock.unlockShared();
const face = try grid.resolver.collection.getFace(idx);
break :glyph_index face.glyphIndex('A').?;
};
const render_opts: RenderOptions = .{ .grid_metrics = grid.metrics };
const key: GlyphKey = .{ .index = idx, .glyph = glyph_index, .opts = render_opts };
// Verify the cache is empty for this glyph
try testing.expect(grid.glyphs.get(key) == null);
// Set up tripwire to fail after cache insert.
// We use OutOfMemory as it's a valid error in the renderGlyph error set.
const tw = renderGlyph_tw;
defer tw.end(.reset) catch {};
tw.errorAlways(.get_presentation, error.OutOfMemory);
// This should fail due to the tripwire
try testing.expectError(
error.OutOfMemory,
grid.renderGlyph(alloc, idx, glyph_index, render_opts),
);
// The errdefer should have removed the cache entry, leaving the cache clean.
// Without the errdefer fix, this would contain garbage/uninitialized data.
try testing.expect(grid.glyphs.get(key) == null);
}
test "init error" {
// Test every failure point in `init` and ensure that we don't
// leak memory (testing.allocator verifies) since we're exiting early.
//
// BUG: Currently this test will fail because init() is missing errdefer
// cleanup for codepoints and glyphs when late operations fail
// (ensureTotalCapacity, reloadMetrics).
const testing = std.testing;
const alloc = testing.allocator;
for (std.meta.tags(init_tw.FailPoint)) |tag| {
const tw = init_tw;
defer tw.end(.reset) catch unreachable;
tw.errorAlways(tag, error.OutOfMemory);
// Create a resolver for testing - we need to set up a minimal one.
// The caller is responsible for cleaning up the resolver if init fails.
var lib = try Library.init(alloc);
defer lib.deinit();
var c = Collection.init();
c.load_options = .{ .library = lib };
_ = try c.add(alloc, try .init(
lib,
font.embedded.regular,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
), .{
.style = .regular,
.fallback = false,
.size_adjustment = .none,
});
var resolver: CodepointResolver = .{ .collection = c };
defer resolver.deinit(alloc); // Caller cleans up on init failure
try testing.expectError(
error.OutOfMemory,
init(alloc, resolver),
);
}
}

View File

@@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator;
const harfbuzz = @import("harfbuzz");
const font = @import("../main.zig");
const terminal = @import("../../terminal/main.zig");
const unicode = @import("../../unicode/main.zig");
const Feature = font.shape.Feature;
const FeatureList = font.shape.FeatureList;
const default_features = font.shape.default_features;
@@ -19,7 +20,7 @@ const log = std.log.scoped(.font_shaper);
/// Shaper that uses Harfbuzz.
pub const Shaper = struct {
/// The allocated used for the feature list and cell buf.
/// The allocated used for the feature list, cell buf, and codepoints.
alloc: Allocator,
/// The buffer used for text shaping. We reuse it across multiple shaping
@@ -32,8 +33,29 @@ pub const Shaper = struct {
/// The features to use for shaping.
hb_feats: []harfbuzz.Feature,
/// The codepoints added to the buffer before shaping. We need to keep
/// these separately because after shaping, HarfBuzz replaces codepoints
/// with glyph indices in the buffer.
codepoints: std.ArrayListUnmanaged(Codepoint) = .{},
const Codepoint = struct {
cluster: u32,
codepoint: u32,
};
const CellBuf = std.ArrayListUnmanaged(font.shape.Cell);
const RunOffset = struct {
cluster: u32 = 0,
x: i32 = 0,
y: i32 = 0,
};
const CellOffset = struct {
cluster: u32 = 0,
x: i32 = 0,
};
/// The cell_buf argument is the buffer to use for storing shaped results.
/// This should be at least the number of columns in the terminal.
pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper {
@@ -74,6 +96,7 @@ pub const Shaper = struct {
self.hb_buf.destroy();
self.cell_buf.deinit(self.alloc);
self.alloc.free(self.hb_feats);
self.codepoints.deinit(self.alloc);
}
pub fn endFrame(self: *const Shaper) void {
@@ -135,33 +158,97 @@ pub const Shaper = struct {
// If it isn't true, I'd like to catch it and learn more.
assert(info.len == pos.len);
// This keeps track of the current offsets within a single cell.
var cell_offset: struct {
cluster: u32 = 0,
x: i32 = 0,
y: i32 = 0,
} = .{};
// This keeps track of the current x and y offsets (sum of advances)
// and the furthest cluster we've seen so far (max).
var run_offset: RunOffset = .{};
// This keeps track of the cell starting x and cluster.
var cell_offset: CellOffset = .{};
// Convert all our info/pos to cells and set it.
self.cell_buf.clearRetainingCapacity();
for (info, pos) |info_v, pos_v| {
// If our cluster changed then we've moved to a new cell.
if (info_v.cluster != cell_offset.cluster) cell_offset = .{
.cluster = info_v.cluster,
};
// info_v.cluster is the index into our codepoints array. We use it
// to get the original cluster.
const index = info_v.cluster;
// Our cluster is also our cell X position. If the cluster changes
// then we need to reset our current cell offsets.
const cluster = self.codepoints.items[index].cluster;
if (cell_offset.cluster != cluster) {
const is_after_glyph_from_current_or_next_clusters =
cluster <= run_offset.cluster;
try self.cell_buf.append(self.alloc, .{
.x = @intCast(info_v.cluster),
.x_offset = @intCast(cell_offset.x),
.y_offset = @intCast(cell_offset.y),
.glyph_index = info_v.codepoint,
});
const is_first_codepoint_in_cluster = blk: {
var i = index;
while (i > 0) {
i -= 1;
const codepoint = self.codepoints.items[i];
break :blk codepoint.cluster != cluster;
} else break :blk true;
};
// We need to reset the `cell_offset` at the start of a new
// cluster, but we do that conditionally if the codepoint
// `is_first_codepoint_in_cluster` and the cluster is not
// `is_after_glyph_from_current_or_next_clusters`, which is
// a heuristic to detect ligatures and avoid positioning
// glyphs that mark ligatures incorrectly. The idea is that
// if the first codepoint in a cluster doesn't appear in
// the stream, it's very likely that it combined with
// codepoints from a previous cluster into a ligature.
// Then, the subsequent codepoints are very likely marking
// glyphs that are placed relative to that ligature, so if
// we were to reset the `cell_offset` to align it with the
// grid, the positions would be off. The
// `!is_after_glyph_from_current_or_next_clusters` check is
// needed in case these marking glyphs come from a later
// cluster but are rendered first (see the Chakma and
// Bengali tests). In that case when we get to the
// codepoint that `is_first_codepoint_in_cluster`, but in a
// cluster that
// `is_after_glyph_from_current_or_next_clusters`, we don't
// want to reset to the grid and cause the positions to be
// off. (Note that we could go back and align the cells to
// the grid starting from the one from the cluster that
// rendered out of order, but that is more complicated so
// we don't do that for now. Also, it's TBD if there are
// exceptions to this heuristic for detecting ligatures,
// but using the logging below seems to show it works
// well.)
if (is_first_codepoint_in_cluster and
!is_after_glyph_from_current_or_next_clusters)
{
cell_offset = .{
.cluster = cluster,
.x = run_offset.x,
};
}
}
// Under both FreeType and CoreText the harfbuzz scale is
// in 26.6 fixed point units, so we round to the nearest
// whole value here.
cell_offset.x += (pos_v.x_advance + 0b100_000) >> 6;
cell_offset.y += (pos_v.y_advance + 0b100_000) >> 6;
const x_offset = run_offset.x - cell_offset.x + ((pos_v.x_offset + 0b100_000) >> 6);
const y_offset = run_offset.y + ((pos_v.y_offset + 0b100_000) >> 6);
// For debugging positions, turn this on:
//try self.debugPositions(run_offset, cell_offset, pos_v, index);
try self.cell_buf.append(self.alloc, .{
.x = @intCast(cell_offset.cluster),
.x_offset = @intCast(x_offset),
.y_offset = @intCast(y_offset),
.glyph_index = info_v.codepoint,
});
// Add our advances to keep track of our run offsets.
// Advances apply to the NEXT cell.
// Under both FreeType and CoreText the harfbuzz scale is
// in 26.6 fixed point units, so we round to the nearest
// whole value here.
run_offset.x += (pos_v.x_advance + 0b100_000) >> 6;
run_offset.y += (pos_v.y_advance + 0b100_000) >> 6;
run_offset.cluster = @max(run_offset.cluster, cluster);
// const i = self.cell_buf.items.len - 1;
// log.warn("i={} info={} pos={} cell={}", .{ i, info_v, pos_v, self.cell_buf.items[i] });
@@ -180,6 +267,13 @@ pub const Shaper = struct {
self.shaper.hb_buf.reset();
self.shaper.hb_buf.setContentType(.unicode);
// We set the cluster level to `characters` to give us the most
// granularity, matching the CoreText shaper, and allowing us
// to use our same ligature detection heuristics.
self.shaper.hb_buf.setClusterLevel(.characters);
self.shaper.codepoints.clearRetainingCapacity();
// We don't support RTL text because RTL in terminals is messy.
// Its something we want to improve. For now, we force LTR because
// our renderers assume a strictly increasing X value.
@@ -188,13 +282,156 @@ pub const Shaper = struct {
pub fn addCodepoint(self: RunIteratorHook, cp: u32, cluster: u32) !void {
// log.warn("cluster={} cp={x}", .{ cluster, cp });
self.shaper.hb_buf.add(cp, cluster);
// We pass the index into codepoints as the cluster value to HarfBuzz.
// After shaping, we use info.cluster to get back the index, which
// lets us look up the original cluster value from codepoints.
const index: u32 = @intCast(self.shaper.codepoints.items.len);
self.shaper.hb_buf.add(cp, index);
try self.shaper.codepoints.append(self.shaper.alloc, .{
.cluster = cluster,
.codepoint = cp,
});
}
pub fn finalize(self: RunIteratorHook) void {
self.shaper.hb_buf.guessSegmentProperties();
}
};
fn debugPositions(
self: *Shaper,
run_offset: RunOffset,
cell_offset: CellOffset,
pos_v: harfbuzz.GlyphPosition,
index: u32,
) !void {
const x_offset = run_offset.x - cell_offset.x + ((pos_v.x_offset + 0b100_000) >> 6);
const y_offset = run_offset.y + ((pos_v.y_offset + 0b100_000) >> 6);
const advance_x_offset = run_offset.x - cell_offset.x;
const advance_y_offset = run_offset.y;
const x_offset_diff = x_offset - advance_x_offset;
const y_offset_diff = y_offset - advance_y_offset;
const positions_differ = @abs(x_offset_diff) > 0 or @abs(y_offset_diff) > 0;
const y_offset_differs = run_offset.y != 0;
const cluster = self.codepoints.items[index].cluster;
const cluster_differs = cluster != cell_offset.cluster;
// To debug every loop, flip this to true:
const extra_debugging = false;
const is_previous_codepoint_prepend = if (cluster_differs or
extra_debugging)
blk: {
var i = index;
while (i > 0) {
i -= 1;
const codepoint = self.codepoints.items[i];
break :blk unicode.table.get(@intCast(codepoint.codepoint)).grapheme_boundary_class == .prepend;
}
break :blk false;
} else false;
const formatted_cps: ?[]u8 = if (positions_differ or
y_offset_differs or
cluster_differs or
extra_debugging)
blk: {
var allocating = std.Io.Writer.Allocating.init(self.alloc);
defer allocating.deinit();
const writer = &allocating.writer;
const codepoints = self.codepoints.items;
var last_cluster: ?u32 = null;
for (codepoints, 0..) |cp, i| {
if (@as(i32, @intCast(cp.cluster)) >= @as(i32, @intCast(cell_offset.cluster)) - 1 and
cp.cluster <= cluster + 1)
{
if (last_cluster) |last| {
if (cp.cluster != last) {
try writer.writeAll(" ");
}
}
if (i == index) {
try writer.writeAll("");
}
// Using Python syntax for easier debugging
if (cp.codepoint > 0xFFFF) {
try writer.print("\\U{x:0>8}", .{cp.codepoint});
} else {
try writer.print("\\u{x:0>4}", .{cp.codepoint});
}
last_cluster = cp.cluster;
}
}
try writer.writeAll("");
for (codepoints) |cp| {
if (@as(i32, @intCast(cp.cluster)) >= @as(i32, @intCast(cell_offset.cluster)) - 1 and
cp.cluster <= cluster + 1)
{
try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))});
}
}
break :blk try allocating.toOwnedSlice();
} else null;
defer if (formatted_cps) |cps| self.alloc.free(cps);
if (extra_debugging) {
log.warn("extra debugging of positions index={d} cell_offset.cluster={d} cluster={d} run_offset.cluster={d} diff={d} pos=({d},{d}) run_offset=({d},{d}) cell_offset.x={d} is_prev_prepend={} cps = {s}", .{
index,
cell_offset.cluster,
cluster,
run_offset.cluster,
@as(isize, @intCast(cluster)) - @as(isize, @intCast(cell_offset.cluster)),
x_offset,
y_offset,
run_offset.x,
run_offset.y,
cell_offset.x,
is_previous_codepoint_prepend,
formatted_cps.?,
});
}
if (positions_differ) {
log.warn("position differs from advance: cluster={d} pos=({d},{d}) adv=({d},{d}) diff=({d},{d}) cps = {s}", .{
cluster,
x_offset,
y_offset,
advance_x_offset,
advance_y_offset,
x_offset_diff,
y_offset_diff,
formatted_cps.?,
});
}
if (y_offset_differs) {
log.warn("run_offset.y differs from zero: cluster={d} pos=({d},{d}) run_offset=({d},{d}) cell_offset.x={d} cps = {s}", .{
cluster,
x_offset,
y_offset,
run_offset.x,
run_offset.y,
cell_offset.x,
formatted_cps.?,
});
}
if (cluster_differs) {
log.warn("cell_offset.cluster differs from cluster (potential ligature detected) cell_offset.cluster={d} cluster={d} run_offset.cluster={d} diff={d} pos=({d},{d}) run_offset=({d},{d}) cell_offset.x={d} is_prev_prepend={} cps = {s}", .{
cell_offset.cluster,
cluster,
run_offset.cluster,
@as(isize, @intCast(cluster)) - @as(isize, @intCast(cell_offset.cluster)),
x_offset,
y_offset,
run_offset.x,
run_offset.y,
cell_offset.x,
is_previous_codepoint_prepend,
formatted_cps.?,
});
}
}
};
test "run iterator" {
@@ -737,7 +974,7 @@ test "shape with empty cells in between" {
}
}
test "shape Chinese characters" {
test "shape Combining characters" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -786,6 +1023,443 @@ test "shape Chinese characters" {
try testing.expectEqual(@as(usize, 1), count);
}
// This test exists because the string it uses causes HarfBuzz to output a
// non-monotonic run with our cluster level set to `characters`, which we need
// to handle by tracking the max cluster for the run.
test "shape Devanagari string" {
const testing = std.testing;
const alloc = testing.allocator;
// We need a font that supports devanagari for this to work, if we can't
// find Arial Unicode MS, which is a system font on macOS, we just skip
// the test.
var testdata = testShaperWithDiscoveredFont(
alloc,
"Arial Unicode MS",
) catch return error.SkipZigTest;
defer testdata.deinit();
// Make a screen with some data
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
defer t.deinit(alloc);
// Disable grapheme clustering
t.modes.set(.grapheme_cluster, false);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("अपार्टमेंट");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
const run = try it.next(alloc);
try testing.expect(run != null);
const cells = try shaper.shape(run.?);
try testing.expectEqual(@as(usize, 8), cells.len);
try testing.expectEqual(@as(u16, 0), cells[0].x);
try testing.expectEqual(@as(u16, 1), cells[1].x);
try testing.expectEqual(@as(u16, 2), cells[2].x);
try testing.expectEqual(@as(u16, 4), cells[3].x);
try testing.expectEqual(@as(u16, 4), cells[4].x);
try testing.expectEqual(@as(u16, 5), cells[5].x);
try testing.expectEqual(@as(u16, 5), cells[6].x);
try testing.expectEqual(@as(u16, 6), cells[7].x);
try testing.expect(try it.next(alloc) == null);
}
test "shape Tai Tham vowels (position differs from advance)" {
// Note that while this test was necessary for CoreText, the old logic was
// working for HarfBuzz. Still we keep it to ensure it has the correct
// behavior.
const testing = std.testing;
const alloc = testing.allocator;
// We need a font that supports Tai Tham for this to work, if we can't find
// Noto Sans Tai Tham, which is a system font on macOS, we just skip the
// test.
var testdata = testShaperWithDiscoveredFont(
alloc,
"Noto Sans Tai Tham",
) catch return error.SkipZigTest;
defer testdata.deinit();
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ
buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ
// Make a screen with some data
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
defer t.deinit(alloc);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u16, 0), cells[0].x);
try testing.expectEqual(@as(u16, 0), cells[1].x);
// The first glyph renders in the next cell. We expect the x_offset
// to equal the cell width. However, with FreeType the cell_width is
// computed from ASCII glyphs, and Noto Sans Tai Tham only has the
// space character in ASCII (with a 3px advance), so the cell_width
// metric doesn't match the actual Tai Tham glyph positioning.
const expected_x_offset: i16 = if (comptime font.options.backend.hasFreetype()) 7 else @intCast(run.grid.metrics.cell_width);
try testing.expectEqual(expected_x_offset, cells[0].x_offset);
try testing.expectEqual(@as(i16, 0), cells[1].x_offset);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape Tibetan characters" {
const testing = std.testing;
const alloc = testing.allocator;
// We need a font that has multiple glyphs for this codepoint to reproduce
// the old broken behavior, and Noto Serif Tibetan is one of them. It's not
// a default Mac font, and if we can't find it we just skip the test.
var testdata = testShaperWithDiscoveredFont(
alloc,
"Noto Serif Tibetan",
) catch return error.SkipZigTest;
defer testdata.deinit();
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
buf_idx += try std.unicode.utf8Encode(0x0f00, buf[buf_idx..]); // ༀ
// Make a screen with some data
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
defer t.deinit(alloc);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u16, 0), cells[0].x);
try testing.expectEqual(@as(u16, 0), cells[1].x);
// The second glyph renders at the correct location
try testing.expect(cells[1].x_offset < 2);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape Tai Tham letters (run_offset.y differs from zero)" {
const testing = std.testing;
const alloc = testing.allocator;
// We need a font that supports Tai Tham for this to work, if we can't find
// Noto Sans Tai Tham, which is a system font on macOS, we just skip the
// test.
var testdata = testShaperWithDiscoveredFont(
alloc,
"Noto Sans Tai Tham",
) catch return error.SkipZigTest;
defer testdata.deinit();
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
// First grapheme cluster:
buf_idx += try std.unicode.utf8Encode(0x1a49, buf[buf_idx..]); // HA
buf_idx += try std.unicode.utf8Encode(0x1a60, buf[buf_idx..]); // SAKOT
// Second grapheme cluster, combining with the first in a ligature:
buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // YA
buf_idx += try std.unicode.utf8Encode(0x1a69, buf[buf_idx..]); // U
// Make a screen with some data
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
defer t.deinit(alloc);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 3), cells.len);
try testing.expectEqual(@as(u16, 0), cells[0].x);
try testing.expectEqual(@as(u16, 0), cells[1].x);
try testing.expectEqual(@as(u16, 0), cells[2].x); // U from second grapheme
// The U glyph renders at a y below zero
try testing.expectEqual(@as(i16, -3), cells[2].y_offset);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape Javanese ligatures" {
const testing = std.testing;
const alloc = testing.allocator;
// We need a font that supports Javanese for this to work, if we can't find
// Noto Sans Javanese Regular, which is a system font on macOS, we just
// skip the test.
var testdata = testShaperWithDiscoveredFont(
alloc,
"Noto Sans Javanese",
) catch return error.SkipZigTest;
defer testdata.deinit();
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
// First grapheme cluster:
buf_idx += try std.unicode.utf8Encode(0xa9a4, buf[buf_idx..]); // NA
buf_idx += try std.unicode.utf8Encode(0xa9c0, buf[buf_idx..]); // PANGKON
// Second grapheme cluster, combining with the first in a ligature:
buf_idx += try std.unicode.utf8Encode(0xa9b2, buf[buf_idx..]); // HA
buf_idx += try std.unicode.utf8Encode(0xa9b8, buf[buf_idx..]); // Vowel sign SUKU
// Make a screen with some data
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
defer t.deinit(alloc);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
const cell_width = run.grid.metrics.cell_width;
try testing.expectEqual(@as(usize, 3), cells.len);
try testing.expectEqual(@as(u16, 0), cells[0].x);
try testing.expectEqual(@as(u16, 0), cells[1].x);
try testing.expectEqual(@as(u16, 0), cells[2].x);
// The vowel sign SUKU renders with correct x_offset
try testing.expect(cells[2].x_offset > 3 * cell_width);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape Chakma vowel sign with ligature (vowel sign renders first)" {
const testing = std.testing;
const alloc = testing.allocator;
// We need a font that supports Chakma for this to work, if we can't find
// Noto Sans Chakma Regular, which is a system font on macOS, we just skip
// the test.
var testdata = testShaperWithDiscoveredFont(
alloc,
"Noto Sans Chakma",
) catch return error.SkipZigTest;
defer testdata.deinit();
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
// First grapheme cluster:
buf_idx += try std.unicode.utf8Encode(0x1111d, buf[buf_idx..]); // BAA
// Second grapheme cluster:
buf_idx += try std.unicode.utf8Encode(0x11116, buf[buf_idx..]); // TAA
buf_idx += try std.unicode.utf8Encode(0x11133, buf[buf_idx..]); // Virama
// Third grapheme cluster, combining with the second in a ligature:
buf_idx += try std.unicode.utf8Encode(0x11120, buf[buf_idx..]); // YYAA
buf_idx += try std.unicode.utf8Encode(0x1112c, buf[buf_idx..]); // Vowel Sign U
// Make a screen with some data
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
defer t.deinit(alloc);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 4), cells.len);
try testing.expectEqual(@as(u16, 0), cells[0].x);
// See the giant "We need to reset the `cell_offset`" comment, but here
// we should technically have the rest of these be `x` of 1, but that
// would require going back in the stream to adjust past cells, and
// we don't take on that complexity.
try testing.expectEqual(@as(u16, 0), cells[1].x);
try testing.expectEqual(@as(u16, 0), cells[2].x);
try testing.expectEqual(@as(u16, 0), cells[3].x);
// The vowel sign U renders before the TAA:
try testing.expect(cells[1].x_offset < cells[2].x_offset);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape Bengali ligatures with out of order vowels" {
// Whereas this test in CoreText had everything shaping into one giant
// ligature, HarfBuzz splits it into a few clusters. It still looks okay
// (see #10332).
const testing = std.testing;
const alloc = testing.allocator;
// We need a font that supports Bengali for this to work, if we can't find
// Arial Unicode MS, which is a system font on macOS, we just skip the
// test.
var testdata = testShaperWithDiscoveredFont(
alloc,
"Arial Unicode MS",
) catch return error.SkipZigTest;
defer testdata.deinit();
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
// First grapheme cluster:
buf_idx += try std.unicode.utf8Encode(0x09b0, buf[buf_idx..]); // RA
buf_idx += try std.unicode.utf8Encode(0x09be, buf[buf_idx..]); // Vowel sign AA
// Second grapheme cluster:
buf_idx += try std.unicode.utf8Encode(0x09b7, buf[buf_idx..]); // SSA
buf_idx += try std.unicode.utf8Encode(0x09cd, buf[buf_idx..]); // Virama
// Third grapheme cluster:
buf_idx += try std.unicode.utf8Encode(0x099f, buf[buf_idx..]); // TTA
buf_idx += try std.unicode.utf8Encode(0x09cd, buf[buf_idx..]); // Virama
// Fourth grapheme cluster:
buf_idx += try std.unicode.utf8Encode(0x09b0, buf[buf_idx..]); // RA
buf_idx += try std.unicode.utf8Encode(0x09c7, buf[buf_idx..]); // Vowel sign E
// Make a screen with some data
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
defer t.deinit(alloc);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 8), cells.len);
try testing.expectEqual(@as(u16, 0), cells[0].x);
try testing.expectEqual(@as(u16, 0), cells[1].x);
// Whereas CoreText puts everything all into the first cell (see the
// corresponding test), HarfBuzz splits into two clusters.
try testing.expectEqual(@as(u16, 1), cells[2].x);
try testing.expectEqual(@as(u16, 1), cells[3].x);
try testing.expectEqual(@as(u16, 1), cells[4].x);
try testing.expectEqual(@as(u16, 1), cells[5].x);
try testing.expectEqual(@as(u16, 1), cells[6].x);
try testing.expectEqual(@as(u16, 1), cells[7].x);
// The vowel sign E renders before the SSA:
try testing.expect(cells[2].x_offset < cells[3].x_offset);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape box glyphs" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -1432,3 +2106,58 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
.lib = lib,
};
}
fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestShaper {
if (font.Discover == void) return error.SkipZigTest;
var lib = try Library.init(alloc);
errdefer lib.deinit();
var c = Collection.init();
c.load_options = .{ .library = lib };
// Discover and add our font to the collection.
{
var disco = font.Discover.init();
defer disco.deinit();
var disco_it = try disco.discover(alloc, .{
.family = font_req,
.size = 12,
.monospace = false,
});
defer disco_it.deinit();
var face: font.DeferredFace = (try disco_it.next()) orelse return error.FontNotFound;
errdefer face.deinit();
// Check which font was discovered - skip if it doesn't match the request
var name_buf: [256]u8 = undefined;
const face_name = face.name(&name_buf) catch "(unknown)";
if (std.mem.indexOf(u8, face_name, font_req) == null) {
return error.SkipZigTest;
}
_ = try c.add(
alloc,
try face.load(lib, .{ .size = .{ .points = 12 } }),
.{
.style = .regular,
.fallback = false,
.size_adjustment = .none,
},
);
}
const grid_ptr = try alloc.create(SharedGrid);
errdefer alloc.destroy(grid_ptr);
grid_ptr.* = try .init(alloc, .{ .collection = c });
errdefer grid_ptr.*.deinit(alloc);
var shaper = try Shaper.init(alloc, .{});
errdefer shaper.deinit();
return TestShaper{
.alloc = alloc,
.shaper = shaper,
.grid = grid_ptr,
.lib = lib,
};
}

12
src/inspector/AGENTS.md Normal file
View File

@@ -0,0 +1,12 @@
# Inspector Subsystem
The inspector is a feature of Ghostty that works similar to a
browser's developer tools. It allows the user to inspect and modify the
terminal state.
- See the full C API by finding `dcimgui.h` in the `.zig-cache` folder
in the root: `find . -type f -name dcimgui.h`. Use the newest version.
- See full examples of how to use every widget by loading this file:
<https://raw.githubusercontent.com/ocornut/imgui/refs/heads/master/imgui_demo.cpp>
- On macOS, run builds with `-Demit-macos-app=false` to verify API usage.
- There are no unit tests in this package.

File diff suppressed because it is too large Load Diff

View File

@@ -1,223 +0,0 @@
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const cimgui = @import("dcimgui");
const terminal = @import("../terminal/main.zig");
/// A cell being inspected. This duplicates much of the data in
/// the terminal data structure because we want the inspector to
/// not have a reference to the terminal state or to grab any
/// locks.
pub const Cell = struct {
/// The main codepoint for this cell.
codepoint: u21,
/// Codepoints for this cell to produce a single grapheme cluster.
/// This is only non-empty if the cell is part of a multi-codepoint
/// grapheme cluster. This does NOT include the primary codepoint.
cps: []const u21,
/// The style of this cell.
style: terminal.Style,
/// Wide state of the terminal cell
wide: terminal.Cell.Wide,
pub fn init(
alloc: Allocator,
pin: terminal.Pin,
) !Cell {
const cell = pin.rowAndCell().cell;
const style = pin.style(cell);
const cps: []const u21 = if (cell.hasGrapheme()) cps: {
const src = pin.grapheme(cell).?;
assert(src.len > 0);
break :cps try alloc.dupe(u21, src);
} else &.{};
errdefer if (cps.len > 0) alloc.free(cps);
return .{
.codepoint = cell.codepoint(),
.cps = cps,
.style = style,
.wide = cell.wide,
};
}
pub fn deinit(self: *Cell, alloc: Allocator) void {
if (self.cps.len > 0) alloc.free(self.cps);
}
pub fn renderTable(
self: *const Cell,
t: *const terminal.Terminal,
x: usize,
y: usize,
) void {
// We have a selected cell, show information about it.
_ = cimgui.c.ImGui_BeginTable(
"table_cursor",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Grid Position");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("row=%d col=%d", y, x);
}
}
// NOTE: we don't currently write the character itself because
// we haven't hooked up imgui to our font system. That's hard! We
// can/should instead hook up our renderer to imgui and just render
// the single glyph in an image view so it looks _identical_ to the
// terminal.
codepoint: {
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Codepoints");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (cimgui.c.ImGui_BeginListBox("##codepoints", .{ .x = 0, .y = 0 })) {
defer cimgui.c.ImGui_EndListBox();
if (self.codepoint == 0) {
_ = cimgui.c.ImGui_SelectableEx("(empty)", false, 0, .{});
break :codepoint;
}
// Primary codepoint
var buf: [256]u8 = undefined;
{
const key = std.fmt.bufPrintZ(&buf, "U+{X}", .{self.codepoint}) catch
"<internal error>";
_ = cimgui.c.ImGui_SelectableEx(key.ptr, false, 0, .{});
}
// All extras
for (self.cps) |cp| {
const key = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch
"<internal error>";
_ = cimgui.c.ImGui_SelectableEx(key.ptr, false, 0, .{});
}
}
}
}
// Character width property
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Width Property");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(@tagName(self.wide));
// If we have a color then we show the color
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Foreground Color");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
switch (self.style.fg_color) {
.none => cimgui.c.ImGui_Text("default"),
.palette => |idx| {
const rgb = t.colors.palette.current[idx];
cimgui.c.ImGui_Text("Palette %d", idx);
var color: [3]f32 = .{
@as(f32, @floatFromInt(rgb.r)) / 255,
@as(f32, @floatFromInt(rgb.g)) / 255,
@as(f32, @floatFromInt(rgb.b)) / 255,
};
_ = cimgui.c.ImGui_ColorEdit3(
"color_fg",
&color,
cimgui.c.ImGuiColorEditFlags_DisplayHex |
cimgui.c.ImGuiColorEditFlags_NoPicker |
cimgui.c.ImGuiColorEditFlags_NoLabel,
);
},
.rgb => |rgb| {
var color: [3]f32 = .{
@as(f32, @floatFromInt(rgb.r)) / 255,
@as(f32, @floatFromInt(rgb.g)) / 255,
@as(f32, @floatFromInt(rgb.b)) / 255,
};
_ = cimgui.c.ImGui_ColorEdit3(
"color_fg",
&color,
cimgui.c.ImGuiColorEditFlags_DisplayHex |
cimgui.c.ImGuiColorEditFlags_NoPicker |
cimgui.c.ImGuiColorEditFlags_NoLabel,
);
},
}
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Background Color");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
switch (self.style.bg_color) {
.none => cimgui.c.ImGui_Text("default"),
.palette => |idx| {
const rgb = t.colors.palette.current[idx];
cimgui.c.ImGui_Text("Palette %d", idx);
var color: [3]f32 = .{
@as(f32, @floatFromInt(rgb.r)) / 255,
@as(f32, @floatFromInt(rgb.g)) / 255,
@as(f32, @floatFromInt(rgb.b)) / 255,
};
_ = cimgui.c.ImGui_ColorEdit3(
"color_bg",
&color,
cimgui.c.ImGuiColorEditFlags_DisplayHex |
cimgui.c.ImGuiColorEditFlags_NoPicker |
cimgui.c.ImGuiColorEditFlags_NoLabel,
);
},
.rgb => |rgb| {
var color: [3]f32 = .{
@as(f32, @floatFromInt(rgb.r)) / 255,
@as(f32, @floatFromInt(rgb.g)) / 255,
@as(f32, @floatFromInt(rgb.b)) / 255,
};
_ = cimgui.c.ImGui_ColorEdit3(
"color_bg",
&color,
cimgui.c.ImGuiColorEditFlags_DisplayHex |
cimgui.c.ImGuiColorEditFlags_NoPicker |
cimgui.c.ImGuiColorEditFlags_NoLabel,
);
},
}
// Boolean styles
const styles = .{
"bold", "italic", "faint", "blink",
"inverse", "invisible", "strikethrough",
};
inline for (styles) |style| style: {
if (!@field(self.style.flags, style)) break :style;
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text(style.ptr);
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("true");
}
}
cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)");
}
};

View File

@@ -1,142 +0,0 @@
const cimgui = @import("dcimgui");
const terminal = @import("../terminal/main.zig");
/// Render cursor information with a table already open.
pub fn renderInTable(
t: *const terminal.Terminal,
cursor: *const terminal.Screen.Cursor,
) void {
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Position (x, y)");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("(%d, %d)", cursor.x, cursor.y);
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Style");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%s", @tagName(cursor.cursor_style).ptr);
}
}
if (cursor.pending_wrap) {
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Pending Wrap");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%s", if (cursor.pending_wrap) "true".ptr else "false".ptr);
}
}
// If we have a color then we show the color
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Foreground Color");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
switch (cursor.style.fg_color) {
.none => cimgui.c.ImGui_Text("default"),
.palette => |idx| {
const rgb = t.colors.palette.current[idx];
cimgui.c.ImGui_Text("Palette %d", idx);
var color: [3]f32 = .{
@as(f32, @floatFromInt(rgb.r)) / 255,
@as(f32, @floatFromInt(rgb.g)) / 255,
@as(f32, @floatFromInt(rgb.b)) / 255,
};
_ = cimgui.c.ImGui_ColorEdit3(
"color_fg",
&color,
cimgui.c.ImGuiColorEditFlags_DisplayHex |
cimgui.c.ImGuiColorEditFlags_NoPicker |
cimgui.c.ImGuiColorEditFlags_NoLabel,
);
},
.rgb => |rgb| {
var color: [3]f32 = .{
@as(f32, @floatFromInt(rgb.r)) / 255,
@as(f32, @floatFromInt(rgb.g)) / 255,
@as(f32, @floatFromInt(rgb.b)) / 255,
};
_ = cimgui.c.ImGui_ColorEdit3(
"color_fg",
&color,
cimgui.c.ImGuiColorEditFlags_DisplayHex |
cimgui.c.ImGuiColorEditFlags_NoPicker |
cimgui.c.ImGuiColorEditFlags_NoLabel,
);
},
}
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Background Color");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
switch (cursor.style.bg_color) {
.none => cimgui.c.ImGui_Text("default"),
.palette => |idx| {
const rgb = t.colors.palette.current[idx];
cimgui.c.ImGui_Text("Palette %d", idx);
var color: [3]f32 = .{
@as(f32, @floatFromInt(rgb.r)) / 255,
@as(f32, @floatFromInt(rgb.g)) / 255,
@as(f32, @floatFromInt(rgb.b)) / 255,
};
_ = cimgui.c.ImGui_ColorEdit3(
"color_bg",
&color,
cimgui.c.ImGuiColorEditFlags_DisplayHex |
cimgui.c.ImGuiColorEditFlags_NoPicker |
cimgui.c.ImGuiColorEditFlags_NoLabel,
);
},
.rgb => |rgb| {
var color: [3]f32 = .{
@as(f32, @floatFromInt(rgb.r)) / 255,
@as(f32, @floatFromInt(rgb.g)) / 255,
@as(f32, @floatFromInt(rgb.b)) / 255,
};
_ = cimgui.c.ImGui_ColorEdit3(
"color_bg",
&color,
cimgui.c.ImGuiColorEditFlags_DisplayHex |
cimgui.c.ImGuiColorEditFlags_NoPicker |
cimgui.c.ImGuiColorEditFlags_NoLabel,
);
},
}
// Boolean styles
const styles = .{
"bold", "italic", "faint", "blink",
"inverse", "invisible", "strikethrough",
};
inline for (styles) |style| style: {
if (!@field(cursor.style.flags, style)) break :style;
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text(style.ptr);
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("true");
}
}
}

View File

@@ -1,240 +0,0 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const input = @import("../input.zig");
const CircBuf = @import("../datastruct/main.zig").CircBuf;
const cimgui = @import("dcimgui");
/// Circular buffer of key events.
pub const EventRing = CircBuf(Event, undefined);
/// Represents a recorded keyboard event.
pub const Event = struct {
/// The input event.
event: input.KeyEvent,
/// The binding that was triggered as a result of this event.
/// Multiple bindings are possible if they are chained.
binding: []const input.Binding.Action = &.{},
/// The data sent to the pty as a result of this keyboard event.
/// This is allocated using the inspector allocator.
pty: []const u8 = "",
/// State for the inspector GUI. Do not set this unless you're the inspector.
imgui_state: struct {
selected: bool = false,
} = .{},
pub fn init(alloc: Allocator, event: input.KeyEvent) !Event {
var copy = event;
copy.utf8 = "";
if (event.utf8.len > 0) copy.utf8 = try alloc.dupe(u8, event.utf8);
return .{ .event = copy };
}
pub fn deinit(self: *const Event, alloc: Allocator) void {
alloc.free(self.binding);
if (self.event.utf8.len > 0) alloc.free(self.event.utf8);
if (self.pty.len > 0) alloc.free(self.pty);
}
/// Returns a label that can be used for this event. This is null-terminated
/// so it can be easily used with C APIs.
pub fn label(self: *const Event, buf: []u8) ![:0]const u8 {
var buf_stream = std.io.fixedBufferStream(buf);
const writer = buf_stream.writer();
switch (self.event.action) {
.press => try writer.writeAll("Press: "),
.release => try writer.writeAll("Release: "),
.repeat => try writer.writeAll("Repeat: "),
}
if (self.event.mods.shift) try writer.writeAll("Shift+");
if (self.event.mods.ctrl) try writer.writeAll("Ctrl+");
if (self.event.mods.alt) try writer.writeAll("Alt+");
if (self.event.mods.super) try writer.writeAll("Super+");
// Write our key. If we have an invalid key we attempt to write
// the utf8 associated with it if we have it to handle non-ascii.
try writer.writeAll(switch (self.event.key) {
.unidentified => if (self.event.utf8.len > 0) self.event.utf8 else @tagName(self.event.key),
else => @tagName(self.event.key),
});
// Deadkey
if (self.event.composing) try writer.writeAll(" (composing)");
// Null-terminator
try writer.writeByte(0);
return buf[0..(buf_stream.getWritten().len - 1) :0];
}
/// Render this event in the inspector GUI.
pub fn render(self: *const Event) void {
_ = cimgui.c.ImGui_BeginTable(
"##event",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
if (self.binding.len > 0) {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Triggered Binding");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
const height: f32 = height: {
const item_count: f32 = @floatFromInt(@min(self.binding.len, 5));
const padding = cimgui.c.ImGui_GetStyle().*.FramePadding.y * 2;
break :height cimgui.c.ImGui_GetTextLineHeightWithSpacing() * item_count + padding;
};
if (cimgui.c.ImGui_BeginListBox("##bindings", .{ .x = 0, .y = height })) {
defer cimgui.c.ImGui_EndListBox();
for (self.binding) |action| {
_ = cimgui.c.ImGui_SelectableEx(
@tagName(action).ptr,
false,
cimgui.c.ImGuiSelectableFlags_None,
.{ .x = 0, .y = 0 },
);
}
}
}
pty: {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Encoding to Pty");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (self.pty.len == 0) {
cimgui.c.ImGui_TextDisabled("(no data)");
break :pty;
}
self.renderPty() catch {
cimgui.c.ImGui_TextDisabled("(error rendering pty data)");
break :pty;
};
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Action");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%s", @tagName(self.event.action).ptr);
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Key");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%s", @tagName(self.event.key).ptr);
}
if (!self.event.mods.empty()) {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Mods");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (self.event.mods.shift) cimgui.c.ImGui_Text("shift ");
if (self.event.mods.ctrl) cimgui.c.ImGui_Text("ctrl ");
if (self.event.mods.alt) cimgui.c.ImGui_Text("alt ");
if (self.event.mods.super) cimgui.c.ImGui_Text("super ");
}
if (self.event.composing) {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Composing");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("true");
}
utf8: {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("UTF-8");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (self.event.utf8.len == 0) {
cimgui.c.ImGui_TextDisabled("(empty)");
break :utf8;
}
self.renderUtf8(self.event.utf8) catch {
cimgui.c.ImGui_TextDisabled("(error rendering utf-8)");
break :utf8;
};
}
}
fn renderUtf8(self: *const Event, utf8: []const u8) !void {
_ = self;
// Format the codepoint sequence
var buf: [1024]u8 = undefined;
var buf_stream = std.io.fixedBufferStream(&buf);
const writer = buf_stream.writer();
if (std.unicode.Utf8View.init(utf8)) |view| {
var it = view.iterator();
while (it.nextCodepoint()) |cp| {
try writer.print("U+{X} ", .{cp});
}
} else |_| {
try writer.writeAll("(invalid utf-8)");
}
try writer.writeByte(0);
// Render as a textbox
_ = cimgui.c.ImGui_InputText(
"##utf8",
&buf,
buf_stream.getWritten().len - 1,
cimgui.c.ImGuiInputTextFlags_ReadOnly,
);
}
fn renderPty(self: *const Event) !void {
// Format the codepoint sequence
var buf: [1024]u8 = undefined;
var buf_stream = std.io.fixedBufferStream(&buf);
const writer = buf_stream.writer();
for (self.pty) |byte| {
// Print ESC special because its so common
if (byte == 0x1B) {
try writer.writeAll("ESC ");
continue;
}
// Print ASCII as-is
if (byte > 0x20 and byte < 0x7F) {
try writer.writeByte(byte);
continue;
}
// Everything else as a hex byte
try writer.print("0x{X} ", .{byte});
}
try writer.writeByte(0);
// Render as a textbox
_ = cimgui.c.ImGui_InputText(
"##pty",
&buf,
buf_stream.getWritten().len - 1,
cimgui.c.ImGuiInputTextFlags_ReadOnly,
);
}
};
test "event string" {
const testing = std.testing;
const alloc = testing.allocator;
var event = try Event.init(alloc, .{ .key = .key_a });
defer event.deinit(alloc);
var buf: [1024]u8 = undefined;
try testing.expectEqualStrings("Press: key_a", try event.label(&buf));
}

View File

@@ -1,13 +1,8 @@
const std = @import("std");
pub const cell = @import("cell.zig");
pub const cursor = @import("cursor.zig");
pub const key = @import("key.zig");
pub const page = @import("page.zig");
pub const termio = @import("termio.zig");
pub const Cell = cell.Cell;
pub const widgets = @import("widgets.zig");
pub const Inspector = @import("Inspector.zig");
pub const KeyEvent = widgets.key.Event;
test {
@import("std").testing.refAllDecls(@This());
}

View File

@@ -1,163 +0,0 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const cimgui = @import("dcimgui");
const terminal = @import("../terminal/main.zig");
const units = @import("units.zig");
pub fn render(page: *const terminal.Page) void {
cimgui.c.ImGui_PushIDPtr(page);
defer cimgui.c.ImGui_PopID();
_ = cimgui.c.ImGui_BeginTable(
"##page_state",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Memory Size");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d bytes (%d KiB)", page.memory.len, units.toKibiBytes(page.memory.len));
cimgui.c.ImGui_Text("%d VM pages", page.memory.len / std.heap.page_size_min);
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Unique Styles");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d", page.styles.count());
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Grapheme Entries");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d", page.graphemeCount());
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Capacity");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
_ = cimgui.c.ImGui_BeginTable(
"##capacity",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
const cap = page.capacity;
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Columns");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d", @as(u32, @intCast(cap.cols)));
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Rows");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d", @as(u32, @intCast(cap.rows)));
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Unique Styles");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d", @as(u32, @intCast(cap.styles)));
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Grapheme Bytes");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d", cap.grapheme_bytes);
}
}
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Size");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
_ = cimgui.c.ImGui_BeginTable(
"##size",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
const size = page.size;
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Columns");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d", @as(u32, @intCast(size.cols)));
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Rows");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d", @as(u32, @intCast(size.rows)));
}
}
}
} // size table
}

View File

@@ -1,398 +0,0 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const cimgui = @import("dcimgui");
const terminal = @import("../terminal/main.zig");
const CircBuf = @import("../datastruct/main.zig").CircBuf;
const Surface = @import("../Surface.zig");
/// The stream handler for our inspector.
pub const Stream = terminal.Stream(VTHandler);
/// VT event circular buffer.
pub const VTEventRing = CircBuf(VTEvent, undefined);
/// VT event
pub const VTEvent = struct {
/// Sequence number, just monotonically increasing.
seq: usize = 1,
/// Kind of event, for filtering
kind: Kind,
/// The formatted string of the event. This is allocated. We format the
/// event for now because there is so much data to copy if we wanted to
/// store the raw event.
str: [:0]const u8,
/// Various metadata at the time of the event (before processing).
cursor: terminal.Screen.Cursor,
scrolling_region: terminal.Terminal.ScrollingRegion,
metadata: Metadata.Unmanaged = .{},
/// imgui selection state
imgui_selected: bool = false,
const Kind = enum { print, execute, csi, esc, osc, dcs, apc };
const Metadata = std.StringHashMap([:0]const u8);
/// Initialize the event information for the given parser action.
pub fn init(
alloc: Allocator,
surface: *Surface,
action: terminal.Parser.Action,
) !VTEvent {
var md = Metadata.init(alloc);
errdefer md.deinit();
var buf: std.Io.Writer.Allocating = .init(alloc);
defer buf.deinit();
try encodeAction(alloc, &buf.writer, &md, action);
const str = try buf.toOwnedSliceSentinel(0);
errdefer alloc.free(str);
const kind: Kind = switch (action) {
.print => .print,
.execute => .execute,
.csi_dispatch => .csi,
.esc_dispatch => .esc,
.osc_dispatch => .osc,
.dcs_hook, .dcs_put, .dcs_unhook => .dcs,
.apc_start, .apc_put, .apc_end => .apc,
};
const t = surface.renderer_state.terminal;
return .{
.kind = kind,
.str = str,
.cursor = t.screens.active.cursor,
.scrolling_region = t.scrolling_region,
.metadata = md.unmanaged,
};
}
pub fn deinit(self: *VTEvent, alloc: Allocator) void {
{
var it = self.metadata.valueIterator();
while (it.next()) |v| alloc.free(v.*);
self.metadata.deinit(alloc);
}
alloc.free(self.str);
}
/// Returns true if the event passes the given filter.
pub fn passFilter(
self: *const VTEvent,
filter: *const cimgui.c.ImGuiTextFilter,
) bool {
// Check our main string
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
self.str.ptr,
null,
)) return true;
// We also check all metadata keys and values
var it = self.metadata.iterator();
while (it.next()) |entry| {
var buf: [256]u8 = undefined;
const key = std.fmt.bufPrintZ(&buf, "{s}", .{entry.key_ptr.*}) catch continue;
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
key.ptr,
null,
)) return true;
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
entry.value_ptr.ptr,
null,
)) return true;
}
return false;
}
/// Encode a parser action as a string that we show in the logs.
fn encodeAction(
alloc: Allocator,
writer: *std.Io.Writer,
md: *Metadata,
action: terminal.Parser.Action,
) !void {
switch (action) {
.print => try encodePrint(writer, action),
.execute => try encodeExecute(writer, action),
.csi_dispatch => |v| try encodeCSI(writer, v),
.esc_dispatch => |v| try encodeEsc(writer, v),
.osc_dispatch => |v| try encodeOSC(alloc, writer, md, v),
else => try writer.print("{f}", .{action}),
}
}
fn encodePrint(writer: *std.Io.Writer, action: terminal.Parser.Action) !void {
const ch = action.print;
try writer.print("'{u}' (U+{X})", .{ ch, ch });
}
fn encodeExecute(writer: *std.Io.Writer, action: terminal.Parser.Action) !void {
const ch = action.execute;
switch (ch) {
0x00 => try writer.writeAll("NUL"),
0x01 => try writer.writeAll("SOH"),
0x02 => try writer.writeAll("STX"),
0x03 => try writer.writeAll("ETX"),
0x04 => try writer.writeAll("EOT"),
0x05 => try writer.writeAll("ENQ"),
0x06 => try writer.writeAll("ACK"),
0x07 => try writer.writeAll("BEL"),
0x08 => try writer.writeAll("BS"),
0x09 => try writer.writeAll("HT"),
0x0A => try writer.writeAll("LF"),
0x0B => try writer.writeAll("VT"),
0x0C => try writer.writeAll("FF"),
0x0D => try writer.writeAll("CR"),
0x0E => try writer.writeAll("SO"),
0x0F => try writer.writeAll("SI"),
else => try writer.writeAll("?"),
}
try writer.print(" (0x{X})", .{ch});
}
fn encodeCSI(writer: *std.Io.Writer, csi: terminal.Parser.Action.CSI) !void {
for (csi.intermediates) |v| try writer.print("{c} ", .{v});
for (csi.params, 0..) |v, i| {
if (i != 0) try writer.writeByte(';');
try writer.print("{d}", .{v});
}
if (csi.intermediates.len > 0 or csi.params.len > 0) try writer.writeByte(' ');
try writer.writeByte(csi.final);
}
fn encodeEsc(writer: *std.Io.Writer, esc: terminal.Parser.Action.ESC) !void {
for (esc.intermediates) |v| try writer.print("{c} ", .{v});
try writer.writeByte(esc.final);
}
fn encodeOSC(
alloc: Allocator,
writer: *std.Io.Writer,
md: *Metadata,
osc: terminal.osc.Command,
) !void {
// The description is just the tag
try writer.print("{s} ", .{@tagName(osc)});
// Add additional fields to metadata
switch (osc) {
inline else => |v, tag| if (tag == osc) {
try encodeMetadata(alloc, md, v);
},
}
}
fn encodeMetadata(
alloc: Allocator,
md: *Metadata,
v: anytype,
) !void {
switch (@TypeOf(v)) {
void => {},
[]const u8,
[:0]const u8,
=> try md.put("data", try alloc.dupeZ(u8, v)),
else => |T| switch (@typeInfo(T)) {
.@"struct" => |info| inline for (info.fields) |field| {
try encodeMetadataSingle(
alloc,
md,
field.name,
@field(v, field.name),
);
},
.@"union" => |info| {
const Tag = info.tag_type orelse @compileError("Unions must have a tag");
const tag_name = @tagName(@as(Tag, v));
inline for (info.fields) |field| {
if (std.mem.eql(u8, field.name, tag_name)) {
if (field.type == void) {
break try md.put("data", tag_name);
} else {
break try encodeMetadataSingle(alloc, md, tag_name, @field(v, field.name));
}
}
}
},
else => {
@compileLog(T);
@compileError("unsupported type, see log");
},
},
}
}
fn encodeMetadataSingle(
alloc: Allocator,
md: *Metadata,
key: []const u8,
value: anytype,
) !void {
const Value = @TypeOf(value);
const info = @typeInfo(Value);
switch (info) {
.optional => if (value) |unwrapped| {
try encodeMetadataSingle(alloc, md, key, unwrapped);
} else {
try md.put(key, try alloc.dupeZ(u8, "(unset)"));
},
.bool => try md.put(
key,
try alloc.dupeZ(u8, if (value) "true" else "false"),
),
.@"enum" => try md.put(
key,
try alloc.dupeZ(u8, @tagName(value)),
),
.@"union" => |u| {
const Tag = u.tag_type orelse @compileError("Unions must have a tag");
const tag_name = @tagName(@as(Tag, value));
inline for (u.fields) |field| {
if (std.mem.eql(u8, field.name, tag_name)) {
const s = if (field.type == void)
try alloc.dupeZ(u8, tag_name)
else if (field.type == [:0]const u8 or field.type == []const u8)
try std.fmt.allocPrintSentinel(alloc, "{s}={s}", .{
tag_name,
@field(value, field.name),
}, 0)
else
try std.fmt.allocPrintSentinel(alloc, "{s}={}", .{
tag_name,
@field(value, field.name),
}, 0);
try md.put(key, s);
}
}
},
.@"struct" => try md.put(
key,
try alloc.dupeZ(u8, @typeName(Value)),
),
else => switch (Value) {
[]const u8,
[:0]const u8,
=> try md.put(key, try alloc.dupeZ(u8, value)),
else => |T| switch (@typeInfo(T)) {
.int => try md.put(
key,
try std.fmt.allocPrintSentinel(alloc, "{}", .{value}, 0),
),
else => {
@compileLog(T);
@compileError("unsupported type, see log");
},
},
},
}
}
};
/// Our VT stream handler.
pub const VTHandler = struct {
/// The surface that the inspector is attached to. We use this instead
/// of the inspector because this is pointer-stable.
surface: *Surface,
/// True if the handler is currently recording.
active: bool = true,
/// Current sequence number
current_seq: usize = 1,
/// Exclude certain actions by tag.
filter_exclude: ActionTagSet = .initMany(&.{.print}),
filter_text: cimgui.c.ImGuiTextFilter = .{},
const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag);
pub fn init(surface: *Surface) VTHandler {
return .{
.surface = surface,
};
}
pub fn deinit(self: *VTHandler) void {
_ = self;
}
pub fn vt(
self: *VTHandler,
comptime action: Stream.Action.Tag,
value: Stream.Action.Value(action),
) !void {
_ = self;
_ = value;
}
/// This is called with every single terminal action.
pub fn handleManually(self: *VTHandler, action: terminal.Parser.Action) !bool {
const insp = self.surface.inspector orelse return false;
// We always increment the sequence number, even if we're paused or
// filter out the event. This helps show the user that there is a gap
// between events and roughly how large that gap was.
defer self.current_seq +%= 1;
// If we're pausing, then we ignore all events.
if (!self.active) return true;
// We ignore certain action types that are too noisy.
switch (action) {
.dcs_put, .apc_put => return true,
else => {},
}
// If we requested a specific type to be ignored, ignore it.
// We return true because we did "handle" it by ignoring it.
if (self.filter_exclude.contains(std.meta.activeTag(action))) return true;
// Build our event
const alloc = self.surface.alloc;
var ev = try VTEvent.init(alloc, self.surface, action);
ev.seq = self.current_seq;
errdefer ev.deinit(alloc);
// Check if the event passes the filter
if (!ev.passFilter(&self.filter_text)) {
ev.deinit(alloc);
return true;
}
const max_capacity = 100;
insp.vt_events.append(ev) catch |err| switch (err) {
error.OutOfMemory => if (insp.vt_events.capacity() < max_capacity) {
// We're out of memory, but we can allocate to our capacity.
const new_capacity = @min(insp.vt_events.capacity() * 2, max_capacity);
try insp.vt_events.resize(insp.surface.alloc, new_capacity);
try insp.vt_events.append(ev);
} else {
var it = insp.vt_events.iterator(.forward);
if (it.next()) |old_ev| old_ev.deinit(insp.surface.alloc);
insp.vt_events.deleteOldest(1);
try insp.vt_events.append(ev);
},
else => return err,
};
return true;
}
};

227
src/inspector/widgets.zig Normal file
View File

@@ -0,0 +1,227 @@
const cimgui = @import("dcimgui");
pub const page = @import("widgets/page.zig");
pub const pagelist = @import("widgets/pagelist.zig");
pub const key = @import("widgets/key.zig");
pub const renderer = @import("widgets/renderer.zig");
pub const screen = @import("widgets/screen.zig");
pub const style = @import("widgets/style.zig");
pub const surface = @import("widgets/surface.zig");
pub const terminal = @import("widgets/terminal.zig");
pub const termio = @import("widgets/termio.zig");
/// Draws a "(?)" disabled text marker that shows some help text
/// on hover.
pub fn helpMarker(text: [:0]const u8) void {
cimgui.c.ImGui_TextDisabled("(?)");
if (!cimgui.c.ImGui_BeginItemTooltip()) return;
defer cimgui.c.ImGui_EndTooltip();
cimgui.c.ImGui_PushTextWrapPos(cimgui.c.ImGui_GetFontSize() * 35.0);
defer cimgui.c.ImGui_PopTextWrapPos();
cimgui.c.ImGui_TextUnformatted(text.ptr);
}
/// DetachableHeader allows rendering a collapsing header that can be
/// detached into its own window.
pub const DetachableHeader = struct {
/// Set whether the window is detached.
detached: bool = false,
/// If true, detaching will move the item into a docking position
/// to the right.
dock: bool = true,
// Internal state do not touch.
window_first: bool = true,
pub fn windowEnd(self: *DetachableHeader) void {
_ = self;
// If we started the window, we need to end it.
cimgui.c.ImGui_End();
}
/// Returns null if there is no window created (not detached).
/// Otherwise returns whether the window is open.
pub fn window(
self: *DetachableHeader,
label: [:0]const u8,
) ?bool {
// If we're not detached, we don't create a window.
if (!self.detached) {
self.window_first = true;
return null;
}
// If this is our first time showing the window then we need to
// setup docking. We only do this on the first time because we
// don't want to reset a user's docking behavior later.
if (self.window_first) dock: {
self.window_first = false;
if (!self.dock) break :dock;
const dock_id = cimgui.c.ImGui_GetWindowDockID();
if (dock_id == 0) break :dock;
var dock_id_right: cimgui.c.ImGuiID = 0;
var dock_id_left: cimgui.c.ImGuiID = 0;
_ = cimgui.ImGui_DockBuilderSplitNode(
dock_id,
cimgui.c.ImGuiDir_Right,
0.4,
&dock_id_right,
&dock_id_left,
);
cimgui.ImGui_DockBuilderDockWindow(label, dock_id_right);
cimgui.ImGui_DockBuilderFinish(dock_id);
}
return cimgui.c.ImGui_Begin(
label,
&self.detached,
cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing,
);
}
pub fn header(
self: *DetachableHeader,
label: [:0]const u8,
) bool {
// If we're detached, create a separate window.
if (self.detached) return false;
// Make sure all headers have a unique ID in the stack. We only
// need to do this for the header side because creating a window
// automatically creates an ID.
cimgui.c.ImGui_PushID(label);
defer cimgui.c.ImGui_PopID();
// Create the collapsing header with the pop out button overlaid.
cimgui.c.ImGui_SetNextItemAllowOverlap();
const is_open = cimgui.c.ImGui_CollapsingHeader(
label,
cimgui.c.ImGuiTreeNodeFlags_None,
);
// Place pop-out button inside the header bar
const header_max = cimgui.c.ImGui_GetItemRectMax();
const header_min = cimgui.c.ImGui_GetItemRectMin();
const frame_height = cimgui.c.ImGui_GetFrameHeight();
const button_size = frame_height - 4;
const padding = 4;
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_SetCursorScreenPos(.{
.x = header_max.x - button_size - padding,
.y = header_min.y + 2,
});
{
cimgui.c.ImGui_PushStyleVarImVec2(
cimgui.c.ImGuiStyleVar_FramePadding,
.{ .x = 0, .y = 0 },
);
defer cimgui.c.ImGui_PopStyleVar();
if (cimgui.c.ImGui_ButtonEx(
">>##detach",
.{ .x = button_size, .y = button_size },
)) {
self.detached = true;
}
}
if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) {
cimgui.c.ImGui_SetTooltip("Detach into separate window");
}
return is_open;
}
};
pub const DetachableHeaderState = struct {
show_window: bool = false,
/// Internal state. Don't touch.
first_show: bool = false,
};
/// Render a collapsing header that can be detached into its own window.
/// When detached, renders as a separate window with a close button.
/// When attached, renders as a collapsing header with a pop-out button.
pub fn detachableHeader(
label: [:0]const u8,
state: *DetachableHeaderState,
ctx: anytype,
comptime contentFn: fn (@TypeOf(ctx)) void,
) void {
cimgui.c.ImGui_PushID(label);
defer cimgui.c.ImGui_PopID();
if (state.show_window) {
// On first show, dock this window to the right of the parent window's dock.
// We only do this once so the user can freely reposition the window afterward
// without it snapping back to the right on every frame.
if (!state.first_show) {
state.first_show = true;
const current_dock_id = cimgui.c.ImGui_GetWindowDockID();
if (current_dock_id != 0) {
var dock_id_right: cimgui.c.ImGuiID = 0;
var dock_id_left: cimgui.c.ImGuiID = 0;
_ = cimgui.ImGui_DockBuilderSplitNode(
current_dock_id,
cimgui.c.ImGuiDir_Right,
0.3,
&dock_id_right,
&dock_id_left,
);
cimgui.ImGui_DockBuilderDockWindow(label, dock_id_right);
cimgui.ImGui_DockBuilderFinish(current_dock_id);
}
}
defer cimgui.c.ImGui_End();
if (cimgui.c.ImGui_Begin(
label,
&state.show_window,
cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing,
)) contentFn(ctx);
return;
}
// Reset first_show when window is closed so next open docks again
state.first_show = false;
cimgui.c.ImGui_SetNextItemAllowOverlap();
const is_open = cimgui.c.ImGui_CollapsingHeader(
label,
cimgui.c.ImGuiTreeNodeFlags_None,
);
// Place pop-out button inside the header bar
const header_max = cimgui.c.ImGui_GetItemRectMax();
const header_min = cimgui.c.ImGui_GetItemRectMin();
const frame_height = cimgui.c.ImGui_GetFrameHeight();
const button_size = frame_height - 4;
const padding = 4;
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_SetCursorScreenPos(.{
.x = header_max.x - button_size - padding,
.y = header_min.y + 2,
});
cimgui.c.ImGui_PushStyleVarImVec2(
cimgui.c.ImGuiStyleVar_FramePadding,
.{ .x = 0, .y = 0 },
);
if (cimgui.c.ImGui_ButtonEx(
">>##detach",
.{ .x = button_size, .y = button_size },
)) {
state.show_window = true;
}
cimgui.c.ImGui_PopStyleVar();
if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) {
cimgui.c.ImGui_SetTooltip("Pop out into separate window");
}
if (is_open) contentFn(ctx);
}

View File

@@ -0,0 +1,535 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const input = @import("../../input.zig");
const CircBuf = @import("../../datastruct/main.zig").CircBuf;
const cimgui = @import("dcimgui");
/// Circular buffer of key events.
pub const EventRing = CircBuf(Event, undefined);
/// Represents a recorded keyboard event.
pub const Event = struct {
/// The input event.
event: input.KeyEvent,
/// The binding that was triggered as a result of this event.
/// Multiple bindings are possible if they are chained.
binding: []const input.Binding.Action = &.{},
/// The data sent to the pty as a result of this keyboard event.
/// This is allocated using the inspector allocator.
pty: []const u8 = "",
/// State for the inspector GUI. Do not set this unless you're the inspector.
imgui_state: struct {
selected: bool = false,
} = .{},
pub fn init(alloc: Allocator, ev: input.KeyEvent) !Event {
var copy = ev;
copy.utf8 = "";
if (ev.utf8.len > 0) copy.utf8 = try alloc.dupe(u8, ev.utf8);
return .{ .event = copy };
}
pub fn deinit(self: *const Event, alloc: Allocator) void {
alloc.free(self.binding);
if (self.event.utf8.len > 0) alloc.free(self.event.utf8);
if (self.pty.len > 0) alloc.free(self.pty);
}
/// Returns a label that can be used for this event. This is null-terminated
/// so it can be easily used with C APIs.
pub fn label(self: *const Event, buf: []u8) ![:0]const u8 {
var buf_stream = std.io.fixedBufferStream(buf);
const writer = buf_stream.writer();
switch (self.event.action) {
.press => try writer.writeAll("Press: "),
.release => try writer.writeAll("Release: "),
.repeat => try writer.writeAll("Repeat: "),
}
if (self.event.mods.shift) try writer.writeAll("Shift+");
if (self.event.mods.ctrl) try writer.writeAll("Ctrl+");
if (self.event.mods.alt) try writer.writeAll("Alt+");
if (self.event.mods.super) try writer.writeAll("Super+");
// Write our key. If we have an invalid key we attempt to write
// the utf8 associated with it if we have it to handle non-ascii.
try writer.writeAll(switch (self.event.key) {
.unidentified => if (self.event.utf8.len > 0) self.event.utf8 else @tagName(self.event.key),
else => @tagName(self.event.key),
});
// Deadkey
if (self.event.composing) try writer.writeAll(" (composing)");
// Null-terminator
try writer.writeByte(0);
return buf[0..(buf_stream.getWritten().len - 1) :0];
}
/// Render this event in the inspector GUI.
pub fn render(self: *const Event) void {
_ = cimgui.c.ImGui_BeginTable(
"##event",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
if (self.binding.len > 0) {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Triggered Binding");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
const height: f32 = height: {
const item_count: f32 = @floatFromInt(@min(self.binding.len, 5));
const padding = cimgui.c.ImGui_GetStyle().*.FramePadding.y * 2;
break :height cimgui.c.ImGui_GetTextLineHeightWithSpacing() * item_count + padding;
};
if (cimgui.c.ImGui_BeginListBox("##bindings", .{ .x = 0, .y = height })) {
defer cimgui.c.ImGui_EndListBox();
for (self.binding) |action| {
_ = cimgui.c.ImGui_SelectableEx(
@tagName(action).ptr,
false,
cimgui.c.ImGuiSelectableFlags_None,
.{ .x = 0, .y = 0 },
);
}
}
}
pty: {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Encoding to Pty");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (self.pty.len == 0) {
cimgui.c.ImGui_TextDisabled("(no data)");
break :pty;
}
self.renderPty() catch {
cimgui.c.ImGui_TextDisabled("(error rendering pty data)");
break :pty;
};
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Action");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%s", @tagName(self.event.action).ptr);
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Key");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%s", @tagName(self.event.key).ptr);
}
if (!self.event.mods.empty()) {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Mods");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (self.event.mods.shift) cimgui.c.ImGui_Text("shift ");
if (self.event.mods.ctrl) cimgui.c.ImGui_Text("ctrl ");
if (self.event.mods.alt) cimgui.c.ImGui_Text("alt ");
if (self.event.mods.super) cimgui.c.ImGui_Text("super ");
}
if (self.event.composing) {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Composing");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("true");
}
utf8: {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("UTF-8");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (self.event.utf8.len == 0) {
cimgui.c.ImGui_TextDisabled("(empty)");
break :utf8;
}
self.renderUtf8(self.event.utf8) catch {
cimgui.c.ImGui_TextDisabled("(error rendering utf-8)");
break :utf8;
};
}
}
fn renderUtf8(self: *const Event, utf8: []const u8) !void {
_ = self;
// Format the codepoint sequence
var buf: [1024]u8 = undefined;
var buf_stream = std.io.fixedBufferStream(&buf);
const writer = buf_stream.writer();
if (std.unicode.Utf8View.init(utf8)) |view| {
var it = view.iterator();
while (it.nextCodepoint()) |cp| {
try writer.print("U+{X} ", .{cp});
}
} else |_| {
try writer.writeAll("(invalid utf-8)");
}
try writer.writeByte(0);
// Render as a textbox
_ = cimgui.c.ImGui_InputText(
"##utf8",
&buf,
buf_stream.getWritten().len - 1,
cimgui.c.ImGuiInputTextFlags_ReadOnly,
);
}
fn renderPty(self: *const Event) !void {
// Format the codepoint sequence
var buf: [1024]u8 = undefined;
var buf_stream = std.io.fixedBufferStream(&buf);
const writer = buf_stream.writer();
for (self.pty) |byte| {
// Print ESC special because its so common
if (byte == 0x1B) {
try writer.writeAll("ESC ");
continue;
}
// Print ASCII as-is
if (byte > 0x20 and byte < 0x7F) {
try writer.writeByte(byte);
continue;
}
// Everything else as a hex byte
try writer.print("0x{X} ", .{byte});
}
try writer.writeByte(0);
// Render as a textbox
_ = cimgui.c.ImGui_InputText(
"##pty",
&buf,
buf_stream.getWritten().len - 1,
cimgui.c.ImGuiInputTextFlags_ReadOnly,
);
}
};
fn modsTooltip(
mods: *const input.Mods,
buf: []u8,
) ![:0]const u8 {
var stream = std.io.fixedBufferStream(buf);
const writer = stream.writer();
var first = true;
if (mods.shift) {
try writer.writeAll("Shift");
first = false;
}
if (mods.ctrl) {
if (!first) try writer.writeAll("+");
try writer.writeAll("Ctrl");
first = false;
}
if (mods.alt) {
if (!first) try writer.writeAll("+");
try writer.writeAll("Alt");
first = false;
}
if (mods.super) {
if (!first) try writer.writeAll("+");
try writer.writeAll("Super");
}
try writer.writeByte(0);
const written = stream.getWritten();
return written[0 .. written.len - 1 :0];
}
/// Keyboard event stream inspector widget.
pub const Stream = struct {
events: EventRing,
pub fn init(alloc: Allocator) !Stream {
var events: EventRing = try .init(alloc, 2);
errdefer events.deinit(alloc);
return .{ .events = events };
}
pub fn deinit(self: *Stream, alloc: Allocator) void {
var it = self.events.iterator(.forward);
while (it.next()) |v| v.deinit(alloc);
self.events.deinit(alloc);
}
pub fn draw(
self: *Stream,
open: bool,
alloc: Allocator,
) void {
if (!open) return;
if (self.events.empty()) {
cimgui.c.ImGui_Text("No recorded key events. Press a key with the " ++
"terminal focused to record it.");
return;
}
if (cimgui.c.ImGui_Button("Clear")) {
var it = self.events.iterator(.forward);
while (it.next()) |v| v.deinit(alloc);
self.events.clear();
}
cimgui.c.ImGui_Separator();
const table_flags = cimgui.c.ImGuiTableFlags_Borders |
cimgui.c.ImGuiTableFlags_Resizable |
cimgui.c.ImGuiTableFlags_ScrollY |
cimgui.c.ImGuiTableFlags_SizingFixedFit;
if (!cimgui.c.ImGui_BeginTable("table_key_events", 6, table_flags)) return;
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableSetupScrollFreeze(0, 1);
cimgui.c.ImGui_TableSetupColumnEx("Action", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 80, 0);
cimgui.c.ImGui_TableSetupColumnEx("Key", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 160, 0);
cimgui.c.ImGui_TableSetupColumnEx("Mods", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 150, 0);
cimgui.c.ImGui_TableSetupColumnEx("UTF-8", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 80, 0);
cimgui.c.ImGui_TableSetupColumnEx("PTY Encoding", cimgui.c.ImGuiTableColumnFlags_WidthStretch, 0, 0);
cimgui.c.ImGui_TableSetupColumnEx("Binding", cimgui.c.ImGuiTableColumnFlags_WidthStretch, 0, 0);
cimgui.c.ImGui_TableHeadersRow();
var it = self.events.iterator(.reverse);
while (it.next()) |ev| {
cimgui.c.ImGui_PushIDPtr(ev);
defer cimgui.c.ImGui_PopID();
cimgui.c.ImGui_TableNextRow();
const row_min_y = cimgui.c.ImGui_GetCursorScreenPos().y;
// Set row background color based on action
cimgui.c.ImGui_TableSetBgColor(cimgui.c.ImGuiTableBgTarget_RowBg0, actionColor(ev.event.action), -1);
// Action column with colored text
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
const action_text_color: cimgui.c.ImVec4 = switch (ev.event.action) {
.press => .{ .x = 0.4, .y = 1.0, .z = 0.4, .w = 1.0 }, // Green
.release => .{ .x = 0.6, .y = 0.6, .z = 1.0, .w = 1.0 }, // Blue
.repeat => .{ .x = 1.0, .y = 1.0, .z = 0.4, .w = 1.0 }, // Yellow
};
cimgui.c.ImGui_TextColored(action_text_color, "%s", @tagName(ev.event.action).ptr);
// Key column with consistent key coloring
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
const key_name = switch (ev.event.key) {
.unidentified => if (ev.event.utf8.len > 0) ev.event.utf8 else @tagName(ev.event.key),
else => @tagName(ev.event.key),
};
const key_rgba = keyColor(ev.event.key);
const key_color: cimgui.c.ImVec4 = .{
.x = @as(f32, @floatFromInt(key_rgba & 0xFF)) / 255.0,
.y = @as(f32, @floatFromInt((key_rgba >> 8) & 0xFF)) / 255.0,
.z = @as(f32, @floatFromInt((key_rgba >> 16) & 0xFF)) / 255.0,
.w = 1.0,
};
cimgui.c.ImGui_TextColored(key_color, "%s", key_name.ptr);
// Composing indicator
if (ev.event.composing) {
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.6, .z = 0.0, .w = 1.0 }, "*");
if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None)) {
cimgui.c.ImGui_SetTooltip("Composing (dead key)");
}
}
// Mods
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
mods: {
if (ev.event.mods.empty()) {
cimgui.c.ImGui_TextDisabled("-");
break :mods;
}
var any_hovered = false;
if (ev.event.mods.shift) {
_ = cimgui.c.ImGui_SmallButton("S");
any_hovered = any_hovered or cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None);
cimgui.c.ImGui_SameLine();
}
if (ev.event.mods.ctrl) {
_ = cimgui.c.ImGui_SmallButton("C");
any_hovered = any_hovered or cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None);
cimgui.c.ImGui_SameLine();
}
if (ev.event.mods.alt) {
_ = cimgui.c.ImGui_SmallButton("A");
any_hovered = any_hovered or cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None);
cimgui.c.ImGui_SameLine();
}
if (ev.event.mods.super) {
_ = cimgui.c.ImGui_SmallButton("M");
any_hovered = any_hovered or cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None);
cimgui.c.ImGui_SameLine();
}
cimgui.c.ImGui_NewLine();
if (any_hovered) tooltip: {
var tooltip_buf: [64]u8 = undefined;
const tooltip = modsTooltip(
&ev.event.mods,
&tooltip_buf,
) catch break :tooltip;
cimgui.c.ImGui_SetTooltip("%s", tooltip.ptr);
}
}
// UTF-8
_ = cimgui.c.ImGui_TableSetColumnIndex(3);
if (ev.event.utf8.len == 0) {
cimgui.c.ImGui_TextDisabled("-");
} else {
var utf8_buf: [128]u8 = undefined;
var utf8_stream = std.io.fixedBufferStream(&utf8_buf);
const utf8_writer = utf8_stream.writer();
if (std.unicode.Utf8View.init(ev.event.utf8)) |view| {
var utf8_it = view.iterator();
while (utf8_it.nextCodepoint()) |cp| {
utf8_writer.print("U+{X} ", .{cp}) catch break;
}
} else |_| {
utf8_writer.writeAll("?") catch {};
}
utf8_writer.writeByte(0) catch {};
cimgui.c.ImGui_Text("%s", &utf8_buf);
}
// PTY
_ = cimgui.c.ImGui_TableSetColumnIndex(4);
if (ev.pty.len == 0) {
cimgui.c.ImGui_TextDisabled("-");
} else {
var pty_buf: [256]u8 = undefined;
var pty_stream = std.io.fixedBufferStream(&pty_buf);
const pty_writer = pty_stream.writer();
for (ev.pty) |byte| {
if (byte == 0x1B) {
pty_writer.writeAll("ESC ") catch break;
} else if (byte > 0x20 and byte < 0x7F) {
pty_writer.writeByte(byte) catch break;
} else {
pty_writer.print("0x{X} ", .{byte}) catch break;
}
}
pty_writer.writeByte(0) catch {};
cimgui.c.ImGui_Text("%s", &pty_buf);
}
// Binding
_ = cimgui.c.ImGui_TableSetColumnIndex(5);
if (ev.binding.len == 0) {
cimgui.c.ImGui_TextDisabled("-");
} else {
var binding_buf: [256]u8 = undefined;
var binding_stream = std.io.fixedBufferStream(&binding_buf);
const binding_writer = binding_stream.writer();
for (ev.binding, 0..) |action, i| {
if (i > 0) binding_writer.writeAll(", ") catch break;
binding_writer.writeAll(@tagName(action)) catch break;
}
binding_writer.writeByte(0) catch {};
cimgui.c.ImGui_Text("%s", &binding_buf);
}
// Row hover highlight
const row_max_y = cimgui.c.ImGui_GetCursorScreenPos().y;
const mouse_pos = cimgui.c.ImGui_GetMousePos();
if (mouse_pos.y >= row_min_y and mouse_pos.y < row_max_y) {
cimgui.c.ImGui_TableSetBgColor(cimgui.c.ImGuiTableBgTarget_RowBg1, 0x1AFFFFFF, -1);
}
}
}
};
/// Returns row background color for an action (ABGR format for ImGui)
fn actionColor(action: input.Action) u32 {
return switch (action) {
.press => 0x1A4A6F4A, // Muted sage green
.release => 0x1A6A5A5A, // Muted slate gray
.repeat => 0x1A4A5A6F, // Muted warm brown
};
}
/// Generate a consistent color for a key based on its enum value.
/// Uses HSV color space with fixed saturation and value for pleasing colors.
fn keyColor(key: input.Key) u32 {
const key_int: u32 = @intCast(@intFromEnum(key));
const hue: f32 = @as(f32, @floatFromInt(key_int *% 47)) / 256.0;
return hsvToRgba(hue, 0.5, 0.9, 1.0);
}
/// Convert HSV (hue 0-1, saturation 0-1, value 0-1) to RGBA u32.
fn hsvToRgba(h: f32, s: f32, v: f32, a: f32) u32 {
var r: f32 = undefined;
var g: f32 = undefined;
var b: f32 = undefined;
const i: u32 = @intFromFloat(h * 6.0);
const f = h * 6.0 - @as(f32, @floatFromInt(i));
const p = v * (1.0 - s);
const q = v * (1.0 - f * s);
const t = v * (1.0 - (1.0 - f) * s);
switch (i % 6) {
0 => {
r = v;
g = t;
b = p;
},
1 => {
r = q;
g = v;
b = p;
},
2 => {
r = p;
g = v;
b = t;
},
3 => {
r = p;
g = q;
b = v;
},
4 => {
r = t;
g = p;
b = v;
},
else => {
r = v;
g = p;
b = q;
},
}
const ri: u32 = @intFromFloat(r * 255.0);
const gi: u32 = @intFromFloat(g * 255.0);
const bi: u32 = @intFromFloat(b * 255.0);
const ai: u32 = @intFromFloat(a * 255.0);
return (ai << 24) | (bi << 16) | (gi << 8) | ri;
}

View File

@@ -0,0 +1,428 @@
const std = @import("std");
const cimgui = @import("dcimgui");
const terminal = @import("../../terminal/main.zig");
const units = @import("../units.zig");
const widgets = @import("../widgets.zig");
const PageList = terminal.PageList;
const Page = terminal.Page;
pub fn inspector(page: *const terminal.Page) void {
cimgui.c.ImGui_SeparatorText("Managed Memory");
managedMemory(page);
cimgui.c.ImGui_SeparatorText("Styles");
stylesList(page);
cimgui.c.ImGui_SeparatorText("Hyperlinks");
hyperlinksList(page);
cimgui.c.ImGui_SeparatorText("Rows");
rowsTable(page);
}
/// Draw a tree node header with metadata about this page. Returns if
/// the tree node is open or not. If it is open you must close it with
/// TreePop.
pub fn treeNode(state: struct {
/// The page
page: *const terminal.Page,
/// The index of the page in a page list, used for headers.
index: usize,
/// The range of rows this page covers, inclusive.
row_range: [2]usize,
/// Whether this page is the active or viewport node.
active: bool,
viewport: bool,
}) bool {
// Setup our node.
const open = open: {
var label_buf: [160]u8 = undefined;
const label = std.fmt.bufPrintZ(
&label_buf,
"Page {d}",
.{state.index},
) catch "Page";
const flags = cimgui.c.ImGuiTreeNodeFlags_AllowOverlap |
cimgui.c.ImGuiTreeNodeFlags_SpanFullWidth |
cimgui.c.ImGuiTreeNodeFlags_FramePadding;
break :open cimgui.c.ImGui_TreeNodeEx(label.ptr, flags);
};
// Move our cursor into the tree header so we can add extra info.
const header_min = cimgui.c.ImGui_GetItemRectMin();
const header_max = cimgui.c.ImGui_GetItemRectMax();
const header_height = header_max.y - header_min.y;
const text_line = cimgui.c.ImGui_GetTextLineHeight();
const y_center = header_min.y + (header_height - text_line) * 0.5;
cimgui.c.ImGui_SetCursorScreenPos(.{ .x = header_min.x + 170, .y = y_center });
// Metadata
cimgui.c.ImGui_TextDisabled(
"%dc x %dr",
state.page.size.cols,
state.page.size.rows,
);
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_Text("rows %d..%d", state.row_range[0], state.row_range[1]);
// Labels
if (state.active) {
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.9, .z = 0.4, .w = 1.0 }, "active");
}
if (state.viewport) {
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "viewport");
}
if (state.page.isDirty()) {
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty");
}
return open;
}
pub fn managedMemory(page: *const Page) void {
if (cimgui.c.ImGui_BeginTable(
"##overview",
3,
cimgui.c.ImGuiTableFlags_BordersInnerV |
cimgui.c.ImGuiTableFlags_RowBg |
cimgui.c.ImGuiTableFlags_SizingFixedFit,
)) {
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Memory Size");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker(
"Memory allocated for this page. Note the backing memory " ++
"may be a larger allocation from which this page " ++
"uses a portion.",
);
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text(
"%d KiB",
units.toKibiBytes(page.memory.len),
);
}
if (cimgui.c.ImGui_BeginTable(
"##managed",
4,
cimgui.c.ImGuiTableFlags_BordersInnerV |
cimgui.c.ImGuiTableFlags_RowBg |
cimgui.c.ImGuiTableFlags_SizingFixedFit,
)) {
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableSetupColumn("Resource", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Used", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Capacity", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableHeadersRow();
const size = page.size;
const cap = page.capacity;
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Columns");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Number of columns in the terminal grid.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", size.cols);
_ = cimgui.c.ImGui_TableSetColumnIndex(3);
cimgui.c.ImGui_Text("%d", cap.cols);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Rows");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Number of rows in this page.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", size.rows);
_ = cimgui.c.ImGui_TableSetColumnIndex(3);
cimgui.c.ImGui_Text("%d", cap.rows);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Styles");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Unique text styles (colors, attributes) currently in use.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", page.styles.count());
_ = cimgui.c.ImGui_TableSetColumnIndex(3);
cimgui.c.ImGui_Text("%d", page.styles.layout.cap);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Graphemes");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Extended grapheme clusters for multi-codepoint characters.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", page.graphemeCount());
_ = cimgui.c.ImGui_TableSetColumnIndex(3);
cimgui.c.ImGui_Text("%d", page.graphemeCapacity());
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Strings (bytes)");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("String storage for hyperlink URIs and other text data.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", page.string_alloc.usedBytes(page.memory));
_ = cimgui.c.ImGui_TableSetColumnIndex(3);
cimgui.c.ImGui_Text("%d", page.string_alloc.capacityBytes());
const hyperlink_map = page.hyperlink_map.map(page.memory);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Hyperlink Map");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Maps cell positions to hyperlink IDs.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", hyperlink_map.count());
_ = cimgui.c.ImGui_TableSetColumnIndex(3);
cimgui.c.ImGui_Text("%d", hyperlink_map.capacity());
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Hyperlink IDs");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Unique hyperlink definitions (URI + optional ID).");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", page.hyperlink_set.count());
_ = cimgui.c.ImGui_TableSetColumnIndex(3);
cimgui.c.ImGui_Text("%d", page.hyperlink_set.layout.cap);
}
}
fn rowsTable(page: *const terminal.Page) void {
const visible_rows: usize = @min(page.size.rows, 12);
const row_height: f32 = cimgui.c.ImGui_GetTextLineHeightWithSpacing();
const child_height: f32 = row_height * (@as(f32, @floatFromInt(visible_rows)) + 2.0);
// Child window so scrolling is separate.
// This defer first is not a bug, EndChild always needs to be called.
defer cimgui.c.ImGui_EndChild();
if (!cimgui.c.ImGui_BeginChild(
"##page_rows",
.{ .x = 0.0, .y = child_height },
cimgui.c.ImGuiChildFlags_Borders,
cimgui.c.ImGuiWindowFlags_None,
)) return;
if (!cimgui.c.ImGui_BeginTable(
"##page_rows_table",
10,
cimgui.c.ImGuiTableFlags_BordersInnerV |
cimgui.c.ImGuiTableFlags_RowBg |
cimgui.c.ImGuiTableFlags_SizingFixedFit,
)) return;
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableSetupScrollFreeze(0, 1);
cimgui.c.ImGui_TableSetupColumn("Row", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Text", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Dirty", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Wrap", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Cont", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Styled", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Grapheme", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Link", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Prompt", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Kitty", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableHeadersRow();
const rows = page.rows.ptr(page.memory)[0..page.size.rows];
for (rows, 0..) |*row, row_index| {
var text_cells: usize = 0;
const cells = page.getCells(row);
for (cells) |cell| {
if (cell.hasText()) {
text_cells += 1;
}
}
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("%d", row_index);
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (text_cells == 0) {
cimgui.c.ImGui_TextDisabled("0");
} else {
cimgui.c.ImGui_Text("%d", text_cells);
}
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
flagCell(row.dirty);
_ = cimgui.c.ImGui_TableSetColumnIndex(3);
flagCell(row.wrap);
_ = cimgui.c.ImGui_TableSetColumnIndex(4);
flagCell(row.wrap_continuation);
_ = cimgui.c.ImGui_TableSetColumnIndex(5);
flagCell(row.styled);
_ = cimgui.c.ImGui_TableSetColumnIndex(6);
flagCell(row.grapheme);
_ = cimgui.c.ImGui_TableSetColumnIndex(7);
flagCell(row.hyperlink);
_ = cimgui.c.ImGui_TableSetColumnIndex(8);
cimgui.c.ImGui_Text("%s", @tagName(row.semantic_prompt).ptr);
_ = cimgui.c.ImGui_TableSetColumnIndex(9);
flagCell(row.kitty_virtual_placeholder);
}
}
fn stylesList(page: *const Page) void {
const items = page.styles.items.ptr(page.memory)[0..page.styles.layout.cap];
var count: usize = 0;
for (items, 0..) |item, index| {
if (index == 0) continue;
if (item.meta.ref == 0) continue;
count += 1;
}
if (count == 0) {
cimgui.c.ImGui_TextDisabled("(no styles in use)");
return;
}
const visible_rows: usize = @min(count, 8);
const row_height: f32 = cimgui.c.ImGui_GetTextLineHeightWithSpacing();
const child_height: f32 = row_height * (@as(f32, @floatFromInt(visible_rows)) + 2.0);
defer cimgui.c.ImGui_EndChild();
if (!cimgui.c.ImGui_BeginChild(
"##page_styles",
.{ .x = 0.0, .y = child_height },
cimgui.c.ImGuiChildFlags_Borders,
cimgui.c.ImGuiWindowFlags_None,
)) return;
if (!cimgui.c.ImGui_BeginTable(
"##page_styles_table",
3,
cimgui.c.ImGuiTableFlags_BordersInnerV |
cimgui.c.ImGuiTableFlags_RowBg |
cimgui.c.ImGuiTableFlags_SizingFixedFit,
)) return;
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableSetupScrollFreeze(0, 1);
cimgui.c.ImGui_TableSetupColumn("ID", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Refs", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Style", cimgui.c.ImGuiTableColumnFlags_WidthStretch);
cimgui.c.ImGui_TableHeadersRow();
for (items, 0..) |item, index| {
if (index == 0) continue;
if (item.meta.ref == 0) continue;
cimgui.c.ImGui_TableNextRow();
cimgui.c.ImGui_PushIDInt(@intCast(index));
defer cimgui.c.ImGui_PopID();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("%d", index);
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d", item.meta.ref);
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
if (cimgui.c.ImGui_TreeNodeEx("Details", cimgui.c.ImGuiTreeNodeFlags_None)) {
defer cimgui.c.ImGui_TreePop();
widgets.style.table(item.value, null);
}
}
}
fn hyperlinksList(page: *const Page) void {
const items = page.hyperlink_set.items.ptr(page.memory)[0..page.hyperlink_set.layout.cap];
var count: usize = 0;
for (items, 0..) |item, index| {
if (index == 0) continue;
if (item.meta.ref == 0) continue;
count += 1;
}
if (count == 0) {
cimgui.c.ImGui_TextDisabled("(no hyperlinks in use)");
return;
}
const visible_rows: usize = @min(count, 8);
const row_height: f32 = cimgui.c.ImGui_GetTextLineHeightWithSpacing();
const child_height: f32 = row_height * (@as(f32, @floatFromInt(visible_rows)) + 2.0);
defer cimgui.c.ImGui_EndChild();
if (!cimgui.c.ImGui_BeginChild(
"##page_hyperlinks",
.{ .x = 0.0, .y = child_height },
cimgui.c.ImGuiChildFlags_Borders,
cimgui.c.ImGuiWindowFlags_None,
)) return;
if (!cimgui.c.ImGui_BeginTable(
"##page_hyperlinks_table",
4,
cimgui.c.ImGuiTableFlags_BordersInnerV |
cimgui.c.ImGuiTableFlags_RowBg |
cimgui.c.ImGuiTableFlags_SizingFixedFit,
)) return;
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableSetupScrollFreeze(0, 1);
cimgui.c.ImGui_TableSetupColumn("ID", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Refs", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Explicit ID", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("URI", cimgui.c.ImGuiTableColumnFlags_WidthStretch);
cimgui.c.ImGui_TableHeadersRow();
for (items, 0..) |item, index| {
if (index == 0) continue;
if (item.meta.ref == 0) continue;
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("%d", index);
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d", item.meta.ref);
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
switch (item.value.id) {
.explicit => |slice| {
const explicit_id = slice.slice(page.memory);
cimgui.c.ImGui_Text("%.*s", explicit_id.len, explicit_id.ptr);
},
.implicit => cimgui.c.ImGui_TextDisabled("-"),
}
_ = cimgui.c.ImGui_TableSetColumnIndex(3);
const uri = item.value.uri.slice(page.memory);
cimgui.c.ImGui_Text("%.*s", uri.len, uri.ptr);
}
}
fn flagCell(value: bool) void {
if (value) {
cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.9, .z = 0.4, .w = 1.0 }, "yes");
} else {
cimgui.c.ImGui_TextDisabled("-");
}
}

View File

@@ -0,0 +1,852 @@
const std = @import("std");
const cimgui = @import("dcimgui");
const terminal = @import("../../terminal/main.zig");
const stylepkg = @import("../../terminal/style.zig");
const widgets = @import("../widgets.zig");
const units = @import("../units.zig");
const PageList = terminal.PageList;
/// PageList inspector widget.
pub const Inspector = struct {
pub const empty: Inspector = .{};
pub fn draw(_: *const Inspector, pages: *PageList) void {
cimgui.c.ImGui_TextWrapped(
"PageList manages the backing pages that hold scrollback and the active " ++
"terminal grid. Each page is a contiguous memory buffer with its " ++
"own rows, cells, style set, grapheme map, and hyperlink storage.",
);
if (cimgui.c.ImGui_CollapsingHeader(
"Overview",
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
)) {
summaryTable(pages);
}
if (cimgui.c.ImGui_CollapsingHeader(
"Scrollbar & Regions",
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
)) {
cimgui.c.ImGui_SeparatorText("Scrollbar");
scrollbarInfo(pages);
cimgui.c.ImGui_SeparatorText("Regions");
regionsTable(pages);
}
if (cimgui.c.ImGui_CollapsingHeader(
"Tracked Pins",
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
)) {
trackedPinsTable(pages);
}
if (cimgui.c.ImGui_CollapsingHeader(
"Pages",
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
)) {
widgets.helpMarker(
"Pages are shown most-recent first. Each page holds a grid of rows/cells " ++
"plus metadata tables for styles, graphemes, strings, and hyperlinks.",
);
const active_pin = pages.getTopLeft(.active);
const viewport_pin = pages.getTopLeft(.viewport);
var row_offset = pages.total_rows;
var index: usize = pages.totalPages();
var node = pages.pages.last;
while (node) |page_node| : (node = page_node.prev) {
const page = &page_node.data;
row_offset -= page.size.rows;
index -= 1;
// We use our location as the ID so that even if reallocations
// happen we remain open if we're open already.
cimgui.c.ImGui_PushIDInt(@intCast(index));
defer cimgui.c.ImGui_PopID();
// Open up the tree node.
if (!widgets.page.treeNode(.{
.page = page,
.index = index,
.row_range = .{ row_offset, row_offset + page.size.rows - 1 },
.active = node == active_pin.node,
.viewport = node == viewport_pin.node,
})) continue;
defer cimgui.c.ImGui_TreePop();
widgets.page.inspector(page);
}
}
}
};
fn summaryTable(pages: *const PageList) void {
if (!cimgui.c.ImGui_BeginTable(
"pagelist_summary",
3,
cimgui.c.ImGuiTableFlags_BordersInnerV |
cimgui.c.ImGuiTableFlags_RowBg |
cimgui.c.ImGuiTableFlags_SizingFixedFit,
)) return;
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Active Grid");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Active viewport size in columns x rows.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%dc x %dr", pages.cols, pages.rows);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Pages");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Total number of pages in the linked list.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", pages.totalPages());
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Total Rows");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Total rows represented by scrollback + active area.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", pages.total_rows);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Page Bytes");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Total bytes allocated for active pages.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text(
"%d KiB",
units.toKibiBytes(pages.page_size),
);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Max Size");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker(
\\Maximum bytes before pages must be evicated. The total
\\used bytes may be higher due to minimum individual page
\\sizes but the next allocation that would exceed this limit
\\will evict pages from the front of the list to free up space.
);
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text(
"%d KiB",
units.toKibiBytes(pages.maxSize()),
);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Viewport");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Current viewport anchoring mode.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Tracked Pins");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Number of pins tracked for automatic updates.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", pages.countTrackedPins());
}
fn scrollbarInfo(pages: *PageList) void {
const scrollbar = pages.scrollbar();
// If we have a scrollbar, show it.
if (scrollbar.total > 0) {
var delta_row: isize = 0;
scrollbarWidget(&scrollbar, &delta_row);
if (delta_row != 0) {
pages.scroll(.{ .delta_row = delta_row });
}
}
if (!cimgui.c.ImGui_BeginTable(
"scrollbar_info",
3,
cimgui.c.ImGuiTableFlags_BordersInnerV |
cimgui.c.ImGuiTableFlags_RowBg |
cimgui.c.ImGuiTableFlags_SizingFixedFit,
)) return;
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Total");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Total number of scrollable rows including scrollback and active area.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", scrollbar.total);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Offset");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Current scroll position as row offset from the top of scrollback.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", scrollbar.offset);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Length");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Number of rows visible in the viewport.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", scrollbar.len);
}
fn regionsTable(pages: *PageList) void {
if (!cimgui.c.ImGui_BeginTable(
"pagelist_regions",
4,
cimgui.c.ImGuiTableFlags_BordersInnerV |
cimgui.c.ImGuiTableFlags_RowBg |
cimgui.c.ImGuiTableFlags_SizingFixedFit,
)) return;
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableSetupColumn("Region", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Top-Left", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Bottom-Right", cimgui.c.ImGuiTableColumnFlags_WidthStretch);
cimgui.c.ImGui_TableHeadersRow();
inline for (comptime std.meta.tags(terminal.point.Tag)) |tag| {
regionRow(pages, tag);
}
}
fn regionRow(pages: *const PageList, comptime tag: terminal.point.Tag) void {
const tl_pin = pages.getTopLeft(tag);
const br_pin = pages.getBottomRight(tag);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("%s", @tagName(tag).ptr);
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker(comptime regionHelpText(tag));
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
if (pages.pointFromPin(tag, tl_pin)) |pt| {
const coord = pt.coord();
cimgui.c.ImGui_Text("(%d, %d)", coord.x, coord.y);
} else {
cimgui.c.ImGui_TextDisabled("(n/a)");
}
_ = cimgui.c.ImGui_TableSetColumnIndex(3);
if (br_pin) |br| {
if (pages.pointFromPin(tag, br)) |pt| {
const coord = pt.coord();
cimgui.c.ImGui_Text("(%d, %d)", coord.x, coord.y);
} else {
cimgui.c.ImGui_TextDisabled("(n/a)");
}
} else {
cimgui.c.ImGui_TextDisabled("(empty)");
}
}
fn regionHelpText(comptime tag: terminal.point.Tag) [:0]const u8 {
return switch (tag) {
.active => "The active area where a running program can jump the cursor " ++
"and make changes. This is the 'editable' part of the screen. " ++
"Bottom-right includes the full height of the screen, including " ++
"rows that may not be written yet.",
.viewport => "The visible viewport. If the user has scrolled, top-left changes. " ++
"Bottom-right is the last written row from the top-left.",
.screen => "Top-left is the furthest back in scrollback history. Bottom-right " ++
"is the last written row. Unlike 'active', this only contains " ++
"written rows.",
.history => "Same top-left as 'screen' but bottom-right is the line just before " ++
"the top of 'active'. Contains only the scrollback history.",
};
}
fn trackedPinsTable(pages: *const PageList) void {
if (!cimgui.c.ImGui_BeginTable(
"tracked_pins",
5,
cimgui.c.ImGuiTableFlags_Borders |
cimgui.c.ImGuiTableFlags_RowBg |
cimgui.c.ImGuiTableFlags_SizingFixedFit,
)) return;
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableSetupColumn("Index", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Pin", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Context", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Dirty", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("State", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableHeadersRow();
const active_pin = pages.getTopLeft(.active);
const viewport_pin = pages.getTopLeft(.viewport);
for (pages.trackedPins(), 0..) |tracked, idx| {
const pin = tracked.*;
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("%d", idx);
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (pin.garbage) {
cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.5, .z = 0.3, .w = 1.0 }, "(%d, %d)", pin.x, pin.y);
} else {
cimgui.c.ImGui_Text("(%d, %d)", pin.x, pin.y);
}
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
if (pages.pointFromPin(.screen, pin)) |pt| {
const coord = pt.coord();
cimgui.c.ImGui_Text(
"screen (%d, %d)",
coord.x,
coord.y,
);
} else {
cimgui.c.ImGui_TextDisabled("screen (out of range)");
}
_ = cimgui.c.ImGui_TableSetColumnIndex(3);
const dirty = pin.isDirty();
if (dirty) {
cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty");
} else {
cimgui.c.ImGui_TextDisabled("clean");
}
_ = cimgui.c.ImGui_TableSetColumnIndex(4);
if (pin.eql(active_pin)) {
cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.9, .z = 0.4, .w = 1.0 }, "active top");
} else if (pin.eql(viewport_pin)) {
cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "viewport top");
} else if (pin.garbage) {
cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.5, .z = 0.3, .w = 1.0 }, "garbage");
} else if (tracked == pages.viewport_pin) {
cimgui.c.ImGui_Text("viewport pin");
} else {
cimgui.c.ImGui_TextDisabled("tracked");
}
}
}
fn scrollbarWidget(
scrollbar: *const PageList.Scrollbar,
delta_row: *isize,
) void {
delta_row.* = 0;
const avail_width = cimgui.c.ImGui_GetContentRegionAvail().x;
const bar_height: f32 = cimgui.c.ImGui_GetFrameHeight();
const cursor_pos = cimgui.c.ImGui_GetCursorScreenPos();
const total_f: f32 = @floatFromInt(scrollbar.total);
const offset_f: f32 = @floatFromInt(scrollbar.offset);
const len_f: f32 = @floatFromInt(scrollbar.len);
const grab_start = (offset_f / total_f) * avail_width;
const grab_width = @max((len_f / total_f) * avail_width, 4.0);
const draw_list = cimgui.c.ImGui_GetWindowDrawList();
const bg_color = cimgui.c.ImGui_GetColorU32(cimgui.c.ImGuiCol_ScrollbarBg);
const grab_color = cimgui.c.ImGui_GetColorU32(cimgui.c.ImGuiCol_ScrollbarGrab);
const bg_min: cimgui.c.ImVec2 = cursor_pos;
const bg_max: cimgui.c.ImVec2 = .{ .x = cursor_pos.x + avail_width, .y = cursor_pos.y + bar_height };
cimgui.c.ImDrawList_AddRectFilledEx(
draw_list,
bg_min,
bg_max,
bg_color,
0,
0,
);
const grab_min: cimgui.c.ImVec2 = .{
.x = cursor_pos.x + grab_start,
.y = cursor_pos.y,
};
const grab_max: cimgui.c.ImVec2 = .{
.x = cursor_pos.x + grab_start + grab_width,
.y = cursor_pos.y + bar_height,
};
cimgui.c.ImDrawList_AddRectFilledEx(
draw_list,
grab_min,
grab_max,
grab_color,
0,
0,
);
_ = cimgui.c.ImGui_InvisibleButton(
"scrollbar_drag",
.{ .x = avail_width, .y = bar_height },
0,
);
if (cimgui.c.ImGui_IsItemActive()) {
const drag_delta = cimgui.c.ImGui_GetMouseDragDelta(
cimgui.c.ImGuiMouseButton_Left,
0.0,
);
if (drag_delta.x != 0) {
const row_delta = (drag_delta.x / avail_width) * total_f;
delta_row.* = @intFromFloat(row_delta);
cimgui.c.ImGui_ResetMouseDragDelta();
}
}
if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) {
cimgui.c.ImGui_SetTooltip(
"offset=%d len=%d total=%d",
scrollbar.offset,
scrollbar.len,
scrollbar.total,
);
}
}
/// Grid inspector widget for choosing and inspecting a specific cell.
pub const CellChooser = struct {
lookup_region: terminal.point.Tag,
lookup_coord: terminal.point.Coordinate,
cell_info: CellInfo,
pub const empty: CellChooser = .{
.lookup_region = .viewport,
.lookup_coord = .{ .x = 0, .y = 0 },
.cell_info = .empty,
};
pub fn draw(
self: *CellChooser,
pages: *const PageList,
) void {
cimgui.c.ImGui_TextWrapped(
"Inspect a cell by choosing a coordinate space and entering the X/Y position. " ++
"The inspector resolves the point into the page list and displays the cell contents.",
);
cimgui.c.ImGui_SeparatorText("Cell Inspector");
const region_max = maxCoord(pages, self.lookup_region);
if (region_max) |coord| {
self.lookup_coord.x = @min(self.lookup_coord.x, coord.x);
self.lookup_coord.y = @min(self.lookup_coord.y, coord.y);
} else {
self.lookup_coord = .{ .x = 0, .y = 0 };
}
{
const disabled = region_max == null;
cimgui.c.ImGui_BeginDisabled(disabled);
defer cimgui.c.ImGui_EndDisabled();
const preview = @tagName(self.lookup_region);
const combo_width = comptime blk: {
var max_len: usize = 0;
for (std.meta.tags(terminal.point.Tag)) |tag| {
max_len = @max(max_len, @tagName(tag).len);
}
break :blk max_len + 4;
};
cimgui.c.ImGui_SetNextItemWidth(cimgui.c.ImGui_CalcTextSize("X" ** combo_width).x);
if (cimgui.c.ImGui_BeginCombo(
"##grid_region",
preview.ptr,
cimgui.c.ImGuiComboFlags_HeightSmall,
)) {
inline for (comptime std.meta.tags(terminal.point.Tag)) |tag| {
const selected = tag == self.lookup_region;
if (cimgui.c.ImGui_SelectableEx(
@tagName(tag).ptr,
selected,
cimgui.c.ImGuiSelectableFlags_None,
.{ .x = 0, .y = 0 },
)) {
self.lookup_region = tag;
}
if (selected) cimgui.c.ImGui_SetItemDefaultFocus();
}
cimgui.c.ImGui_EndCombo();
}
cimgui.c.ImGui_SameLine();
const width = cimgui.c.ImGui_CalcTextSize("00000").x;
var x_value: terminal.size.CellCountInt = self.lookup_coord.x;
var y_value: u32 = self.lookup_coord.y;
var changed = false;
cimgui.c.ImGui_AlignTextToFramePadding();
cimgui.c.ImGui_Text("x:");
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_SetNextItemWidth(width);
if (cimgui.c.ImGui_InputScalar(
"##grid_x",
cimgui.c.ImGuiDataType_U16,
&x_value,
)) changed = true;
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_AlignTextToFramePadding();
cimgui.c.ImGui_Text("y:");
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_SetNextItemWidth(width);
if (cimgui.c.ImGui_InputScalar(
"##grid_y",
cimgui.c.ImGuiDataType_U32,
&y_value,
)) changed = true;
cimgui.c.ImGui_SameLine();
widgets.helpMarker("Choose the coordinate space and X/Y position (0-indexed).");
if (changed) {
if (region_max) |coord| {
self.lookup_coord.x = @min(x_value, coord.x);
self.lookup_coord.y = @min(y_value, coord.y);
}
}
}
if (region_max) |coord| {
cimgui.c.ImGui_TextDisabled(
"Range: x 0..%d, y 0..%d",
coord.x,
coord.y,
);
} else {
cimgui.c.ImGui_TextDisabled("(region has no rows)");
return;
}
const pt = switch (self.lookup_region) {
.active => terminal.Point{ .active = self.lookup_coord },
.viewport => terminal.Point{ .viewport = self.lookup_coord },
.screen => terminal.Point{ .screen = self.lookup_coord },
.history => terminal.Point{ .history = self.lookup_coord },
};
const cell = pages.getCell(pt) orelse {
cimgui.c.ImGui_TextDisabled("(cell out of range)");
return;
};
self.cell_info.draw(cell, pt);
if (cell.cell.style_id != stylepkg.default_id) {
cimgui.c.ImGui_SeparatorText("Style");
const style = cell.node.data.styles.get(
cell.node.data.memory,
cell.cell.style_id,
).*;
widgets.style.table(style, null);
}
if (cell.cell.hyperlink) {
cimgui.c.ImGui_SeparatorText("Hyperlink");
hyperlinkTable(cell);
}
if (cell.cell.hasGrapheme()) {
cimgui.c.ImGui_SeparatorText("Grapheme");
graphemeTable(cell);
}
}
};
fn maxCoord(
pages: *const PageList,
tag: terminal.point.Tag,
) ?terminal.point.Coordinate {
const br_pin = pages.getBottomRight(tag) orelse return null;
const br_point = pages.pointFromPin(tag, br_pin) orelse return null;
return br_point.coord();
}
fn hyperlinkTable(cell: PageList.Cell) void {
if (!cimgui.c.ImGui_BeginTable(
"cell_hyperlink",
2,
cimgui.c.ImGuiTableFlags_None,
)) return;
defer cimgui.c.ImGui_EndTable();
const page = &cell.node.data;
const link_id = page.lookupHyperlink(cell.cell) orelse {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Status");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_TextDisabled("(missing link data)");
return;
};
const entry = page.hyperlink_set.get(page.memory, link_id);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("ID");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
switch (entry.id) {
.implicit => |value| cimgui.c.ImGui_Text("implicit %d", value),
.explicit => |slice| {
const id = slice.slice(page.memory);
if (id.len == 0) {
cimgui.c.ImGui_TextDisabled("(empty)");
} else {
cimgui.c.ImGui_Text("%.*s", id.len, id.ptr);
}
},
}
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("URI");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
const uri = entry.uri.slice(page.memory);
if (uri.len == 0) {
cimgui.c.ImGui_TextDisabled("(empty)");
} else {
cimgui.c.ImGui_Text("%.*s", uri.len, uri.ptr);
}
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Ref Count");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
const refs = page.hyperlink_set.refCount(page.memory, link_id);
cimgui.c.ImGui_Text("%d", refs);
}
fn graphemeTable(cell: PageList.Cell) void {
if (!cimgui.c.ImGui_BeginTable(
"cell_grapheme",
2,
cimgui.c.ImGuiTableFlags_None,
)) return;
defer cimgui.c.ImGui_EndTable();
const page = &cell.node.data;
const cps = page.lookupGrapheme(cell.cell) orelse {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Status");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_TextDisabled("(missing grapheme data)");
return;
};
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Extra Codepoints");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (cps.len == 0) {
cimgui.c.ImGui_TextDisabled("(none)");
return;
}
var buf: [96]u8 = undefined;
if (cimgui.c.ImGui_BeginListBox("##grapheme_list", .{ .x = 0, .y = 0 })) {
defer cimgui.c.ImGui_EndListBox();
for (cps) |cp| {
const label = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch "U+?";
_ = cimgui.c.ImGui_SelectableEx(
label.ptr,
false,
cimgui.c.ImGuiSelectableFlags_None,
.{ .x = 0, .y = 0 },
);
}
}
}
/// Cell inspector widget.
pub const CellInfo = struct {
pub const empty: CellInfo = .{};
pub fn draw(
_: *const CellInfo,
cell: PageList.Cell,
point: terminal.Point,
) void {
if (!cimgui.c.ImGui_BeginTable(
"cell_info",
3,
cimgui.c.ImGuiTableFlags_BordersInnerV |
cimgui.c.ImGuiTableFlags_RowBg |
cimgui.c.ImGuiTableFlags_SizingFixedFit,
)) return;
defer cimgui.c.ImGui_EndTable();
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Grid Position");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("The cell's X/Y coordinates in the selected region.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
const coord = point.coord();
cimgui.c.ImGui_Text("(%d, %d)", coord.x, coord.y);
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Page Location");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Row and column indices within the backing page.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("row=%d col=%d", cell.row_idx, cell.col_idx);
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Content");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Content tag describing how the cell data is stored.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%s", @tagName(cell.cell.content_tag).ptr);
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Codepoint");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Primary Unicode codepoint for the cell.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
const cp = cell.cell.codepoint();
if (cp == 0) {
cimgui.c.ImGui_TextDisabled("(empty)");
} else {
cimgui.c.ImGui_Text("U+%04X", @as(u32, cp));
}
}
if (cell.cell.hasGrapheme()) {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Grapheme");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Extra codepoints that combine with the primary codepoint to form the grapheme cluster.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
if (cimgui.c.ImGui_BeginListBox("##cell_grapheme", .{ .x = 0, .y = 0 })) {
defer cimgui.c.ImGui_EndListBox();
if (cell.node.data.lookupGrapheme(cell.cell)) |cps| {
var buf: [96]u8 = undefined;
for (cps) |cp| {
const label = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch "U+?";
_ = cimgui.c.ImGui_SelectableEx(
label.ptr,
false,
cimgui.c.ImGuiSelectableFlags_None,
.{ .x = 0, .y = 0 },
);
}
} else {
_ = cimgui.c.ImGui_SelectableEx(
"(missing)",
false,
cimgui.c.ImGuiSelectableFlags_None,
.{ .x = 0, .y = 0 },
);
}
}
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Width Property");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Character width property (narrow, wide, spacer, etc.).");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%s", @tagName(cell.cell.wide).ptr);
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Row Flags");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Flags set on the row containing this cell.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
const row = cell.row;
if (row.wrap or row.wrap_continuation or row.grapheme or row.styled or row.hyperlink) {
if (row.wrap) {
cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "wrap");
cimgui.c.ImGui_SameLine();
}
if (row.wrap_continuation) {
cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "cont");
cimgui.c.ImGui_SameLine();
}
if (row.grapheme) {
cimgui.c.ImGui_TextColored(.{ .x = 0.9, .y = 0.7, .z = 0.3, .w = 1.0 }, "grapheme");
cimgui.c.ImGui_SameLine();
}
if (row.styled) {
cimgui.c.ImGui_TextColored(.{ .x = 0.7, .y = 0.9, .z = 0.5, .w = 1.0 }, "styled");
cimgui.c.ImGui_SameLine();
}
if (row.hyperlink) {
cimgui.c.ImGui_TextColored(.{ .x = 0.8, .y = 0.6, .z = 1.0, .w = 1.0 }, "link");
cimgui.c.ImGui_SameLine();
}
} else {
cimgui.c.ImGui_TextDisabled("(none)");
}
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Style ID");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Internal style reference ID for this cell.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_Text("%d", cell.cell.style_id);
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Style");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("Resolved style for the cell (colors, attributes, etc.).");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
if (cell.cell.style_id == stylepkg.default_id) {
cimgui.c.ImGui_TextDisabled("(default)");
} else {
cimgui.c.ImGui_TextDisabled("(see below)");
}
}
if (cell.cell.hyperlink) {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Hyperlink");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
widgets.helpMarker("OSC8 hyperlink ID associated with this cell.");
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
const link_id = cell.node.data.lookupHyperlink(cell.cell) orelse 0;
cimgui.c.ImGui_Text("id=%d", link_id);
}
}
};

View File

@@ -0,0 +1,123 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const cimgui = @import("dcimgui");
const widgets = @import("../widgets.zig");
const renderer = @import("../../renderer.zig");
const log = std.log.scoped(.inspector_renderer);
/// Renderer information inspector widget.
pub const Info = struct {
features: std.AutoArrayHashMapUnmanaged(
std.meta.Tag(renderer.Overlay.Feature),
renderer.Overlay.Feature,
),
pub const empty: Info = .{
.features = .empty,
};
pub fn deinit(self: *Info, alloc: Allocator) void {
self.features.deinit(alloc);
}
/// Grab the features into a new allocated slice. This is used by
pub fn overlayFeatures(
self: *const Info,
alloc: Allocator,
) Allocator.Error![]renderer.Overlay.Feature {
// The features from our internal state.
const features = self.features.values();
// For now we do a dumb copy since the features have no managed
// memory.
const result = try alloc.dupe(
renderer.Overlay.Feature,
features,
);
errdefer alloc.free(result);
return result;
}
/// Draw the renderer info window.
pub fn draw(
self: *Info,
alloc: Allocator,
open: bool,
) void {
if (!open) return;
cimgui.c.ImGui_SetNextItemOpen(true, cimgui.c.ImGuiCond_Once);
if (!cimgui.c.ImGui_CollapsingHeader("Overlays", cimgui.c.ImGuiTreeNodeFlags_None)) return;
cimgui.c.ImGui_SeparatorText("Hyperlinks");
self.overlayHyperlinks(alloc);
cimgui.c.ImGui_SeparatorText("Semantic Prompts");
self.overlaySemanticPrompts(alloc);
}
fn overlayHyperlinks(self: *Info, alloc: Allocator) void {
var hyperlinks: bool = self.features.contains(.highlight_hyperlinks);
_ = cimgui.c.ImGui_Checkbox("Overlay Hyperlinks", &hyperlinks);
cimgui.c.ImGui_SameLine();
widgets.helpMarker("When enabled, highlights OSC8 hyperlinks.");
if (!hyperlinks) {
_ = self.features.swapRemove(.highlight_hyperlinks);
} else {
self.features.put(
alloc,
.highlight_hyperlinks,
.highlight_hyperlinks,
) catch log.warn("error enabling hyperlink overlay feature", .{});
}
}
fn overlaySemanticPrompts(self: *Info, alloc: Allocator) void {
var semantic_prompts: bool = self.features.contains(.semantic_prompts);
_ = cimgui.c.ImGui_Checkbox("Overlay Semantic Prompts", &semantic_prompts);
cimgui.c.ImGui_SameLine();
widgets.helpMarker("When enabled, highlights OSC 133 semantic prompts.");
// Handle the checkbox results
if (!semantic_prompts) {
_ = self.features.swapRemove(.semantic_prompts);
} else {
self.features.put(
alloc,
.semantic_prompts,
.semantic_prompts,
) catch log.warn("error enabling semantic prompt overlay feature", .{});
}
// Help
cimgui.c.ImGui_Indent();
defer cimgui.c.ImGui_Unindent();
cimgui.c.ImGui_TextDisabled("Colors:");
const prompt_rgb = renderer.Overlay.Color.semantic_prompt.rgb();
const input_rgb = renderer.Overlay.Color.semantic_input.rgb();
const prompt_col: cimgui.c.ImVec4 = .{
.x = @as(f32, @floatFromInt(prompt_rgb.r)) / 255.0,
.y = @as(f32, @floatFromInt(prompt_rgb.g)) / 255.0,
.z = @as(f32, @floatFromInt(prompt_rgb.b)) / 255.0,
.w = 1.0,
};
const input_col: cimgui.c.ImVec4 = .{
.x = @as(f32, @floatFromInt(input_rgb.r)) / 255.0,
.y = @as(f32, @floatFromInt(input_rgb.g)) / 255.0,
.z = @as(f32, @floatFromInt(input_rgb.b)) / 255.0,
.w = 1.0,
};
_ = cimgui.c.ImGui_ColorButton("##prompt_color", prompt_col, cimgui.c.ImGuiColorEditFlags_NoTooltip);
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_Text("Prompt");
_ = cimgui.c.ImGui_ColorButton("##input_color", input_col, cimgui.c.ImGuiColorEditFlags_NoTooltip);
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_Text("Input");
}
};

View File

@@ -0,0 +1,400 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = @import("../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const cimgui = @import("dcimgui");
const widgets = @import("../widgets.zig");
const units = @import("../units.zig");
const terminal = @import("../../terminal/main.zig");
const stylepkg = @import("../../terminal/style.zig");
/// Window names for the screen dockspace.
const window_info = "Info";
const window_cell = "Cell";
const window_pagelist = "PageList";
/// Screen information inspector widget.
pub const Info = struct {
pagelist: widgets.pagelist.Inspector,
cell_chooser: widgets.pagelist.CellChooser,
pub const empty: Info = .{
.pagelist = .empty,
.cell_chooser = .empty,
};
/// Draw the screen info contents.
pub fn draw(self: *Info, open: bool, data: struct {
/// The screen that we're inspecting.
screen: *terminal.Screen,
/// Which screen key we're viewing.
key: terminal.ScreenSet.Key,
/// Which screen is active (primary or alternate).
active_key: terminal.ScreenSet.Key,
/// Whether xterm modify other keys mode 2 is enabled.
modify_other_keys_2: bool,
/// Color palette for cursor color resolution.
color_palette: *const terminal.color.DynamicPalette,
}) void {
// Create the dockspace for this screen
const dockspace_id = cimgui.c.ImGui_GetID("Screen Dockspace");
_ = createDockSpace(dockspace_id);
const screen = data.screen;
// Info window
info: {
defer cimgui.c.ImGui_End();
if (!cimgui.c.ImGui_Begin(
window_info,
null,
cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing,
)) break :info;
if (cimgui.c.ImGui_CollapsingHeader(
"Cursor",
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
)) {
cursorTable(&screen.cursor);
cimgui.c.ImGui_Separator();
cursorStyle(
&screen.cursor,
&data.color_palette.current,
);
}
if (cimgui.c.ImGui_CollapsingHeader(
"Keyboard",
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
)) keyboardTable(
screen,
data.modify_other_keys_2,
);
if (cimgui.c.ImGui_CollapsingHeader(
"Semantic Prompt",
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
)) semanticPromptTable(&screen.semantic_prompt);
if (cimgui.c.ImGui_CollapsingHeader(
"Kitty Graphics",
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
)) kittyGraphicsTable(&screen.kitty_images);
if (cimgui.c.ImGui_CollapsingHeader(
"Other Screen State",
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
)) internalStateTable(screen);
}
// Cell window
cell: {
defer cimgui.c.ImGui_End();
if (!cimgui.c.ImGui_Begin(
window_cell,
null,
cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing,
)) break :cell;
self.cell_chooser.draw(&screen.pages);
}
// PageList window
pagelist: {
defer cimgui.c.ImGui_End();
if (!cimgui.c.ImGui_Begin(
window_pagelist,
null,
cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing,
)) break :pagelist;
self.pagelist.draw(&screen.pages);
}
// The remainder is the open state
if (!open) return;
// Show warning if viewing an inactive screen
if (data.key != data.active_key) {
cimgui.c.ImGui_TextColored(
.{ .x = 1.0, .y = 0.8, .z = 0.0, .w = 1.0 },
"⚠ Viewing inactive screen",
);
cimgui.c.ImGui_Separator();
}
}
/// Create the dock space for the screen inspector. This creates
/// a dedicated dock space for the screen inspector windows. But they
/// can of course be undocked and moved around as desired.
fn createDockSpace(dockspace_id: cimgui.c.ImGuiID) bool {
// Check if we need to set up the dockspace
const setup = cimgui.ImGui_DockBuilderGetNode(dockspace_id) == null;
if (setup) {
// Register our dockspace node
assert(cimgui.ImGui_DockBuilderAddNodeEx(
dockspace_id,
cimgui.ImGuiDockNodeFlagsPrivate.DockSpace,
) == dockspace_id);
// Dock windows into the space
cimgui.ImGui_DockBuilderDockWindow(window_info, dockspace_id);
cimgui.ImGui_DockBuilderDockWindow(window_cell, dockspace_id);
cimgui.ImGui_DockBuilderDockWindow(window_pagelist, dockspace_id);
cimgui.ImGui_DockBuilderFinish(dockspace_id);
}
// Create the dockspace
assert(cimgui.c.ImGui_DockSpaceEx(
dockspace_id,
.{ .x = 0, .y = 0 },
cimgui.c.ImGuiDockNodeFlags_None,
null,
) == dockspace_id);
return setup;
}
};
/// Render cursor state with a table of cursor-specific fields.
pub fn cursorTable(
cursor: *const terminal.Screen.Cursor,
) void {
if (!cimgui.c.ImGui_BeginTable(
"table_cursor",
2,
cimgui.c.ImGuiTableFlags_None,
)) return;
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Position (x, y)");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The current cursor position in the terminal grid (0-indexed).");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("(%d, %d)", cursor.x, cursor.y);
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Hyperlink");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The active OSC8 hyperlink for newly printed characters.");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (cursor.hyperlink) |link| {
cimgui.c.ImGui_Text("%.*s", link.uri.len, link.uri.ptr);
} else {
cimgui.c.ImGui_TextDisabled("(none)");
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Pending Wrap");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The 'last column flag' (LCF). If set, the next character will force a soft-wrap to the next line.");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
var value: bool = cursor.pending_wrap;
_ = cimgui.c.ImGui_Checkbox("##pending_wrap", &value);
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Protected");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("If enabled, new characters will have the protected attribute set, preventing erasure by certain sequences.");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
var value: bool = cursor.protected;
_ = cimgui.c.ImGui_Checkbox("##protected", &value);
}
}
/// Render cursor style information using the shared style table.
pub fn cursorStyle(cursor: *const terminal.Screen.Cursor, palette: ?*const terminal.color.Palette) void {
widgets.style.table(cursor.style, palette);
}
/// Render keyboard information with a table.
fn keyboardTable(
screen: *const terminal.Screen,
modify_other_keys_2: bool,
) void {
if (!cimgui.c.ImGui_BeginTable(
"table_keyboard",
2,
cimgui.c.ImGuiTableFlags_None,
)) return;
defer cimgui.c.ImGui_EndTable();
const kitty_flags = screen.kitty_keyboard.current();
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Mode");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
const mode = if (kitty_flags.int() != 0) "kitty" else "legacy";
cimgui.c.ImGui_Text("%s", mode.ptr);
}
}
if (kitty_flags.int() != 0) {
const Flags = @TypeOf(kitty_flags);
inline for (@typeInfo(Flags).@"struct".fields) |field| {
{
const value = @field(kitty_flags, field.name);
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
const field_name = std.fmt.comptimePrint("{s}", .{field.name});
cimgui.c.ImGui_Text("%s", field_name.ptr);
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"%s",
if (value) "true".ptr else "false".ptr,
);
}
}
}
} else {
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Xterm modify keys");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"%s",
if (modify_other_keys_2) "true".ptr else "false".ptr,
);
}
}
} // keyboard mode info
}
/// Render kitty graphics information table.
pub fn kittyGraphicsTable(
kitty_images: *const terminal.kitty.graphics.ImageStorage,
) void {
if (!kitty_images.enabled()) {
cimgui.c.ImGui_TextDisabled("(Kitty graphics are disabled)");
return;
}
if (!cimgui.c.ImGui_BeginTable(
"##kitty_graphics",
2,
cimgui.c.ImGuiTableFlags_None,
)) return;
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Memory Usage");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d bytes (%d KiB)", kitty_images.total_bytes, units.toKibiBytes(kitty_images.total_bytes));
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Memory Limit");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d bytes (%d KiB)", kitty_images.total_limit, units.toKibiBytes(kitty_images.total_limit));
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Image Count");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d", kitty_images.images.count());
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Placement Count");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d", kitty_images.placements.count());
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Image Loading");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%s", if (kitty_images.loading != null) "true".ptr else "false".ptr);
}
/// Render internal terminal state table.
pub fn internalStateTable(
screen: *const terminal.Screen,
) void {
const pages = &screen.pages;
if (!cimgui.c.ImGui_BeginTable(
"##terminal_state",
2,
cimgui.c.ImGuiTableFlags_None,
)) return;
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Memory Usage");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d bytes (%d KiB)", pages.page_size, units.toKibiBytes(pages.page_size));
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Memory Limit");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%d bytes (%d KiB)", pages.maxSize(), units.toKibiBytes(pages.maxSize()));
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Viewport Location");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr);
}
/// Render semantic prompt state table.
pub fn semanticPromptTable(
semantic_prompt: *const terminal.Screen.SemanticPrompt,
) void {
if (!cimgui.c.ImGui_BeginTable(
"##semantic_prompt",
2,
cimgui.c.ImGuiTableFlags_None,
)) return;
defer cimgui.c.ImGui_EndTable();
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Seen");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("Whether any semantic prompt markers (OSC 133) have been seen in this screen.");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
var value: bool = semantic_prompt.seen;
_ = cimgui.c.ImGui_Checkbox("##seen", &value);
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Click Handling");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("How click events are handled in prompts. Set via 'cl' or 'click_events' options in OSC 133.");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
switch (semantic_prompt.click) {
.none => cimgui.c.ImGui_TextDisabled("(none)"),
.click_events => cimgui.c.ImGui_Text("click_events"),
.cl => |cl| cimgui.c.ImGui_Text("cl=%s", @tagName(cl).ptr),
}
}
}

View File

@@ -0,0 +1,125 @@
const std = @import("std");
const cimgui = @import("dcimgui");
const terminal = @import("../../terminal/main.zig");
const widgets = @import("../widgets.zig");
/// Render a style as a table.
pub fn table(
st: terminal.Style,
palette: ?*const terminal.color.Palette,
) void {
{
_ = cimgui.c.ImGui_BeginTable(
"style",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Foreground");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The foreground (text) color");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
color("fg", st.fg_color, palette);
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Background");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The background (cell) color");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
color("bg", st.bg_color, palette);
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Underline");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The underline color, if underlines are enabled.");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
color("underline", st.underline_color, palette);
}
const style_flags = .{
.{ "bold", "Text will be rendered with bold weight." },
.{ "italic", "Text will be rendered in italic style." },
.{ "faint", "Text will be rendered with reduced intensity." },
.{ "blink", "Text will blink." },
.{ "inverse", "Foreground and background colors are swapped." },
.{ "invisible", "Text will be invisible (hidden)." },
.{ "strikethrough", "Text will have a line through it." },
};
inline for (style_flags) |entry| entry: {
const style = entry[0];
const help = entry[1];
if (!@field(st.flags, style)) break :entry;
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text(style.ptr);
cimgui.c.ImGui_SameLine();
widgets.helpMarker(help);
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("true");
}
}
}
cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)");
}
/// Render a style color.
pub fn color(
id: [:0]const u8,
c: terminal.Style.Color,
palette: ?*const terminal.color.Palette,
) void {
cimgui.c.ImGui_PushID(id);
defer cimgui.c.ImGui_PopID();
switch (c) {
.none => cimgui.c.ImGui_Text("default"),
.palette => |idx| {
cimgui.c.ImGui_Text("Palette %d", idx);
if (palette) |p| {
const rgb = p[idx];
var data: [3]f32 = .{
@as(f32, @floatFromInt(rgb.r)) / 255,
@as(f32, @floatFromInt(rgb.g)) / 255,
@as(f32, @floatFromInt(rgb.b)) / 255,
};
_ = cimgui.c.ImGui_ColorEdit3(
"color_fg",
&data,
cimgui.c.ImGuiColorEditFlags_DisplayHex |
cimgui.c.ImGuiColorEditFlags_NoPicker |
cimgui.c.ImGuiColorEditFlags_NoLabel,
);
}
},
.rgb => |rgb| {
var data: [3]f32 = .{
@as(f32, @floatFromInt(rgb.r)) / 255,
@as(f32, @floatFromInt(rgb.g)) / 255,
@as(f32, @floatFromInt(rgb.b)) / 255,
};
_ = cimgui.c.ImGui_ColorEdit3(
"color_fg",
&data,
cimgui.c.ImGuiColorEditFlags_DisplayHex |
cimgui.c.ImGuiColorEditFlags_NoPicker |
cimgui.c.ImGuiColorEditFlags_NoLabel,
);
},
}
}

View File

@@ -0,0 +1,503 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = @import("../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const cimgui = @import("dcimgui");
const inspector = @import("../main.zig");
const widgets = @import("../widgets.zig");
const input = @import("../../input.zig");
const renderer = @import("../../renderer.zig");
const terminal = @import("../../terminal/main.zig");
const Surface = @import("../../Surface.zig");
/// This is discovered via the hardcoded string in the ImGui demo window.
const window_imgui_demo = "Dear ImGui Demo";
const window_keyboard = "Keyboard";
const window_terminal = "Terminal";
const window_surface = "Surface";
const window_termio = "Terminal IO";
const window_renderer = "Renderer";
pub const Inspector = struct {
/// Internal GUI state
surface_info: Info,
key_stream: widgets.key.Stream,
terminal_info: widgets.terminal.Info,
vt_stream: widgets.termio.Stream,
renderer_info: widgets.renderer.Info,
show_demo_window: bool,
pub fn init(alloc: Allocator) !Inspector {
return .{
.surface_info = .empty,
.key_stream = try .init(alloc),
.terminal_info = .empty,
.vt_stream = try .init(alloc),
.renderer_info = .empty,
.show_demo_window = true,
};
}
pub fn deinit(self: *Inspector, alloc: Allocator) void {
self.key_stream.deinit(alloc);
self.vt_stream.deinit(alloc);
self.renderer_info.deinit(alloc);
}
pub fn draw(
self: *Inspector,
surface: *const Surface,
mouse: Mouse,
) void {
// Create our dockspace first. If we had to setup our dockspace,
// then it is a first render.
const dockspace_id = cimgui.c.ImGui_GetID("Main Dockspace");
const first_render = createDockSpace(dockspace_id);
// Draw everything that requires the terminal state mutex.
{
surface.renderer_state.mutex.lock();
defer surface.renderer_state.mutex.unlock();
const t = surface.renderer_state.terminal;
// Terminal info window
{
const open = cimgui.c.ImGui_Begin(
window_terminal,
null,
cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing,
);
defer cimgui.c.ImGui_End();
self.terminal_info.draw(open, t);
}
// Surface info window
{
const open = cimgui.c.ImGui_Begin(
window_surface,
null,
cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing,
);
defer cimgui.c.ImGui_End();
self.surface_info.draw(
open,
surface,
mouse,
);
}
// Keyboard info window
{
const open = cimgui.c.ImGui_Begin(
window_keyboard,
null,
cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing,
);
defer cimgui.c.ImGui_End();
self.key_stream.draw(
open,
surface.alloc,
);
}
// Terminal IO window
{
const open = cimgui.c.ImGui_Begin(
window_termio,
null,
cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing,
);
defer cimgui.c.ImGui_End();
if (open) {
self.vt_stream.draw(
surface.alloc,
&t.colors.palette.current,
);
}
}
// Renderer info window
{
const open = cimgui.c.ImGui_Begin(
window_renderer,
null,
cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing,
);
defer cimgui.c.ImGui_End();
self.renderer_info.draw(
surface.alloc,
open,
);
}
}
// In debug we show the ImGui demo window so we can easily view
// available widgets and such.
if (comptime builtin.mode == .Debug) {
if (self.show_demo_window) {
cimgui.c.ImGui_ShowDemoWindow(&self.show_demo_window);
}
}
if (first_render) {
// On first render, setup our initial focus state. We only
// do this on first render so that we can let the user change
// focus afterward without it snapping back.
cimgui.c.ImGui_SetWindowFocusStr(window_terminal);
}
}
/// Create the global dock space for the inspector. A dock space
/// is a special area where windows can be docked into. The global
/// dock space fills the entire main viewport.
///
/// Returns true if this was the first time the dock space was created.
fn createDockSpace(dockspace_id: cimgui.c.ImGuiID) bool {
const viewport: *cimgui.c.ImGuiViewport = cimgui.c.ImGui_GetMainViewport();
// Initial Docking setup
const setup = cimgui.ImGui_DockBuilderGetNode(dockspace_id) == null;
if (setup) {
// Register our dockspace node
assert(cimgui.ImGui_DockBuilderAddNodeEx(
dockspace_id,
cimgui.ImGuiDockNodeFlagsPrivate.DockSpace,
) == dockspace_id);
// Ensure it is the full size of the viewport
cimgui.ImGui_DockBuilderSetNodeSize(
dockspace_id,
viewport.Size,
);
// We only initialize one central docking point now but
// this is the point we'd pre-split and so on for the initial
// layout.
const dock_id_main: cimgui.c.ImGuiID = dockspace_id;
cimgui.ImGui_DockBuilderDockWindow(window_imgui_demo, dock_id_main);
cimgui.ImGui_DockBuilderDockWindow(window_terminal, dock_id_main);
cimgui.ImGui_DockBuilderDockWindow(window_surface, dock_id_main);
cimgui.ImGui_DockBuilderDockWindow(window_keyboard, dock_id_main);
cimgui.ImGui_DockBuilderDockWindow(window_termio, dock_id_main);
cimgui.ImGui_DockBuilderDockWindow(window_renderer, dock_id_main);
cimgui.ImGui_DockBuilderFinish(dockspace_id);
}
// Put the dockspace over the viewport.
assert(cimgui.c.ImGui_DockSpaceOverViewportEx(
dockspace_id,
viewport,
cimgui.c.ImGuiDockNodeFlags_PassthruCentralNode,
null,
) == dockspace_id);
return setup;
}
};
pub const Mouse = struct {
/// Last hovered x/y
last_xpos: f64 = 0,
last_ypos: f64 = 0,
// Last hovered screen point
last_point: ?terminal.Pin = null,
};
/// Surface information inspector widget.
pub const Info = struct {
pub const empty: Info = .{};
/// Draw the surface info window.
pub fn draw(
self: *Info,
open: bool,
surface: *const Surface,
mouse: Mouse,
) void {
_ = self;
if (!open) return;
if (cimgui.c.ImGui_CollapsingHeader(
"Help",
cimgui.c.ImGuiTreeNodeFlags_None,
)) {
cimgui.c.ImGui_TextWrapped(
"This window displays information about the surface (window). " ++
"A surface is the graphical area that displays the terminal " ++
"content. It includes dimensions, font sizing, and mouse state " ++
"information specific to this window instance.",
);
}
cimgui.c.ImGui_SeparatorText("Dimensions");
dimensionsTable(surface);
cimgui.c.ImGui_SeparatorText("Font");
fontTable(surface);
cimgui.c.ImGui_SeparatorText("Mouse");
mouseTable(surface, mouse);
}
};
fn dimensionsTable(surface: *const Surface) void {
_ = cimgui.c.ImGui_BeginTable(
"table_size",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
// Screen Size
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Screen Size");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"%dpx x %dpx",
surface.size.screen.width,
surface.size.screen.height,
);
}
}
// Grid Size
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Grid Size");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
const grid_size = surface.size.grid();
cimgui.c.ImGui_Text(
"%dc x %dr",
grid_size.columns,
grid_size.rows,
);
}
}
// Cell Size
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Cell Size");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"%dpx x %dpx",
surface.size.cell.width,
surface.size.cell.height,
);
}
}
// Padding
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Window Padding");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"T=%d B=%d L=%d R=%d px",
surface.size.padding.top,
surface.size.padding.bottom,
surface.size.padding.left,
surface.size.padding.right,
);
}
}
}
fn fontTable(surface: *const Surface) void {
_ = cimgui.c.ImGui_BeginTable(
"table_font",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Size (Points)");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"%.2f pt",
surface.font_size.points,
);
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Size (Pixels)");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"%.2f px",
surface.font_size.pixels(),
);
}
}
}
fn mouseTable(
surface: *const Surface,
mouse: Mouse,
) void {
_ = cimgui.c.ImGui_BeginTable(
"table_mouse",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
const surface_mouse = &surface.mouse;
const t = surface.renderer_state.terminal;
{
const hover_point: terminal.point.Coordinate = pt: {
const p = mouse.last_point orelse break :pt .{};
const pt = t.screens.active.pages.pointFromPin(
.active,
p,
) orelse break :pt .{};
break :pt pt.coord();
};
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Hover Grid");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"row=%d, col=%d",
hover_point.y,
hover_point.x,
);
}
}
{
const coord: renderer.Coordinate.Terminal = (renderer.Coordinate{
.surface = .{
.x = mouse.last_xpos,
.y = mouse.last_ypos,
},
}).convert(.terminal, surface.size).terminal;
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Hover Point");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"(%dpx, %dpx)",
@as(i64, @intFromFloat(coord.x)),
@as(i64, @intFromFloat(coord.y)),
);
}
}
const any_click = for (surface_mouse.click_state) |state| {
if (state == .press) break true;
} else false;
click: {
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Click State");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (!any_click) {
cimgui.c.ImGui_Text("none");
break :click;
}
for (surface_mouse.click_state, 0..) |state, i| {
if (state != .press) continue;
const button: input.MouseButton = @enumFromInt(i);
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_Text("%s", (switch (button) {
.unknown => "?",
.left => "L",
.middle => "M",
.right => "R",
.four => "{4}",
.five => "{5}",
.six => "{6}",
.seven => "{7}",
.eight => "{8}",
.nine => "{9}",
.ten => "{10}",
.eleven => "{11}",
}).ptr);
}
}
}
{
const left_click_point: terminal.point.Coordinate = pt: {
const p = surface_mouse.left_click_pin orelse break :pt .{};
const pt = t.screens.active.pages.pointFromPin(
.active,
p.*,
) orelse break :pt .{};
break :pt pt.coord();
};
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Click Grid");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"row=%d, col=%d",
left_click_point.y,
left_click_point.x,
);
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Click Point");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"(%dpx, %dpx)",
@as(u32, @intFromFloat(surface_mouse.left_click_xpos)),
@as(u32, @intFromFloat(surface_mouse.left_click_ypos)),
);
}
}
}

View File

@@ -0,0 +1,726 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = @import("../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const cimgui = @import("dcimgui");
const widgets = @import("../widgets.zig");
const terminal = @import("../../terminal/main.zig");
const modes = terminal.modes;
const Terminal = terminal.Terminal;
/// Terminal information inspector widget.
pub const Info = struct {
/// True if we're showing the 256-color palette window.
show_palette: bool,
/// The various detachable headers.
misc_header: widgets.DetachableHeader,
layout_header: widgets.DetachableHeader,
mouse_header: widgets.DetachableHeader,
color_header: widgets.DetachableHeader,
modes_header: widgets.DetachableHeader,
/// Screen detail windows for each screen key.
screens: ScreenMap,
pub const empty: Info = .{
.show_palette = false,
.misc_header = .{},
.layout_header = .{},
.mouse_header = .{},
.color_header = .{},
.modes_header = .{},
.screens = .{},
};
/// Draw the terminal info window.
pub fn draw(
self: *Info,
open: bool,
t: *Terminal,
) void {
// Draw our open state if we're open.
if (open) self.drawOpen(t);
// Draw our detached state that draws regardless of if
// we're open or not.
if (self.misc_header.window("Terminal Misc")) |visible| {
defer self.misc_header.windowEnd();
if (visible) miscTable(t);
}
if (self.layout_header.window("Terminal Layout")) |visible| {
defer self.layout_header.windowEnd();
if (visible) layoutTable(t);
}
if (self.mouse_header.window("Terminal Mouse")) |visible| {
defer self.mouse_header.windowEnd();
if (visible) mouseTable(t);
}
if (self.color_header.window("Terminal Color")) |visible| {
defer self.color_header.windowEnd();
if (visible) colorTable(t, &self.show_palette);
}
if (self.modes_header.window("Terminal Modes")) |visible| {
defer self.modes_header.windowEnd();
if (visible) modesTable(t);
}
// Palette pop-out window
if (self.show_palette) {
defer cimgui.c.ImGui_End();
if (cimgui.c.ImGui_Begin(
"256-Color Palette",
&self.show_palette,
cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing,
)) {
palette("palette", &t.colors.palette.current);
}
}
// Screen pop-out windows
var it = self.screens.iterator();
while (it.next()) |entry| {
const screen = t.screens.get(entry.key) orelse {
// Could happen if we opened up a window for a screen
// and that screen was subsequently deinitialized. In
// this case, hide the window.
self.screens.remove(entry.key);
continue;
};
var title_buf: [128]u8 = undefined;
const title = std.fmt.bufPrintZ(
&title_buf,
"Screen: {t}",
.{entry.key},
) catch "Screen";
// Setup our next window so it has some size to it.
const viewport = cimgui.c.ImGui_GetMainViewport();
cimgui.c.ImGui_SetNextWindowSize(
.{
.x = @min(400, viewport.*.Size.x),
.y = @min(300, viewport.*.Size.y),
},
cimgui.c.ImGuiCond_FirstUseEver,
);
var screen_open: bool = true;
defer cimgui.c.ImGui_End();
const screen_draw = cimgui.c.ImGui_Begin(
title,
&screen_open,
cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing,
);
entry.value.draw(screen_draw, .{
.screen = screen,
.key = entry.key,
.active_key = t.screens.active_key,
.modify_other_keys_2 = t.flags.modify_other_keys_2,
.color_palette = &t.colors.palette,
});
// If the window was closed, remove it from our map so future
// renders don't draw it.
if (!screen_open) self.screens.remove(entry.key);
}
}
fn drawOpen(self: *Info, t: *Terminal) void {
// Show our screens up top.
screensTable(t, &self.screens);
if (self.misc_header.header("Misc")) miscTable(t);
if (self.layout_header.header("Layout")) layoutTable(t);
if (self.mouse_header.header("Mouse")) mouseTable(t);
if (self.color_header.header("Color")) colorTable(t, &self.show_palette);
if (self.modes_header.header("Modes")) modesTable(t);
}
};
pub const ScreenMap = std.EnumMap(
terminal.ScreenSet.Key,
widgets.screen.Info,
);
/// Render the table of possible screens with various actions.
fn screensTable(
t: *Terminal,
map: *ScreenMap,
) void {
if (!cimgui.c.ImGui_BeginTable(
"screens",
3,
cimgui.c.ImGuiTableFlags_Borders |
cimgui.c.ImGuiTableFlags_RowBg |
cimgui.c.ImGuiTableFlags_SizingFixedFit,
)) return;
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableSetupColumn("Screen", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("Status", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_WidthFixed);
// Custom header row to include help marker before "Screen"
{
cimgui.c.ImGui_TableNextRowEx(cimgui.c.ImGuiTableRowFlags_Headers, 0.0);
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_PushStyleVarImVec2(cimgui.c.ImGuiStyleVar_FramePadding, .{ .x = 0, .y = 0 });
widgets.helpMarker(
"A terminal can have multiple screens, only one of which is active at " ++
"a time. Each screen has its own grid, contents, and other state. " ++
"This section allows you to inspect the different screens managed by " ++
"the terminal.",
);
cimgui.c.ImGui_PopStyleVar();
cimgui.c.ImGui_SameLineEx(0.0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x);
cimgui.c.ImGui_TableHeader("Screen");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_TableHeader("Status");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_TableHeader("");
}
}
for (std.meta.tags(terminal.ScreenSet.Key)) |key| {
const is_initialized = t.screens.get(key) != null;
const is_active = t.screens.active_key == key;
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("%s", @tagName(key).ptr);
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (is_active) {
cimgui.c.ImGui_TextColored(
.{ .x = 0.4, .y = 1.0, .z = 0.4, .w = 1.0 },
"active",
);
} else if (is_initialized) {
cimgui.c.ImGui_TextColored(
.{ .x = 0.6, .y = 0.6, .z = 0.6, .w = 1.0 },
"initialized",
);
} else {
cimgui.c.ImGui_TextColored(
.{ .x = 0.4, .y = 0.4, .z = 0.4, .w = 1.0 },
"(not initialized)",
);
}
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
cimgui.c.ImGui_PushIDInt(@intFromEnum(key));
defer cimgui.c.ImGui_PopID();
cimgui.c.ImGui_BeginDisabled(!is_initialized);
defer cimgui.c.ImGui_EndDisabled();
if (cimgui.c.ImGui_Button("View")) {
if (!map.contains(key)) {
map.put(key, .empty);
}
}
}
}
}
/// Table of miscellaneous terminal information.
fn miscTable(t: *Terminal) void {
_ = cimgui.c.ImGui_BeginTable(
"table_misc",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Working Directory");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The current working directory reported by the shell.");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (t.pwd.items.len > 0) {
cimgui.c.ImGui_Text(
"%.*s",
t.pwd.items.len,
t.pwd.items.ptr,
);
} else {
cimgui.c.ImGui_TextDisabled("(none)");
}
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Focused");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("Whether the terminal itself is currently focused.");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
var value: bool = t.flags.focused;
_ = cimgui.c.ImGui_Checkbox("##focused", &value);
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Previous Char");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The previously printed character, used only for the REP sequence.");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (t.previous_char) |c| {
cimgui.c.ImGui_Text("U+%04X", @as(u32, c));
} else {
cimgui.c.ImGui_TextDisabled("(none)");
}
}
}
}
/// Table of terminal layout information.
fn layoutTable(t: *Terminal) void {
_ = cimgui.c.ImGui_BeginTable(
"table_layout",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Grid");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The size of the terminal grid in columns and rows.");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"%dc x %dr",
t.cols,
t.rows,
);
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Pixels");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The size of the terminal grid in pixels.");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"%dw x %dh",
t.width_px,
t.height_px,
);
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Scroll Region");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The scrolling region boundaries (top, bottom, left, right).");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_PushItemWidth(cimgui.c.ImGui_CalcTextSize("00000").x);
defer cimgui.c.ImGui_PopItemWidth();
var override = t.scrolling_region;
var changed = false;
cimgui.c.ImGui_AlignTextToFramePadding();
cimgui.c.ImGui_Text("T:");
cimgui.c.ImGui_SameLine();
if (cimgui.c.ImGui_InputScalar(
"##scroll_top",
cimgui.c.ImGuiDataType_U16,
&override.top,
)) {
override.top = @min(override.top, t.rows -| 1);
changed = true;
}
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_Text("B:");
cimgui.c.ImGui_SameLine();
if (cimgui.c.ImGui_InputScalar(
"##scroll_bottom",
cimgui.c.ImGuiDataType_U16,
&override.bottom,
)) {
override.bottom = @min(override.bottom, t.rows -| 1);
changed = true;
}
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_Text("L:");
cimgui.c.ImGui_SameLine();
if (cimgui.c.ImGui_InputScalar(
"##scroll_left",
cimgui.c.ImGuiDataType_U16,
&override.left,
)) {
override.left = @min(override.left, t.cols -| 1);
changed = true;
}
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_Text("R:");
cimgui.c.ImGui_SameLine();
if (cimgui.c.ImGui_InputScalar(
"##scroll_right",
cimgui.c.ImGuiDataType_U16,
&override.right,
)) {
override.right = @min(override.right, t.cols -| 1);
changed = true;
}
if (changed and
override.top < override.bottom and
override.left < override.right)
{
t.scrolling_region = override;
}
}
}
}
/// Table of mouse-related terminal information.
fn mouseTable(t: *Terminal) void {
_ = cimgui.c.ImGui_BeginTable(
"table_mouse",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Event Mode");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The mouse event reporting mode set by the application.");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%s", @tagName(t.flags.mouse_event).ptr);
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Format");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The mouse event encoding format.");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%s", @tagName(t.flags.mouse_format).ptr);
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Shape");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The current mouse cursor shape set by the application.");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%s", @tagName(t.mouse_shape).ptr);
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Shift Capture");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("XTSHIFTESCAPE state for capturing shift in mouse protocol.");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (t.flags.mouse_shift_capture == .null) {
cimgui.c.ImGui_TextDisabled("(unset)");
} else {
cimgui.c.ImGui_Text("%s", @tagName(t.flags.mouse_shift_capture).ptr);
}
}
}
}
/// Table of color-related terminal information.
fn colorTable(
t: *Terminal,
show_palette: *bool,
) void {
cimgui.c.ImGui_TextWrapped(
"Color state for the terminal. Note these colors only apply " ++
"to the palette and unstyled colors. Many modern terminal " ++
"applications use direct RGB colors which are not reflected here.",
);
cimgui.c.ImGui_Separator();
_ = cimgui.c.ImGui_BeginTable(
"table_color",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Background");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("Unstyled cell background color.");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
_ = dynamicRGB(
"bg_color",
&t.colors.background,
);
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Foreground");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("Unstyled cell foreground color.");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
_ = dynamicRGB(
"fg_color",
&t.colors.foreground,
);
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Cursor");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("Cursor coloring set by escape sequences.");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
_ = dynamicRGB(
"cursor_color",
&t.colors.cursor,
);
}
}
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Palette");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("The 256-color palette.");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
if (cimgui.c.ImGui_Button("View")) {
show_palette.* = true;
}
}
}
}
/// Table of terminal modes.
fn modesTable(t: *Terminal) void {
_ = cimgui.c.ImGui_BeginTable(
"table_modes",
3,
cimgui.c.ImGuiTableFlags_SizingFixedFit |
cimgui.c.ImGuiTableFlags_RowBg,
);
defer cimgui.c.ImGui_EndTable();
{
cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_NoResize);
cimgui.c.ImGui_TableSetupColumn("Number", cimgui.c.ImGuiTableColumnFlags_PreferSortAscending);
cimgui.c.ImGui_TableSetupColumn("Name", cimgui.c.ImGuiTableColumnFlags_WidthStretch);
cimgui.c.ImGui_TableHeadersRow();
}
inline for (@typeInfo(terminal.Mode).@"enum".fields) |field| {
@setEvalBranchQuota(6000);
const tag: modes.ModeTag = @bitCast(@as(modes.ModeTag.Backing, field.value));
cimgui.c.ImGui_TableNextRow();
cimgui.c.ImGui_PushIDInt(@intCast(field.value));
defer cimgui.c.ImGui_PopID();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
var value: bool = t.modes.get(@field(terminal.Mode, field.name));
_ = cimgui.c.ImGui_Checkbox("##checkbox", &value);
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"%s%d",
if (tag.ansi) "" else "?",
@as(u32, @intCast(tag.value)),
);
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(2);
const name = std.fmt.comptimePrint("{s}", .{field.name});
cimgui.c.ImGui_Text("%s", name.ptr);
}
}
}
/// Render a DynamicRGB color.
fn dynamicRGB(
label: [:0]const u8,
rgb: *terminal.color.DynamicRGB,
) bool {
_ = cimgui.c.ImGui_BeginTable(
label,
if (rgb.override != null) 2 else 1,
cimgui.c.ImGuiTableFlags_SizingFixedFit,
);
defer cimgui.c.ImGui_EndTable();
if (rgb.override != null) cimgui.c.ImGui_TableSetupColumn(
"##label",
cimgui.c.ImGuiTableColumnFlags_WidthFixed,
);
cimgui.c.ImGui_TableSetupColumn(
"##value",
cimgui.c.ImGuiTableColumnFlags_WidthStretch,
);
if (rgb.override) |c| {
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("override:");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("Overridden color set by escape sequences.");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
var col = [3]f32{
@as(f32, @floatFromInt(c.r)) / 255.0,
@as(f32, @floatFromInt(c.g)) / 255.0,
@as(f32, @floatFromInt(c.b)) / 255.0,
};
_ = cimgui.c.ImGui_ColorEdit3(
"##override",
&col,
cimgui.c.ImGuiColorEditFlags_None,
);
}
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
if (rgb.default) |c| {
if (rgb.override != null) {
cimgui.c.ImGui_Text("default:");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("Default color from configuration.");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
}
var col = [3]f32{
@as(f32, @floatFromInt(c.r)) / 255.0,
@as(f32, @floatFromInt(c.g)) / 255.0,
@as(f32, @floatFromInt(c.b)) / 255.0,
};
_ = cimgui.c.ImGui_ColorEdit3(
"##default",
&col,
cimgui.c.ImGuiColorEditFlags_None,
);
} else {
cimgui.c.ImGui_TextDisabled("(unset)");
}
return false;
}
/// Render a color palette as a 16x16 grid of color buttons.
fn palette(
label: [:0]const u8,
pal: *const terminal.color.Palette,
) void {
cimgui.c.ImGui_PushID(label);
defer cimgui.c.ImGui_PopID();
for (0..16) |row| {
for (0..16) |col| {
const idx = row * 16 + col;
const rgb = pal[idx];
var col_arr = [3]f32{
@as(f32, @floatFromInt(rgb.r)) / 255.0,
@as(f32, @floatFromInt(rgb.g)) / 255.0,
@as(f32, @floatFromInt(rgb.b)) / 255.0,
};
if (col > 0) cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_PushIDInt(@intCast(idx));
_ = cimgui.c.ImGui_ColorEdit3(
"##color",
&col_arr,
cimgui.c.ImGuiColorEditFlags_NoInputs,
);
if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) {
cimgui.c.ImGui_SetTooltip(
"%d: #%02X%02X%02X",
idx,
rgb.r,
rgb.g,
rgb.b,
);
}
cimgui.c.ImGui_PopID();
}
}
}

View File

@@ -0,0 +1,811 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const cimgui = @import("dcimgui");
const terminal = @import("../../terminal/main.zig");
const CircBuf = @import("../../datastruct/main.zig").CircBuf;
const Surface = @import("../../Surface.zig");
const screen = @import("screen.zig");
/// VT event stream inspector widget.
pub const Stream = struct {
events: VTEvent.Ring,
parser_stream: VTHandler.Stream,
/// The currently selected event sequence number for keyboard navigation
selected_event_seq: ?u32 = null,
/// Flag indicating whether we need to scroll to the selected item
need_scroll_to_selected: bool = false,
/// Flag indicating whether the selection was made by keyboard
is_keyboard_selection: bool = false,
pub fn init(alloc: Allocator) !Stream {
var events: VTEvent.Ring = try .init(alloc, 2);
errdefer events.deinit(alloc);
var handler: VTHandler = .init;
errdefer handler.deinit();
return .{
.events = events,
.parser_stream = .initAlloc(alloc, handler),
};
}
pub fn deinit(self: *Stream, alloc: Allocator) void {
var it = self.events.iterator(.forward);
while (it.next()) |v| v.deinit(alloc);
self.events.deinit(alloc);
self.parser_stream.deinit();
}
pub fn recordPtyRead(
self: *Stream,
alloc: Allocator,
t: *terminal.Terminal,
data: []const u8,
) !void {
self.parser_stream.handler.state = .{
.alloc = alloc,
.terminal = t,
.events = &self.events,
};
defer self.parser_stream.handler.state = null;
try self.parser_stream.nextSlice(data);
}
pub fn draw(
self: *Stream,
alloc: Allocator,
palette: *const terminal.color.Palette,
) void {
const events = &self.events;
const handler = &self.parser_stream.handler;
const popup_filter = "Filter";
// Controls
{
const pause_play: [:0]const u8 = if (!handler.paused)
"Pause##pause_play"
else
"Resume##pause_play";
if (cimgui.c.ImGui_Button(pause_play.ptr)) {
handler.paused = !handler.paused;
}
cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x);
if (cimgui.c.ImGui_Button("Filter")) {
cimgui.c.ImGui_OpenPopup(
popup_filter,
cimgui.c.ImGuiPopupFlags_None,
);
}
if (!events.empty()) {
cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x);
if (cimgui.c.ImGui_Button("Clear")) {
var it = events.iterator(.forward);
while (it.next()) |v| v.deinit(alloc);
events.clear();
handler.current_seq = 1;
}
}
}
// Events Table
if (events.empty()) {
cimgui.c.ImGui_Text("Waiting for events...");
} else {
// TODO: Eventually
// eventTable(events);
}
{
cimgui.c.ImGui_Separator();
_ = cimgui.c.ImGui_BeginTable(
"table_vt_events",
3,
cimgui.c.ImGuiTableFlags_RowBg |
cimgui.c.ImGuiTableFlags_Borders,
);
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableSetupColumn(
"Seq",
cimgui.c.ImGuiTableColumnFlags_WidthFixed,
);
cimgui.c.ImGui_TableSetupColumn(
"Kind",
cimgui.c.ImGuiTableColumnFlags_WidthFixed,
);
cimgui.c.ImGui_TableSetupColumn(
"Description",
cimgui.c.ImGuiTableColumnFlags_WidthStretch,
);
// Handle keyboard navigation when window is focused
if (cimgui.c.ImGui_IsWindowFocused(cimgui.c.ImGuiFocusedFlags_RootAndChildWindows)) {
const key_pressed = getKeyAction();
switch (key_pressed) {
.none => {},
.up, .down => {
// If no event is selected, select the first/last event based on direction
if (self.selected_event_seq == null) {
if (!events.empty()) {
var it = events.iterator(if (key_pressed == .up) .forward else .reverse);
if (it.next()) |ev| {
self.selected_event_seq = @as(u32, @intCast(ev.seq));
}
}
} else {
// Find next/previous event based on current selection
var it = events.iterator(.reverse);
switch (key_pressed) {
.down => {
var found = false;
while (it.next()) |ev| {
if (found) {
self.selected_event_seq = @as(u32, @intCast(ev.seq));
break;
}
if (ev.seq == self.selected_event_seq.?) {
found = true;
}
}
},
.up => {
var prev_ev: ?*const VTEvent = null;
while (it.next()) |ev| {
if (ev.seq == self.selected_event_seq.?) {
if (prev_ev) |prev| {
self.selected_event_seq = @as(u32, @intCast(prev.seq));
break;
}
}
prev_ev = ev;
}
},
.none => unreachable,
}
}
// Mark that we need to scroll to the newly selected item
self.need_scroll_to_selected = true;
self.is_keyboard_selection = true;
},
}
}
var it = events.iterator(.reverse);
while (it.next()) |ev| {
// Need to push an ID so that our selectable is unique.
cimgui.c.ImGui_PushIDPtr(ev);
defer cimgui.c.ImGui_PopID();
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableNextColumn();
// Store the previous selection state to detect changes
const was_selected = ev.imgui_selected;
// Update selection state based on keyboard navigation
if (self.selected_event_seq) |seq| {
ev.imgui_selected = (@as(u32, @intCast(ev.seq)) == seq);
}
// Handle selectable widget
if (cimgui.c.ImGui_SelectableBoolPtr(
"##select",
&ev.imgui_selected,
cimgui.c.ImGuiSelectableFlags_SpanAllColumns,
)) {
// If selection state changed, update keyboard navigation state
if (ev.imgui_selected != was_selected) {
self.selected_event_seq = if (ev.imgui_selected)
@as(u32, @intCast(ev.seq))
else
null;
self.is_keyboard_selection = false;
}
}
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_Text("%d", ev.seq);
_ = cimgui.c.ImGui_TableNextColumn();
cimgui.c.ImGui_Text("%s", @tagName(ev.kind).ptr);
_ = cimgui.c.ImGui_TableNextColumn();
cimgui.c.ImGui_Text("%s", ev.raw_description.ptr);
// If the event is selected, we render info about it. For now
// we put this in the last column because that's the widest and
// imgui has no way to make a column span.
if (ev.imgui_selected) {
{
screen.cursorTable(&ev.cursor);
screen.cursorStyle(&ev.cursor, palette);
_ = cimgui.c.ImGui_BeginTable(
"details",
2,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
{
cimgui.c.ImGui_TableNextRow();
{
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Scroll Region");
}
{
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"T=%d B=%d L=%d R=%d",
ev.scrolling_region.top,
ev.scrolling_region.bottom,
ev.scrolling_region.left,
ev.scrolling_region.right,
);
}
}
var md_it = ev.metadata.iterator();
while (md_it.next()) |entry| {
var buf: [256]u8 = undefined;
const key = std.fmt.bufPrintZ(&buf, "{s}", .{entry.key_ptr.*}) catch
"<internal error>";
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableNextColumn();
cimgui.c.ImGui_Text("%s", key.ptr);
_ = cimgui.c.ImGui_TableNextColumn();
cimgui.c.ImGui_Text("%s", entry.value_ptr.ptr);
}
}
// If this is the selected event and scrolling is needed, scroll to it
if (self.need_scroll_to_selected and self.is_keyboard_selection) {
cimgui.c.ImGui_SetScrollHereY(0.5);
self.need_scroll_to_selected = false;
}
}
}
} // table
if (cimgui.c.ImGui_BeginPopupModal(
popup_filter,
null,
cimgui.c.ImGuiWindowFlags_AlwaysAutoResize,
)) {
defer cimgui.c.ImGui_EndPopup();
cimgui.c.ImGui_Text("Changed filter settings will only affect future events.");
cimgui.c.ImGui_Separator();
{
_ = cimgui.c.ImGui_BeginTable(
"table_filter_kind",
3,
cimgui.c.ImGuiTableFlags_None,
);
defer cimgui.c.ImGui_EndTable();
inline for (@typeInfo(terminal.Parser.Action.Tag).@"enum".fields) |field| {
const tag = @field(terminal.Parser.Action.Tag, field.name);
if (tag == .apc_put or tag == .dcs_put) continue;
_ = cimgui.c.ImGui_TableNextColumn();
var value = !handler.filter_exclude.contains(tag);
if (cimgui.c.ImGui_Checkbox(@tagName(tag).ptr, &value)) {
if (value) {
handler.filter_exclude.remove(tag);
} else {
handler.filter_exclude.insert(tag);
}
}
}
} // Filter kind table
cimgui.c.ImGui_Separator();
cimgui.c.ImGui_Text(
"Filter by string. Empty displays all, \"abc\" finds lines\n" ++
"containing \"abc\", \"abc,xyz\" finds lines containing \"abc\"\n" ++
"or \"xyz\", \"-abc\" excludes lines containing \"abc\".",
);
_ = cimgui.c.ImGuiTextFilter_Draw(
&handler.filter_text,
"##filter_text",
0,
);
cimgui.c.ImGui_Separator();
if (cimgui.c.ImGui_Button("Close")) {
cimgui.c.ImGui_CloseCurrentPopup();
}
} // filter popup
}
};
/// Helper function to check keyboard state and determine navigation action.
fn getKeyAction() KeyAction {
const keys = .{
.{ .key = cimgui.c.ImGuiKey_J, .action = KeyAction.down },
.{ .key = cimgui.c.ImGuiKey_DownArrow, .action = KeyAction.down },
.{ .key = cimgui.c.ImGuiKey_K, .action = KeyAction.up },
.{ .key = cimgui.c.ImGuiKey_UpArrow, .action = KeyAction.up },
};
inline for (keys) |k| {
if (cimgui.c.ImGui_IsKeyPressed(k.key)) {
return k.action;
}
}
return .none;
}
pub fn eventTable(events: *const VTEvent.Ring) void {
if (!cimgui.c.ImGui_BeginTable(
"events",
3,
cimgui.c.ImGuiTableFlags_RowBg |
cimgui.c.ImGuiTableFlags_Borders,
)) return;
defer cimgui.c.ImGui_EndTable();
cimgui.c.ImGui_TableSetupColumn(
"Seq",
cimgui.c.ImGuiTableColumnFlags_WidthFixed,
);
cimgui.c.ImGui_TableSetupColumn(
"Kind",
cimgui.c.ImGuiTableColumnFlags_WidthFixed,
);
cimgui.c.ImGui_TableSetupColumn(
"Description",
cimgui.c.ImGuiTableColumnFlags_WidthStretch,
);
var it = events.iterator(.reverse);
while (it.next()) |ev| {
// Need to push an ID so that our selectable is unique.
cimgui.c.ImGui_PushIDPtr(ev);
defer cimgui.c.ImGui_PopID();
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableNextColumn();
cimgui.c.ImGui_SameLine();
cimgui.c.ImGui_Text("%d", ev.seq);
_ = cimgui.c.ImGui_TableNextColumn();
cimgui.c.ImGui_Text("%s", @tagName(ev.kind).ptr);
_ = cimgui.c.ImGui_TableNextColumn();
cimgui.c.ImGui_Text("%s", ev.raw_description.ptr);
}
}
/// VT event. This isn't public because this is just how we store internal
/// events.
const VTEvent = struct {
/// The arena that all allocated memory for this event is stored.
arena_state: ArenaAllocator.State,
/// Sequence number, just monotonically increasing and wrapping if
/// it ever overflows. It gives us a nice way to visualize progress.
seq: usize = 1,
/// Kind of event, for filtering
kind: Kind,
/// The description of the raw event in a more human-friendly format.
/// For example for control sequences this is the full sequence but
/// control characters are replaced with human-readable names, e.g.
/// 0x07 (bell) becomes BEL.
raw_description: [:0]const u8,
/// Various metadata at the time of the event (before processing).
cursor: terminal.Screen.Cursor,
scrolling_region: terminal.Terminal.ScrollingRegion,
metadata: Metadata.Unmanaged = .{},
/// imgui selection state
imgui_selected: bool = false,
const Kind = enum { print, execute, csi, esc, osc, dcs, apc };
const Metadata = std.StringHashMap([:0]const u8);
/// Circular buffer of VT events.
pub const Ring = CircBuf(VTEvent, undefined);
/// Initialize the event information for the given parser action.
pub fn init(
alloc_gpa: Allocator,
t: *const terminal.Terminal,
action: terminal.Parser.Action,
) !VTEvent {
var arena: ArenaAllocator = .init(alloc_gpa);
errdefer arena.deinit();
const alloc = arena.allocator();
var md = Metadata.init(alloc);
var buf: std.Io.Writer.Allocating = .init(alloc);
try encodeAction(alloc, &buf.writer, &md, action);
const desc = try buf.toOwnedSliceSentinel(0);
const kind: Kind = switch (action) {
.print => .print,
.execute => .execute,
.csi_dispatch => .csi,
.esc_dispatch => .esc,
.osc_dispatch => .osc,
.dcs_hook, .dcs_put, .dcs_unhook => .dcs,
.apc_start, .apc_put, .apc_end => .apc,
};
return .{
.arena_state = arena.state,
.kind = kind,
.raw_description = desc,
.cursor = t.screens.active.cursor,
.scrolling_region = t.scrolling_region,
.metadata = md.unmanaged,
};
}
pub fn deinit(self: *VTEvent, alloc_gpa: Allocator) void {
var arena = self.arena_state.promote(alloc_gpa);
arena.deinit();
}
/// Returns true if the event passes the given filter.
pub fn passFilter(
self: *const VTEvent,
filter: *const cimgui.c.ImGuiTextFilter,
) bool {
// Check our main string
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
self.raw_description.ptr,
null,
)) return true;
// We also check all metadata keys and values
var it = self.metadata.iterator();
while (it.next()) |entry| {
var buf: [256]u8 = undefined;
const key = std.fmt.bufPrintZ(&buf, "{s}", .{entry.key_ptr.*}) catch continue;
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
key.ptr,
null,
)) return true;
if (cimgui.c.ImGuiTextFilter_PassFilter(
filter,
entry.value_ptr.ptr,
null,
)) return true;
}
return false;
}
/// Encode a parser action as a string that we show in the logs.
fn encodeAction(
alloc: Allocator,
writer: *std.Io.Writer,
md: *Metadata,
action: terminal.Parser.Action,
) !void {
switch (action) {
.print => try encodePrint(writer, action),
.execute => try encodeExecute(writer, action),
.csi_dispatch => |v| try encodeCSI(writer, v),
.esc_dispatch => |v| try encodeEsc(writer, v),
.osc_dispatch => |v| try encodeOSC(alloc, writer, md, v),
else => try writer.print("{f}", .{action}),
}
}
fn encodePrint(writer: *std.Io.Writer, action: terminal.Parser.Action) !void {
const ch = action.print;
try writer.print("'{u}' (U+{X})", .{ ch, ch });
}
fn encodeExecute(writer: *std.Io.Writer, action: terminal.Parser.Action) !void {
const ch = action.execute;
switch (ch) {
0x00 => try writer.writeAll("NUL"),
0x01 => try writer.writeAll("SOH"),
0x02 => try writer.writeAll("STX"),
0x03 => try writer.writeAll("ETX"),
0x04 => try writer.writeAll("EOT"),
0x05 => try writer.writeAll("ENQ"),
0x06 => try writer.writeAll("ACK"),
0x07 => try writer.writeAll("BEL"),
0x08 => try writer.writeAll("BS"),
0x09 => try writer.writeAll("HT"),
0x0A => try writer.writeAll("LF"),
0x0B => try writer.writeAll("VT"),
0x0C => try writer.writeAll("FF"),
0x0D => try writer.writeAll("CR"),
0x0E => try writer.writeAll("SO"),
0x0F => try writer.writeAll("SI"),
else => try writer.writeAll("?"),
}
try writer.print(" (0x{X})", .{ch});
}
fn encodeCSI(writer: *std.Io.Writer, csi: terminal.Parser.Action.CSI) !void {
for (csi.intermediates) |v| try writer.print("{c} ", .{v});
for (csi.params, 0..) |v, i| {
if (i != 0) try writer.writeByte(';');
try writer.print("{d}", .{v});
}
if (csi.intermediates.len > 0 or csi.params.len > 0) try writer.writeByte(' ');
try writer.writeByte(csi.final);
}
fn encodeEsc(writer: *std.Io.Writer, esc: terminal.Parser.Action.ESC) !void {
for (esc.intermediates) |v| try writer.print("{c} ", .{v});
try writer.writeByte(esc.final);
}
fn encodeOSC(
alloc: Allocator,
writer: *std.Io.Writer,
md: *Metadata,
osc: terminal.osc.Command,
) !void {
// The description is just the tag
try writer.print("{s} ", .{@tagName(osc)});
// Add additional fields to metadata
switch (osc) {
inline else => |v, tag| if (tag == osc) {
try encodeMetadata(alloc, md, v);
},
}
}
fn encodeMetadata(
alloc: Allocator,
md: *Metadata,
v: anytype,
) !void {
switch (@TypeOf(v)) {
void => {},
[]const u8,
[:0]const u8,
=> try md.put("data", try alloc.dupeZ(u8, v)),
else => |T| switch (@typeInfo(T)) {
.@"struct" => |info| inline for (info.fields) |field| {
try encodeMetadataSingle(
alloc,
md,
field.name,
@field(v, field.name),
);
},
.@"union" => |info| {
const Tag = info.tag_type orelse @compileError("Unions must have a tag");
const tag_name = @tagName(@as(Tag, v));
inline for (info.fields) |field| {
if (std.mem.eql(u8, field.name, tag_name)) {
if (field.type == void) {
break try md.put("data", tag_name);
} else {
break try encodeMetadataSingle(alloc, md, tag_name, @field(v, field.name));
}
}
}
},
else => {
@compileLog(T);
@compileError("unsupported type, see log");
},
},
}
}
fn encodeMetadataSingle(
alloc: Allocator,
md: *Metadata,
key: []const u8,
value: anytype,
) !void {
const Value = @TypeOf(value);
const info = @typeInfo(Value);
switch (info) {
.optional => if (value) |unwrapped| {
try encodeMetadataSingle(alloc, md, key, unwrapped);
} else {
try md.put(key, try alloc.dupeZ(u8, "(unset)"));
},
.bool => try md.put(
key,
try alloc.dupeZ(u8, if (value) "true" else "false"),
),
.@"enum" => try md.put(
key,
try alloc.dupeZ(u8, @tagName(value)),
),
.@"union" => |u| {
const Tag = u.tag_type orelse @compileError("Unions must have a tag");
const tag_name = @tagName(@as(Tag, value));
inline for (u.fields) |field| {
if (std.mem.eql(u8, field.name, tag_name)) {
const s = if (field.type == void)
try alloc.dupeZ(u8, tag_name)
else if (field.type == [:0]const u8 or field.type == []const u8)
try std.fmt.allocPrintSentinel(alloc, "{s}={s}", .{
tag_name,
@field(value, field.name),
}, 0)
else
try std.fmt.allocPrintSentinel(alloc, "{s}={}", .{
tag_name,
@field(value, field.name),
}, 0);
try md.put(key, s);
}
}
},
.@"struct" => try md.put(
key,
try alloc.dupeZ(u8, @typeName(Value)),
),
else => switch (Value) {
[]const u8,
[:0]const u8,
=> try md.put(key, try alloc.dupeZ(u8, value)),
else => |T| switch (@typeInfo(T)) {
.int => try md.put(
key,
try std.fmt.allocPrintSentinel(alloc, "{}", .{value}, 0),
),
else => {
@compileLog(T);
@compileError("unsupported type, see log");
},
},
},
}
}
};
/// Our VT stream handler for the Stream widget. This isn't public
/// because there is no reason to use this directly.
const VTHandler = struct {
/// The capture state, must be set before use. If null, then
/// events are dropped.
state: ?State,
/// True to pause this artificially.
paused: bool,
/// Current sequence number
current_seq: usize,
/// Exclude certain actions by tag.
filter_exclude: ActionTagSet,
filter_text: cimgui.c.ImGuiTextFilter,
const Stream = terminal.Stream(VTHandler);
pub const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag);
pub const State = struct {
/// The allocator to use for the events.
alloc: Allocator,
/// The terminal state at the time of the event.
terminal: *const terminal.Terminal,
/// The event ring to write events to.
events: *VTEvent.Ring,
};
pub const init: VTHandler = .{
.state = null,
.paused = false,
.current_seq = 1,
.filter_exclude = .initMany(&.{.print}),
.filter_text = .{},
};
pub fn deinit(self: *VTHandler) void {
// Required for the parser stream interface
_ = self;
}
pub fn vt(
self: *VTHandler,
comptime action: VTHandler.Stream.Action.Tag,
value: VTHandler.Stream.Action.Value(action),
) !void {
_ = self;
_ = value;
}
/// This is called with every single terminal action.
pub fn vtRaw(self: *VTHandler, action: terminal.Parser.Action) !bool {
const state: *State = if (self.state) |*s| s else return true;
const alloc = state.alloc;
const vt_events = state.events;
// We always increment the sequence number, even if we're paused or
// filter out the event. This helps show the user that there is a gap
// between events and roughly how large that gap was.
defer self.current_seq +%= 1;
// If we're manually paused, we ignore all events.
if (self.paused) return true;
// We ignore certain action types that are too noisy.
switch (action) {
.dcs_put, .apc_put => return true,
else => {},
}
// If we requested a specific type to be ignored, ignore it.
// We return true because we did "handle" it by ignoring it.
if (self.filter_exclude.contains(std.meta.activeTag(action))) return true;
// Build our event
var ev: VTEvent = try .init(
alloc,
state.terminal,
action,
);
ev.seq = self.current_seq;
errdefer ev.deinit(alloc);
// Check if the event passes the filter
if (!ev.passFilter(&self.filter_text)) {
ev.deinit(alloc);
return true;
}
const max_capacity = 100;
vt_events.append(ev) catch |err| switch (err) {
error.OutOfMemory => if (vt_events.capacity() < max_capacity) {
// We're out of memory, but we can allocate to our capacity.
const new_capacity = @min(vt_events.capacity() * 2, max_capacity);
try vt_events.resize(alloc, new_capacity);
try vt_events.append(ev);
} else {
var it = vt_events.iterator(.forward);
if (it.next()) |old_ev| old_ev.deinit(alloc);
vt_events.deleteOldest(1);
try vt_events.append(ev);
},
else => return err,
};
// Do NOT skip it, because we want to record more information
// about this event.
return false;
}
};
/// Enum representing keyboard navigation actions
const KeyAction = enum {
down,
none,
up,
};

View File

@@ -190,6 +190,7 @@ test {
_ = @import("surface_mouse.zig");
// Libraries
_ = @import("tripwire.zig");
_ = @import("benchmark/main.zig");
_ = @import("crash/main.zig");
_ = @import("datastruct/main.zig");

View File

@@ -53,4 +53,5 @@ pub const locales = [_][:0]const u8{
"zh_TW.UTF-8",
"hr_HR.UTF-8",
"lt_LT.UTF-8",
"lv_LV.UTF-8",
};

View File

@@ -19,6 +19,7 @@ pub const Metal = @import("renderer/Metal.zig");
pub const OpenGL = @import("renderer/OpenGL.zig");
pub const WebGL = @import("renderer/WebGL.zig");
pub const Options = @import("renderer/Options.zig");
pub const Overlay = @import("renderer/Overlay.zig");
pub const Thread = @import("renderer/Thread.zig");
pub const State = @import("renderer/State.zig");
pub const CursorStyle = cursor.Style;

415
src/renderer/Overlay.zig Normal file
View File

@@ -0,0 +1,415 @@
/// The debug overlay that can be drawn on top of the terminal
/// during the rendering process.
///
/// This is implemented by doing all the drawing on the CPU via z2d,
/// since the debug overlay isn't that common, z2d is pretty fast, and
/// it simplifies our implementation quite a bit by not relying on us
/// having a bunch of shaders that we have to write per-platform.
///
/// Initialize the overlay, apply features with `applyFeatures`, then
/// get the resulting image with `pendingImage` to upload to the GPU.
/// This works in concert with `renderer.image.State` to simplify. Draw
/// it on the GPU as an image composited on top of the terminal output.
const Overlay = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const z2d = @import("z2d");
const terminal = @import("../terminal/main.zig");
const size = @import("size.zig");
const Size = size.Size;
const CellSize = size.CellSize;
const Image = @import("image.zig").Image;
const log = std.log.scoped(.renderer_overlay);
/// The colors we use for overlays.
pub const Color = enum {
hyperlink, // light blue
semantic_prompt, // orange/gold
semantic_input, // cyan
pub fn rgb(self: Color) z2d.pixel.RGB {
return switch (self) {
.hyperlink => .{ .r = 180, .g = 180, .b = 255 },
.semantic_prompt => .{ .r = 255, .g = 200, .b = 64 },
.semantic_input => .{ .r = 64, .g = 200, .b = 255 },
};
}
/// The fill color for rectangles.
pub fn rectFill(self: Color) z2d.Pixel {
return self.alphaPixel(96);
}
/// The border color for rectangles.
pub fn rectBorder(self: Color) z2d.Pixel {
return self.alphaPixel(200);
}
/// The raw RGB as a pixel.
pub fn pixel(self: Color) z2d.Pixel {
return self.rgb().asPixel();
}
fn alphaPixel(self: Color, alpha: u8) z2d.Pixel {
var rgba: z2d.pixel.RGBA = .fromPixel(self.pixel());
rgba.a = alpha;
return rgba.multiply().asPixel();
}
};
/// The surface we're drawing our overlay to.
surface: z2d.Surface,
/// Cell size information so we can map grid coordinates to pixels.
cell_size: CellSize,
/// The set of available features and their configuration.
pub const Feature = union(enum) {
highlight_hyperlinks,
semantic_prompts,
};
pub const InitError = Allocator.Error || error{
// The terminal dimensions are invalid to support an overlay.
// Either too small or too big.
InvalidDimensions,
};
/// Initialize a new, blank overlay.
pub fn init(alloc: Allocator, sz: Size) InitError!Overlay {
// Our surface does NOT need to take into account padding because
// we render the overlay using the image subsystem and shaders which
// already take that into account.
const term_size = sz.terminal();
var sfc = z2d.Surface.initPixel(
.{ .rgba = .{ .r = 0, .g = 0, .b = 0, .a = 0 } },
alloc,
std.math.cast(i32, term_size.width) orelse
return error.InvalidDimensions,
std.math.cast(i32, term_size.height) orelse
return error.InvalidDimensions,
) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.InvalidWidth, error.InvalidHeight => return error.InvalidDimensions,
};
errdefer sfc.deinit(alloc);
return .{
.surface = sfc,
.cell_size = sz.cell,
};
}
pub fn deinit(self: *Overlay, alloc: Allocator) void {
self.surface.deinit(alloc);
}
/// Returns a pending image that can be used to copy, convert, upload, etc.
pub fn pendingImage(self: *const Overlay) Image.Pending {
return .{
.width = @intCast(self.surface.getWidth()),
.height = @intCast(self.surface.getHeight()),
.pixel_format = .rgba,
.data = @ptrCast(self.surface.image_surface_rgba.buf.ptr),
};
}
/// Clear the overlay.
pub fn reset(self: *Overlay) void {
self.surface.paintPixel(.{ .rgba = .{
.r = 0,
.g = 0,
.b = 0,
.a = 0,
} });
}
/// Apply the given features to this overlay. This will draw on top of
/// any pre-existing content in the overlay.
pub fn applyFeatures(
self: *Overlay,
alloc: Allocator,
state: *const terminal.RenderState,
features: []const Feature,
) void {
for (features) |f| switch (f) {
.highlight_hyperlinks => self.highlightHyperlinks(
alloc,
state,
),
.semantic_prompts => self.highlightSemanticPrompts(
alloc,
state,
),
};
}
/// Add rectangles around contiguous hyperlinks in the render state.
///
/// Note: this currently doesn't take into account unique hyperlink IDs
/// because the render state doesn't contain this. This will be added
/// later.
fn highlightHyperlinks(
self: *Overlay,
alloc: Allocator,
state: *const terminal.RenderState,
) void {
const border_color = Color.hyperlink.rectBorder();
const fill_color = Color.hyperlink.rectFill();
const row_slice = state.row_data.slice();
const row_raw = row_slice.items(.raw);
const row_cells = row_slice.items(.cells);
for (row_raw, row_cells, 0..) |row, cells, y| {
if (!row.hyperlink) continue;
const cells_slice = cells.slice();
const raw_cells = cells_slice.items(.raw);
var x: usize = 0;
while (x < raw_cells.len) {
// Skip cells without hyperlinks
if (!raw_cells[x].hyperlink) {
x += 1;
continue;
}
// Found start of a hyperlink run
const start_x = x;
// Find end of contiguous hyperlink cells
while (x < raw_cells.len and raw_cells[x].hyperlink) x += 1;
const end_x = x;
self.highlightGridRect(
alloc,
start_x,
y,
end_x - start_x,
1,
border_color,
fill_color,
) catch |err| {
std.log.warn("Error drawing hyperlink border: {}", .{err});
};
}
}
}
fn highlightSemanticPrompts(
self: *Overlay,
alloc: Allocator,
state: *const terminal.RenderState,
) void {
const row_slice = state.row_data.slice();
const row_raw = row_slice.items(.raw);
const row_cells = row_slice.items(.cells);
// Highlight the row-level semantic prompt bars. The prompts are easy
// because they're part of the row metadata.
{
const prompt_border = Color.semantic_prompt.rectBorder();
const prompt_fill = Color.semantic_prompt.rectFill();
var y: usize = 0;
while (y < row_raw.len) {
// If its not a semantic prompt row, skip it.
if (row_raw[y].semantic_prompt == .none) {
y += 1;
continue;
}
// Find the full length of the semantic prompt row by connecting
// all continuations.
const start_y = y;
y += 1;
while (y < row_raw.len and
row_raw[y].semantic_prompt == .prompt_continuation)
{
y += 1;
}
const end_y = y; // Exclusive
const bar_width = @min(@as(usize, 5), self.cell_size.width);
self.highlightPixelRect(
alloc,
0,
start_y,
bar_width,
end_y - start_y,
prompt_border,
prompt_fill,
) catch |err| {
log.warn("Error drawing semantic prompt bar: {}", .{err});
};
}
}
// Highlight contiguous semantic cells within rows.
for (row_cells, 0..) |cells, y| {
const cells_slice = cells.slice();
const raw_cells = cells_slice.items(.raw);
var x: usize = 0;
while (x < raw_cells.len) {
const cell = raw_cells[x];
const content = cell.semantic_content;
const start_x = x;
// We skip output because its just the rest of the non-prompt
// parts and it makes the overlay too noisy.
if (cell.semantic_content == .output) {
x += 1;
continue;
}
// Find the end of this content.
x += 1;
while (x < raw_cells.len) {
const next = raw_cells[x];
if (next.semantic_content != content) break;
x += 1;
}
const color: Color = switch (content) {
.prompt => .semantic_prompt,
.input => .semantic_input,
.output => unreachable,
};
self.highlightGridRect(
alloc,
start_x,
y,
x - start_x,
1,
color.rectBorder(),
color.rectFill(),
) catch |err| {
log.warn("Error drawing semantic content highlight: {}", .{err});
};
}
}
}
/// Creates a rectangle for highlighting a grid region. x/y/width/height
/// are all in grid cells.
fn highlightGridRect(
self: *Overlay,
alloc: Allocator,
x: usize,
y: usize,
width: usize,
height: usize,
border_color: z2d.Pixel,
fill_color: z2d.Pixel,
) !void {
// All math below uses checked arithmetic to avoid overflows. The
// inputs aren't trusted and the path this is in isn't hot enough
// to wrarrant unsafe optimizations.
// Calculate our width/height in pixels.
const px_width = std.math.cast(i32, try std.math.mul(
usize,
width,
self.cell_size.width,
)) orelse return error.Overflow;
const px_height = std.math.cast(i32, try std.math.mul(
usize,
height,
self.cell_size.height,
)) orelse return error.Overflow;
// Calculate pixel coordinates
const start_x: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul(
usize,
x,
self.cell_size.width,
)) orelse return error.Overflow);
const start_y: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul(
usize,
y,
self.cell_size.height,
)) orelse return error.Overflow);
const end_x: f64 = start_x + @as(f64, @floatFromInt(px_width));
const end_y: f64 = start_y + @as(f64, @floatFromInt(px_height));
// Grab our context to draw
var ctx: z2d.Context = .init(alloc, &self.surface);
defer ctx.deinit();
// Don't need AA because we use sharp edges
ctx.setAntiAliasingMode(.none);
// Can use hairline since we have 1px borders
ctx.setHairline(true);
// Draw rectangle path
try ctx.moveTo(start_x, start_y);
try ctx.lineTo(end_x, start_y);
try ctx.lineTo(end_x, end_y);
try ctx.lineTo(start_x, end_y);
try ctx.closePath();
// Fill
ctx.setSourceToPixel(fill_color);
try ctx.fill();
// Border
ctx.setLineWidth(1);
ctx.setSourceToPixel(border_color);
try ctx.stroke();
}
/// Creates a rectangle for highlighting a region. x/y are grid cells and
/// width/height are pixels.
fn highlightPixelRect(
self: *Overlay,
alloc: Allocator,
x: usize,
y: usize,
width_px: usize,
height: usize,
border_color: z2d.Pixel,
fill_color: z2d.Pixel,
) !void {
const px_width = std.math.cast(i32, width_px) orelse return error.Overflow;
const px_height = std.math.cast(i32, try std.math.mul(
usize,
height,
self.cell_size.height,
)) orelse return error.Overflow;
const start_x: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul(
usize,
x,
self.cell_size.width,
)) orelse return error.Overflow);
const start_y: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul(
usize,
y,
self.cell_size.height,
)) orelse return error.Overflow);
const end_x: f64 = start_x + @as(f64, @floatFromInt(px_width));
const end_y: f64 = start_y + @as(f64, @floatFromInt(px_height));
var ctx: z2d.Context = .init(alloc, &self.surface);
defer ctx.deinit();
ctx.setAntiAliasingMode(.none);
ctx.setHairline(true);
try ctx.moveTo(start_x, start_y);
try ctx.lineTo(end_x, start_y);
try ctx.lineTo(end_x, end_y);
try ctx.lineTo(start_x, end_y);
try ctx.closePath();
ctx.setSourceToPixel(fill_color);
try ctx.fill();
ctx.setLineWidth(1);
ctx.setSourceToPixel(border_color);
try ctx.stroke();
}

View File

@@ -254,7 +254,7 @@ fn threadMain_(self: *Thread) !void {
);
// Start the draw timer
self.startDrawTimer();
self.syncDrawTimer();
// Run
log.debug("starting renderer thread", .{});
@@ -292,11 +292,28 @@ fn setQosClass(self: *const Thread) void {
}
}
fn startDrawTimer(self: *Thread) void {
// If our renderer doesn't support animations then we never run this.
if (!@hasDecl(rendererpkg.Renderer, "hasAnimations")) return;
if (!self.renderer.hasAnimations()) return;
if (self.config.custom_shader_animation == .false) return;
fn syncDrawTimer(self: *Thread) void {
skip: {
// If our renderer supports animations and has them, then we
// can apply draw timer based on custom shader animation configuration.
if (@hasDecl(rendererpkg.Renderer, "hasAnimations") and
self.renderer.hasAnimations())
{
// If our config says to always animate, we do so.
switch (self.config.custom_shader_animation) {
// Always animate
.always => break :skip,
// Only when focused
.true => if (self.flags.focused) break :skip,
// Never animate
.false => {},
}
}
// We're skipping the draw timer. Stop it on the next iteration.
self.draw_active = false;
return;
}
// Set our active state so it knows we're running. We set this before
// even checking the active state in case we have a pending shutdown.
@@ -316,11 +333,6 @@ fn startDrawTimer(self: *Thread) void {
);
}
fn stopDrawTimer(self: *Thread) void {
// This will stop the draw on the next iteration.
self.draw_active = false;
}
/// Drain the mailbox.
fn drainMailbox(self: *Thread) !void {
// There's probably a more elegant way to do this...
@@ -377,12 +389,10 @@ fn drainMailbox(self: *Thread) !void {
// Set it on the renderer
try self.renderer.setFocus(v);
if (!v) {
if (self.config.custom_shader_animation != .always) {
// Stop the draw timer
self.stopDrawTimer();
}
// We always resync our draw timer (may disable it)
self.syncDrawTimer();
if (!v) {
// If we're not focused, then we stop the cursor blink
if (self.cursor_c.state() == .active and
self.cursor_c_cancel.state() == .dead)
@@ -397,9 +407,6 @@ fn drainMailbox(self: *Thread) !void {
);
}
} else {
// Start the draw timer
self.startDrawTimer();
// If we're focused, we immediately show the cursor again
// and then restart the timer.
if (self.cursor_c.state() != .active) {
@@ -446,8 +453,7 @@ fn drainMailbox(self: *Thread) !void {
// Stop and start the draw timer to capture the new
// hasAnimations value.
self.stopDrawTimer();
self.startDrawTimer();
self.syncDrawTimer();
},
.search_viewport_matches => |v| {
@@ -466,7 +472,9 @@ fn drainMailbox(self: *Thread) !void {
self.renderer.search_matches_dirty = true;
},
.inspector => |v| self.flags.has_inspector = v,
.inspector => |v| {
self.flags.has_inspector = v;
},
.macos_display_id => |v| {
if (@hasDecl(rendererpkg.Renderer, "setMacOSDisplayID")) {
@@ -598,11 +606,6 @@ fn renderCallback(
return .disarm;
};
// If we have an inspector, let the app know we want to rerender that.
if (t.flags.has_inspector) {
_ = t.app_mailbox.push(.{ .redraw_inspector = t.surface }, .{ .instant = {} });
}
// Update our frame data
t.renderer.updateFrame(
t.state,

View File

@@ -17,10 +17,9 @@ const noMinContrast = cellpkg.noMinContrast;
const constraintWidth = cellpkg.constraintWidth;
const isCovering = cellpkg.isCovering;
const rowNeverExtendBg = @import("row.zig").neverExtendBg;
const Overlay = @import("Overlay.zig");
const imagepkg = @import("image.zig");
const Image = imagepkg.Image;
const ImageMap = imagepkg.ImageMap;
const ImagePlacementList = std.ArrayListUnmanaged(imagepkg.Placement);
const ImageState = imagepkg.State;
const shadertoy = @import("shadertoy.zig");
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
@@ -169,11 +168,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
font_shaper_cache: font.ShaperCache,
/// The images that we may render.
images: ImageMap = .{},
image_placements: ImagePlacementList = .{},
image_bg_end: u32 = 0,
image_text_end: u32 = 0,
image_virtual: bool = false,
images: ImageState = .empty,
/// Background image, if we have one.
bg_image: ?imagepkg.Image = null,
@@ -227,6 +222,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// a large screen.
terminal_state_frame_count: usize = 0,
/// Our overlay state, if any.
overlay: ?Overlay = null,
const HighlightTag = enum(u8) {
search_match,
search_match_selected,
@@ -787,6 +785,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}
pub fn deinit(self: *Self) void {
if (self.overlay) |*overlay| overlay.deinit(self.alloc);
self.terminal_state.deinit(self.alloc);
if (self.search_selected_match) |*m| m.arena.deinit();
if (self.search_matches) |*m| m.arena.deinit();
@@ -806,12 +805,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.config.deinit();
{
var it = self.images.iterator();
while (it.next()) |kv| kv.value_ptr.image.deinit(self.alloc);
self.images.deinit(self.alloc);
}
self.image_placements.deinit(self.alloc);
self.images.deinit(self.alloc);
if (self.bg_image) |img| img.deinit(self.alloc);
@@ -1118,6 +1112,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
state: *renderer.State,
cursor_blink_visible: bool,
) Allocator.Error!void {
// const start = std.time.Instant.now() catch unreachable;
// const start_micro = std.time.microTimestamp();
// defer {
// const end = std.time.Instant.now() catch unreachable;
// log.warn(
// "[updateFrame time] start_micro={} duration={}ns",
// .{ start_micro, end.since(start) / std.time.ns_per_us },
// );
// }
// We fully deinit and reset the terminal state every so often
// so that a particularly large terminal state doesn't cause
// the renderer to hold on to retained memory.
@@ -1141,6 +1145,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
mouse: renderer.State.Mouse,
preedit: ?renderer.State.Preedit,
scrollbar: terminal.Scrollbar,
overlay_features: []const Overlay.Feature,
};
// Update all our data as tightly as possible within the mutex.
@@ -1190,10 +1195,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// If we have any virtual references, we must also rebuild our
// kitty state on every frame because any cell change can move
// an image.
if (state.terminal.screens.active.kitty_images.dirty or
self.image_virtual)
{
self.prepKittyGraphics(state.terminal);
if (self.images.kittyRequiresUpdate(state.terminal)) {
self.images.kittyUpdate(
self.alloc,
state.terminal,
.{
.width = self.grid_metrics.cell_width,
.height = self.grid_metrics.cell_height,
},
);
}
// Get our OSC8 links we're hovering if we have a mouse.
@@ -1215,11 +1225,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
};
};
const overlay_features: []const Overlay.Feature = overlay: {
const insp = state.inspector orelse break :overlay &.{};
const renderer_info = insp.rendererInfo();
break :overlay renderer_info.overlayFeatures(
arena_alloc,
) catch &.{};
};
break :critical .{
.links = links,
.mouse = state.mouse,
.preedit = preedit,
.scrollbar = scrollbar,
.overlay_features = overlay_features,
};
};
@@ -1288,6 +1307,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Reset our dirty state after updating.
defer self.terminal_state.dirty = .false;
// Rebuild the overlay image if we have one. We can do this
// outside of any critical areas.
self.rebuildOverlay(
critical.overlay_features,
) catch |err| {
log.warn(
"error rebuilding overlay surface err={}",
.{err},
);
};
// Acquire the draw mutex for all remaining state updates.
{
self.draw_mutex.lock();
@@ -1337,6 +1367,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
else => {},
};
// Prepare our overlay image for upload (or unload). This
// has to use our general allocator since it modifies
// state that survives frames.
self.images.overlayUpdate(
self.alloc,
self.overlay,
) catch |err| {
log.warn("error updating overlay images err={}", .{err});
};
// Update custom shader uniforms that depend on terminal state.
self.updateCustomShaderUniformsFromState();
}
@@ -1354,6 +1394,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self: *Self,
sync: bool,
) !void {
// const start = std.time.Instant.now() catch unreachable;
// const start_micro = std.time.microTimestamp();
// defer {
// const end = std.time.Instant.now() catch unreachable;
// log.warn(
// "[drawFrame time] start_micro={} duration={}ns",
// .{ start_micro, end.since(start) / std.time.ns_per_us },
// );
// }
// We hold a the draw mutex to prevent changes to any
// data we access while we're in the middle of drawing.
self.draw_mutex.lock();
@@ -1460,7 +1510,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}
// Upload images to the GPU as necessary.
try self.uploadKittyImages();
_ = self.images.upload(self.alloc, &self.api);
// Upload the background image to the GPU as necessary.
try self.uploadBackgroundImage();
@@ -1542,9 +1592,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Then we draw any kitty images that need
// to be behind text AND cell backgrounds.
try self.drawImagePlacements(
self.images.draw(
&self.api,
self.shaders.pipelines.image,
&pass,
self.image_placements.items[0..self.image_bg_end],
.kitty_below_bg,
);
// Then we draw any opaque cell backgrounds.
@@ -1556,9 +1608,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
});
// Kitty images between cell backgrounds and text.
try self.drawImagePlacements(
self.images.draw(
&self.api,
self.shaders.pipelines.image,
&pass,
self.image_placements.items[self.image_bg_end..self.image_text_end],
.kitty_below_text,
);
// Text.
@@ -1581,9 +1635,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
});
// Kitty images in front of text.
try self.drawImagePlacements(
self.images.draw(
&self.api,
self.shaders.pipelines.image,
&pass,
self.image_placements.items[self.image_text_end..],
.kitty_above_text,
);
// Debug overlay. We do this before any custom shader state
// because our debug overlay is aligned with the grid.
if (self.overlay != null) self.images.draw(
&self.api,
self.shaders.pipelines.image,
&pass,
.overlay,
);
}
@@ -1639,426 +1704,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.swap_chain.releaseFrame();
}
fn drawImagePlacements(
self: *Self,
pass: *RenderPass,
placements: []const imagepkg.Placement,
) !void {
if (placements.len == 0) return;
for (placements) |p| {
// Look up the image
const image = self.images.get(p.image_id) orelse {
log.warn("image not found for placement image_id={}", .{p.image_id});
continue;
};
// Get the texture
const texture = switch (image.image) {
.ready,
.unload_ready,
=> |t| t,
else => {
log.warn("image not ready for placement image_id={}", .{p.image_id});
continue;
},
};
// Create our vertex buffer, which is always exactly one item.
// future(mitchellh): we can group rendering multiple instances of a single image
var buf = try Buffer(shaderpkg.Image).initFill(
self.api.imageBufferOptions(),
&.{.{
.grid_pos = .{
@as(f32, @floatFromInt(p.x)),
@as(f32, @floatFromInt(p.y)),
},
.cell_offset = .{
@as(f32, @floatFromInt(p.cell_offset_x)),
@as(f32, @floatFromInt(p.cell_offset_y)),
},
.source_rect = .{
@as(f32, @floatFromInt(p.source_x)),
@as(f32, @floatFromInt(p.source_y)),
@as(f32, @floatFromInt(p.source_width)),
@as(f32, @floatFromInt(p.source_height)),
},
.dest_size = .{
@as(f32, @floatFromInt(p.width)),
@as(f32, @floatFromInt(p.height)),
},
}},
);
defer buf.deinit();
pass.step(.{
.pipeline = self.shaders.pipelines.image,
.buffers = &.{buf.buffer},
.textures = &.{texture},
.draw = .{
.type = .triangle_strip,
.vertex_count = 4,
},
});
}
}
/// This goes through the Kitty graphic placements and accumulates the
/// placements we need to render on our viewport.
fn prepKittyGraphics(
self: *Self,
t: *terminal.Terminal,
) void {
self.draw_mutex.lock();
defer self.draw_mutex.unlock();
const storage = &t.screens.active.kitty_images;
defer storage.dirty = false;
// We always clear our previous placements no matter what because
// we rebuild them from scratch.
self.image_placements.clearRetainingCapacity();
self.image_virtual = false;
// Go through our known images and if there are any that are no longer
// in use then mark them to be freed.
//
// This never conflicts with the below because a placement can't
// reference an image that doesn't exist.
{
var it = self.images.iterator();
while (it.next()) |kv| {
if (storage.imageById(kv.key_ptr.*) == null) {
kv.value_ptr.image.markForUnload();
}
}
}
// The top-left and bottom-right corners of our viewport in screen
// points. This lets us determine offsets and containment of placements.
const top = t.screens.active.pages.getTopLeft(.viewport);
const bot = t.screens.active.pages.getBottomRight(.viewport).?;
const top_y = t.screens.active.pages.pointFromPin(.screen, top).?.screen.y;
const bot_y = t.screens.active.pages.pointFromPin(.screen, bot).?.screen.y;
// Go through the placements and ensure the image is
// on the GPU or else is ready to be sent to the GPU.
var it = storage.placements.iterator();
while (it.next()) |kv| {
const p = kv.value_ptr;
// Special logic based on location
switch (p.location) {
.pin => {},
.virtual => {
// We need to mark virtual placements on our renderer so that
// we know to rebuild in more scenarios since cell changes can
// now trigger placement changes.
self.image_virtual = true;
// We also continue out because virtual placements are
// only triggered by the unicode placeholder, not by the
// placement itself.
continue;
},
}
// Get the image for the placement
const image = storage.imageById(kv.key_ptr.image_id) orelse {
log.warn(
"missing image for placement, ignoring image_id={}",
.{kv.key_ptr.image_id},
);
continue;
};
self.prepKittyPlacement(
t,
top_y,
bot_y,
&image,
p,
) catch |err| {
// For errors we log and continue. We try to place
// other placements even if one fails.
log.warn("error preparing kitty placement err={}", .{err});
};
}
// If we have virtual placements then we need to scan for placeholders.
if (self.image_virtual) {
var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot);
while (v_it.next()) |virtual_p| {
self.prepKittyVirtualPlacement(
t,
&virtual_p,
) catch |err| {
// For errors we log and continue. We try to place
// other placements even if one fails.
log.warn("error preparing kitty placement err={}", .{err});
};
}
}
// Sort the placements by their Z value.
std.mem.sortUnstable(
imagepkg.Placement,
self.image_placements.items,
{},
struct {
fn lessThan(
ctx: void,
lhs: imagepkg.Placement,
rhs: imagepkg.Placement,
) bool {
_ = ctx;
return lhs.z < rhs.z or (lhs.z == rhs.z and lhs.image_id < rhs.image_id);
}
}.lessThan,
);
// Find our indices. The values are sorted by z so we can
// find the first placement out of bounds to find the limits.
var bg_end: ?u32 = null;
var text_end: ?u32 = null;
const bg_limit = std.math.minInt(i32) / 2;
for (self.image_placements.items, 0..) |p, i| {
if (bg_end == null and p.z >= bg_limit) {
bg_end = @intCast(i);
}
if (text_end == null and p.z >= 0) {
text_end = @intCast(i);
}
}
// If we didn't see any images with a z > the bg limit,
// then our bg end is the end of our placement list.
self.image_bg_end =
bg_end orelse @intCast(self.image_placements.items.len);
// Same idea for the image_text_end.
self.image_text_end =
text_end orelse @intCast(self.image_placements.items.len);
}
fn prepKittyVirtualPlacement(
self: *Self,
t: *terminal.Terminal,
p: *const terminal.kitty.graphics.unicode.Placement,
) PrepKittyImageError!void {
const storage = &t.screens.active.kitty_images;
const image = storage.imageById(p.image_id) orelse {
log.warn(
"missing image for virtual placement, ignoring image_id={}",
.{p.image_id},
);
return;
};
const rp = p.renderPlacement(
storage,
&image,
self.grid_metrics.cell_width,
self.grid_metrics.cell_height,
) catch |err| {
log.warn("error rendering virtual placement err={}", .{err});
return;
};
// If our placement is zero sized then we don't do anything.
if (rp.dest_width == 0 or rp.dest_height == 0) return;
const viewport: terminal.point.Point = t.screens.active.pages.pointFromPin(
.viewport,
rp.top_left,
) orelse {
// This is unreachable with virtual placements because we should
// only ever be looking at virtual placements that are in our
// viewport in the renderer and virtual placements only ever take
// up one row.
unreachable;
};
// Prepare the image for the GPU and store the placement.
try self.prepKittyImage(&image);
try self.image_placements.append(self.alloc, .{
.image_id = image.id,
.x = @intCast(rp.top_left.x),
.y = @intCast(viewport.viewport.y),
.z = -1,
.width = rp.dest_width,
.height = rp.dest_height,
.cell_offset_x = rp.offset_x,
.cell_offset_y = rp.offset_y,
.source_x = rp.source_x,
.source_y = rp.source_y,
.source_width = rp.source_width,
.source_height = rp.source_height,
});
}
/// Get the viewport-relative position for this
/// placement and add it to the placements list.
fn prepKittyPlacement(
self: *Self,
t: *terminal.Terminal,
top_y: u32,
bot_y: u32,
image: *const terminal.kitty.graphics.Image,
p: *const terminal.kitty.graphics.ImageStorage.Placement,
) PrepKittyImageError!void {
// Get the rect for the placement. If this placement doesn't have
// a rect then its virtual or something so skip it.
const rect = p.rect(image.*, t) orelse return;
// This is expensive but necessary.
const img_top_y = t.screens.active.pages.pointFromPin(.screen, rect.top_left).?.screen.y;
const img_bot_y = t.screens.active.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y;
// If the selection isn't within our viewport then skip it.
if (img_top_y > bot_y) return;
if (img_bot_y < top_y) return;
// We need to prep this image for upload if it isn't in the
// cache OR it is in the cache but the transmit time doesn't
// match meaning this image is different.
try self.prepKittyImage(image);
// Calculate the dimensions of our image, taking in to
// account the rows / columns specified by the placement.
const dest_size = p.calculatedSize(image.*, t);
// Calculate the source rectangle
const source_x = @min(image.width, p.source_x);
const source_y = @min(image.height, p.source_y);
const source_width = if (p.source_width > 0)
@min(image.width - source_x, p.source_width)
else
image.width;
const source_height = if (p.source_height > 0)
@min(image.height - source_y, p.source_height)
else
image.height;
// Get the viewport-relative Y position of the placement.
const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y));
// Accumulate the placement
if (dest_size.width > 0 and dest_size.height > 0) {
try self.image_placements.append(self.alloc, .{
.image_id = image.id,
.x = @intCast(rect.top_left.x),
.y = y_pos,
.z = p.z,
.width = dest_size.width,
.height = dest_size.height,
.cell_offset_x = p.x_offset,
.cell_offset_y = p.y_offset,
.source_x = source_x,
.source_y = source_y,
.source_width = source_width,
.source_height = source_height,
});
}
}
const PrepKittyImageError = error{
OutOfMemory,
ImageConversionError,
};
/// Prepare the provided image for upload to the GPU by copying its
/// data with our allocator and setting it to the pending state.
fn prepKittyImage(
self: *Self,
image: *const terminal.kitty.graphics.Image,
) PrepKittyImageError!void {
// If this image exists and its transmit time is the same we assume
// it is the identical image so we don't need to send it to the GPU.
const gop = try self.images.getOrPut(self.alloc, image.id);
if (gop.found_existing and
gop.value_ptr.transmit_time.order(image.transmit_time) == .eq)
{
return;
}
// Copy the data into the pending state.
const data = if (self.alloc.dupe(
u8,
image.data,
)) |v| v else |_| {
if (!gop.found_existing) {
// If this is a new entry we can just remove it since it
// was never sent to the GPU.
_ = self.images.remove(image.id);
} else {
// If this was an existing entry, it is invalid and
// we must unload it.
gop.value_ptr.image.markForUnload();
}
return error.OutOfMemory;
};
// Note: we don't need to errdefer free the data because it is
// put into the map immediately below and our errdefer to
// handle our map state will fix this up.
// Store it in the map
const new_image: Image = .{
.pending = .{
.width = image.width,
.height = image.height,
.pixel_format = switch (image.format) {
.gray => .gray,
.gray_alpha => .gray_alpha,
.rgb => .rgb,
.rgba => .rgba,
.png => unreachable, // should be decoded by now
},
.data = data.ptr,
},
};
if (!gop.found_existing) {
gop.value_ptr.* = .{
.image = new_image,
.transmit_time = undefined,
};
} else {
gop.value_ptr.image.markForReplace(
self.alloc,
new_image,
);
}
// If any error happens, we unload the image and it is invalid.
errdefer gop.value_ptr.image.markForUnload();
gop.value_ptr.image.prepForUpload(self.alloc) catch |err| {
log.warn("error preparing kitty image for upload err={}", .{err});
return error.ImageConversionError;
};
gop.value_ptr.transmit_time = image.transmit_time;
}
/// Upload any images to the GPU that need to be uploaded,
/// and remove any images that are no longer needed on the GPU.
fn uploadKittyImages(self: *Self) !void {
var image_it = self.images.iterator();
while (image_it.next()) |kv| {
const img = &kv.value_ptr.image;
if (img.isUnloading()) {
img.deinit(self.alloc);
self.images.removeByPtr(kv.key_ptr);
continue;
}
if (img.isPending()) try img.upload(self.alloc, &self.api);
}
}
/// Call this any time the background image path changes.
///
/// Caller must hold the draw mutex.
@@ -2531,6 +2176,76 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}
}
/// Build the overlay as configured. Returns null if there is no
/// overlay currently configured.
fn rebuildOverlay(
self: *Self,
features: []const Overlay.Feature,
) Overlay.InitError!void {
// const start = std.time.Instant.now() catch unreachable;
// const start_micro = std.time.microTimestamp();
// defer {
// const end = std.time.Instant.now() catch unreachable;
// log.warn(
// "[rebuildOverlay time] start_micro={} duration={}ns",
// .{ start_micro, end.since(start) / std.time.ns_per_us },
// );
// }
const alloc = self.alloc;
// If we have no features enabled, don't build an overlay.
// If we had a previous overlay, deallocate it.
if (features.len == 0) {
if (self.overlay) |*old| {
old.deinit(alloc);
self.overlay = null;
}
return;
}
// If we had a previous overlay, clear it. Otherwise, init.
const overlay: *Overlay = overlay: {
if (self.overlay) |*v| existing: {
// Verify that our overlay size matches our screen
// size as we know it now. If not, deinit and reinit.
// Note: these intCasts are always safe because z2d
// stores as i32 but we always init with a u32.
const width: u32 = @intCast(v.surface.getWidth());
const height: u32 = @intCast(v.surface.getHeight());
const term_size = self.size.terminal();
if (width != term_size.width or
height != term_size.height) break :existing;
// We also depend on cell size.
if (v.cell_size.width != self.size.cell.width or
v.cell_size.height != self.size.cell.height) break :existing;
// Everything matches, so we can just reset the surface
// and redraw.
v.reset();
break :overlay v;
}
// If we reached this point we want to reset our overlay.
if (self.overlay) |*v| {
v.deinit(alloc);
self.overlay = null;
}
assert(self.overlay == null);
const new: Overlay = try .init(alloc, self.size);
self.overlay = new;
break :overlay &self.overlay.?;
};
overlay.applyFeatures(
alloc,
&self.terminal_state,
features,
);
}
const PreeditRange = struct {
y: terminal.size.CellCountInt,
x: [2]terminal.size.CellCountInt,

View File

@@ -2,16 +2,629 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = @import("../quirks.zig").inlineAssert;
const wuffs = @import("wuffs");
const terminal = @import("../terminal/main.zig");
const Renderer = @import("../renderer.zig").Renderer;
const GraphicsAPI = Renderer.API;
const Texture = GraphicsAPI.Texture;
const CellSize = @import("size.zig").CellSize;
const Overlay = @import("Overlay.zig");
const log = std.log.scoped(.renderer_image);
/// Generic image rendering state for the renderer. This stores all
/// images and their placements and exposes only a limited public API
/// for adding images and placements and drawing them.
pub const State = struct {
/// The full image state for the renderer that specifies what images
/// need to be uploaded, pruned, etc.
images: ImageMap,
/// The placements for the Kitty image protocol.
kitty_placements: std.ArrayListUnmanaged(Placement),
/// The end index (exclusive) for placements that should be
/// drawn below the background, below the text, etc.
kitty_bg_end: u32,
kitty_text_end: u32,
/// True if there are any virtual placements. This needs to be known
/// because virtual placements need to be recalculated more often
/// on frame builds and are generally more expensive to handle.
kitty_virtual: bool,
/// Overlays
overlay_placements: std.ArrayListUnmanaged(Placement),
pub const empty: State = .{
.images = .empty,
.kitty_placements = .empty,
.kitty_bg_end = 0,
.kitty_text_end = 0,
.kitty_virtual = false,
.overlay_placements = .empty,
};
pub fn deinit(self: *State, alloc: Allocator) void {
{
var it = self.images.iterator();
while (it.next()) |kv| kv.value_ptr.image.deinit(alloc);
self.images.deinit(alloc);
}
self.kitty_placements.deinit(alloc);
self.overlay_placements.deinit(alloc);
}
/// Upload any images to the GPU that need to be uploaded,
/// and remove any images that are no longer needed on the GPU.
///
/// If any uploads fail, they are ignored. The return value
/// can be used to detect if upload was a total success (true)
/// or not (false).
pub fn upload(
self: *State,
alloc: Allocator,
api: *GraphicsAPI,
) bool {
var success: bool = true;
var image_it = self.images.iterator();
while (image_it.next()) |kv| {
const img = &kv.value_ptr.image;
if (img.isUnloading()) {
img.deinit(alloc);
self.images.removeByPtr(kv.key_ptr);
continue;
}
if (img.isPending()) {
img.upload(
alloc,
api,
) catch |err| {
log.warn("error uploading image to GPU err={}", .{err});
success = false;
};
}
}
return success;
}
pub const DrawPlacements = enum {
kitty_below_bg,
kitty_below_text,
kitty_above_text,
overlay,
};
/// Draw the given named set of placements.
///
/// Any placements that have non-uploaded images are ignored. Any
/// graphics API errors during drawing are also ignored.
pub fn draw(
self: *State,
api: *GraphicsAPI,
pipeline: GraphicsAPI.Pipeline,
pass: *GraphicsAPI.RenderPass,
placement_type: DrawPlacements,
) void {
const placements: []const Placement = switch (placement_type) {
.kitty_below_bg => self.kitty_placements.items[0..self.kitty_bg_end],
.kitty_below_text => self.kitty_placements.items[self.kitty_bg_end..self.kitty_text_end],
.kitty_above_text => self.kitty_placements.items[self.kitty_text_end..],
.overlay => self.overlay_placements.items,
};
for (placements) |p| {
// Look up the image
const image = self.images.get(p.image_id) orelse {
log.warn("image not found for placement image_id={}", .{p.image_id});
continue;
};
// Get the texture
const texture = switch (image.image) {
.ready,
.unload_ready,
=> |t| t,
else => {
log.warn("image not ready for placement image_id={}", .{p.image_id});
continue;
},
};
// Create our vertex buffer, which is always exactly one item.
// future(mitchellh): we can group rendering multiple instances of a single image
var buf = GraphicsAPI.Buffer(GraphicsAPI.shaders.Image).initFill(
api.imageBufferOptions(),
&.{.{
.grid_pos = .{
@as(f32, @floatFromInt(p.x)),
@as(f32, @floatFromInt(p.y)),
},
.cell_offset = .{
@as(f32, @floatFromInt(p.cell_offset_x)),
@as(f32, @floatFromInt(p.cell_offset_y)),
},
.source_rect = .{
@as(f32, @floatFromInt(p.source_x)),
@as(f32, @floatFromInt(p.source_y)),
@as(f32, @floatFromInt(p.source_width)),
@as(f32, @floatFromInt(p.source_height)),
},
.dest_size = .{
@as(f32, @floatFromInt(p.width)),
@as(f32, @floatFromInt(p.height)),
},
}},
) catch |err| {
log.warn("error creating image vertex buffer err={}", .{err});
continue;
};
defer buf.deinit();
pass.step(.{
.pipeline = pipeline,
.buffers = &.{buf.buffer},
.textures = &.{texture},
.draw = .{
.type = .triangle_strip,
.vertex_count = 4,
},
});
}
}
/// Update our overlay state. Null value deletes any existing overlay.
pub fn overlayUpdate(
self: *State,
alloc: Allocator,
overlay_: ?Overlay,
) !void {
const overlay = overlay_ orelse {
// If we don't have an overlay, remove any existing one.
if (self.images.getPtr(.overlay)) |data| {
data.image.markForUnload();
}
return;
};
// For transmit time we always just use the current time
// and overwrite the overlay.
const transmit_time = try std.time.Instant.now();
// Ensure we have space for our overlay placement. Do this before
// we upload our image so we don't have to deal with cleaning
// that up.
self.overlay_placements.clearRetainingCapacity();
try self.overlay_placements.ensureUnusedCapacity(alloc, 1);
// Setup our image.
const pending = overlay.pendingImage();
try self.prepImage(
alloc,
.overlay,
transmit_time,
pending,
);
errdefer comptime unreachable;
// Setup our placement
self.overlay_placements.appendAssumeCapacity(.{
.image_id = .overlay,
.x = 0,
.y = 0,
.z = 0,
.width = pending.width,
.height = pending.height,
.cell_offset_x = 0,
.cell_offset_y = 0,
.source_x = 0,
.source_y = 0,
.source_width = pending.width,
.source_height = pending.height,
});
}
/// Returns true if the Kitty graphics state requires an update based
/// on the terminal state and our internal state.
///
/// This does not read/write state used by drawing.
pub fn kittyRequiresUpdate(
self: *const State,
t: *const terminal.Terminal,
) bool {
// If the terminal kitty image state is dirty, we must update.
if (t.screens.active.kitty_images.dirty) return true;
// If we have any virtual references, we must also rebuild our
// kitty state on every frame because any cell change can move
// an image. If the virtual placements were removed, this will
// be set to false on the next update.
if (self.kitty_virtual) return true;
return false;
}
/// Update the Kitty graphics state from the terminal.
///
/// This reads/writes state used by drawing.
pub fn kittyUpdate(
self: *State,
alloc: Allocator,
t: *const terminal.Terminal,
cell_size: CellSize,
) void {
const storage = &t.screens.active.kitty_images;
defer storage.dirty = false;
// We always clear our previous placements no matter what because
// we rebuild them from scratch.
self.kitty_placements.clearRetainingCapacity();
self.kitty_virtual = false;
// Go through our known images and if there are any that are no longer
// in use then mark them to be freed.
//
// This never conflicts with the below because a placement can't
// reference an image that doesn't exist.
{
var it = self.images.iterator();
while (it.next()) |kv| {
switch (kv.key_ptr.*) {
// We're only looking at Kitty images
.kitty => |id| if (storage.imageById(id) == null) {
kv.value_ptr.image.markForUnload();
},
.overlay => {},
}
}
}
// The top-left and bottom-right corners of our viewport in screen
// points. This lets us determine offsets and containment of placements.
const top = t.screens.active.pages.getTopLeft(.viewport);
const bot = t.screens.active.pages.getBottomRight(.viewport).?;
const top_y = t.screens.active.pages.pointFromPin(.screen, top).?.screen.y;
const bot_y = t.screens.active.pages.pointFromPin(.screen, bot).?.screen.y;
// Go through the placements and ensure the image is
// on the GPU or else is ready to be sent to the GPU.
var it = storage.placements.iterator();
while (it.next()) |kv| {
const p = kv.value_ptr;
// Special logic based on location
switch (p.location) {
.pin => {},
.virtual => {
// We need to mark virtual placements on our renderer so that
// we know to rebuild in more scenarios since cell changes can
// now trigger placement changes.
self.kitty_virtual = true;
// We also continue out because virtual placements are
// only triggered by the unicode placeholder, not by the
// placement itself.
continue;
},
}
// Get the image for the placement
const image = storage.imageById(kv.key_ptr.image_id) orelse {
log.warn(
"missing image for placement, ignoring image_id={}",
.{kv.key_ptr.image_id},
);
continue;
};
self.prepKittyPlacement(
alloc,
t,
top_y,
bot_y,
&image,
p,
) catch |err| {
// For errors we log and continue. We try to place
// other placements even if one fails.
log.warn("error preparing kitty placement err={}", .{err});
};
}
// If we have virtual placements then we need to scan for placeholders.
if (self.kitty_virtual) {
var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot);
while (v_it.next()) |virtual_p| {
self.prepKittyVirtualPlacement(
alloc,
t,
&virtual_p,
cell_size,
) catch |err| {
// For errors we log and continue. We try to place
// other placements even if one fails.
log.warn("error preparing kitty placement err={}", .{err});
};
}
}
// Sort the placements by their Z value.
std.mem.sortUnstable(
Placement,
self.kitty_placements.items,
{},
struct {
fn lessThan(
ctx: void,
lhs: Placement,
rhs: Placement,
) bool {
_ = ctx;
return lhs.z < rhs.z or
(lhs.z == rhs.z and lhs.image_id.zLessThan(rhs.image_id));
}
}.lessThan,
);
// Find our indices. The values are sorted by z so we can
// find the first placement out of bounds to find the limits.
const bg_limit = std.math.minInt(i32) / 2;
var bg_end: ?u32 = null;
var text_end: ?u32 = null;
for (self.kitty_placements.items, 0..) |p, i| {
if (bg_end == null and p.z >= bg_limit) bg_end = @intCast(i);
if (text_end == null and p.z >= 0) text_end = @intCast(i);
}
// If we didn't see any images with a z > the bg limit,
// then our bg end is the end of our placement list.
self.kitty_bg_end =
bg_end orelse @intCast(self.kitty_placements.items.len);
// Same idea for the image_text_end.
self.kitty_text_end =
text_end orelse @intCast(self.kitty_placements.items.len);
}
const PrepImageError = error{
OutOfMemory,
ImageConversionError,
};
/// Get the viewport-relative position for this
/// placement and add it to the placements list.
fn prepKittyPlacement(
self: *State,
alloc: Allocator,
t: *const terminal.Terminal,
top_y: u32,
bot_y: u32,
image: *const terminal.kitty.graphics.Image,
p: *const terminal.kitty.graphics.ImageStorage.Placement,
) PrepImageError!void {
// Get the rect for the placement. If this placement doesn't have
// a rect then its virtual or something so skip it.
const rect = p.rect(image.*, t) orelse return;
// This is expensive but necessary.
const img_top_y = t.screens.active.pages.pointFromPin(.screen, rect.top_left).?.screen.y;
const img_bot_y = t.screens.active.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y;
// If the selection isn't within our viewport then skip it.
if (img_top_y > bot_y) return;
if (img_bot_y < top_y) return;
// We need to prep this image for upload if it isn't in the
// cache OR it is in the cache but the transmit time doesn't
// match meaning this image is different.
try self.prepKittyImage(alloc, image);
// Calculate the dimensions of our image, taking in to
// account the rows / columns specified by the placement.
const dest_size = p.calculatedSize(image.*, t);
// Calculate the source rectangle
const source_x = @min(image.width, p.source_x);
const source_y = @min(image.height, p.source_y);
const source_width = if (p.source_width > 0)
@min(image.width - source_x, p.source_width)
else
image.width;
const source_height = if (p.source_height > 0)
@min(image.height - source_y, p.source_height)
else
image.height;
// Get the viewport-relative Y position of the placement.
const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y));
// Accumulate the placement
if (dest_size.width > 0 and dest_size.height > 0) {
try self.kitty_placements.append(alloc, .{
.image_id = .{ .kitty = image.id },
.x = @intCast(rect.top_left.x),
.y = y_pos,
.z = p.z,
.width = dest_size.width,
.height = dest_size.height,
.cell_offset_x = p.x_offset,
.cell_offset_y = p.y_offset,
.source_x = source_x,
.source_y = source_y,
.source_width = source_width,
.source_height = source_height,
});
}
}
fn prepKittyVirtualPlacement(
self: *State,
alloc: Allocator,
t: *const terminal.Terminal,
p: *const terminal.kitty.graphics.unicode.Placement,
cell_size: CellSize,
) PrepImageError!void {
const storage = &t.screens.active.kitty_images;
const image = storage.imageById(p.image_id) orelse {
log.warn(
"missing image for virtual placement, ignoring image_id={}",
.{p.image_id},
);
return;
};
const rp = p.renderPlacement(
storage,
&image,
cell_size.width,
cell_size.height,
) catch |err| {
log.warn("error rendering virtual placement err={}", .{err});
return;
};
// If our placement is zero sized then we don't do anything.
if (rp.dest_width == 0 or rp.dest_height == 0) return;
const viewport: terminal.point.Point = t.screens.active.pages.pointFromPin(
.viewport,
rp.top_left,
) orelse {
// This is unreachable with virtual placements because we should
// only ever be looking at virtual placements that are in our
// viewport in the renderer and virtual placements only ever take
// up one row.
unreachable;
};
// Prepare the image for the GPU and store the placement.
try self.prepKittyImage(alloc, &image);
try self.kitty_placements.append(alloc, .{
.image_id = .{ .kitty = image.id },
.x = @intCast(rp.top_left.x),
.y = @intCast(viewport.viewport.y),
.z = -1,
.width = rp.dest_width,
.height = rp.dest_height,
.cell_offset_x = rp.offset_x,
.cell_offset_y = rp.offset_y,
.source_x = rp.source_x,
.source_y = rp.source_y,
.source_width = rp.source_width,
.source_height = rp.source_height,
});
}
/// Prepare an image for upload to the GPU.
fn prepImage(
self: *State,
alloc: Allocator,
id: Id,
transmit_time: std.time.Instant,
pending: Image.Pending,
) PrepImageError!void {
// If this image exists and its transmit time is the same we assume
// it is the identical image so we don't need to send it to the GPU.
const gop = try self.images.getOrPut(alloc, id);
if (gop.found_existing and
gop.value_ptr.transmit_time.order(transmit_time) == .eq)
{
return;
}
// Copy the data so we own it.
const data = if (alloc.dupe(
u8,
pending.dataSlice(),
)) |v| v else |_| {
if (!gop.found_existing) {
// If this is a new entry we can just remove it since it
// was never sent to the GPU.
_ = self.images.remove(id);
} else {
// If this was an existing entry, it is invalid and
// we must unload it.
gop.value_ptr.image.markForUnload();
}
return error.OutOfMemory;
};
// Note: we don't need to errdefer free the data because it is
// put into the map immediately below and our errdefer to
// handle our map state will fix this up.
// Store it in the map
const new_image: Image = .{
.pending = .{
.width = pending.width,
.height = pending.height,
.pixel_format = pending.pixel_format,
.data = data.ptr,
},
};
if (!gop.found_existing) {
gop.value_ptr.* = .{
.image = new_image,
.transmit_time = undefined,
};
} else {
gop.value_ptr.image.markForReplace(
alloc,
new_image,
);
}
// If any error happens, we unload the image and it is invalid.
errdefer gop.value_ptr.image.markForUnload();
gop.value_ptr.image.prepForUpload(alloc) catch |err| {
log.warn("error preparing image for upload err={}", .{err});
return error.ImageConversionError;
};
gop.value_ptr.transmit_time = transmit_time;
}
/// Prepare the provided Kitty image for upload to the GPU by copying its
/// data with our allocator and setting it to the pending state.
fn prepKittyImage(
self: *State,
alloc: Allocator,
image: *const terminal.kitty.graphics.Image,
) PrepImageError!void {
try self.prepImage(
alloc,
.{ .kitty = image.id },
image.transmit_time,
.{
.width = image.width,
.height = image.height,
.pixel_format = switch (image.format) {
.gray => .gray,
.gray_alpha => .gray_alpha,
.rgb => .rgb,
.rgba => .rgba,
.png => unreachable, // should be decoded by now
},
// constCasts are always gross but this one is safe is because
// the data is only read from here and copied into its own
// buffer.
.data = @constCast(image.data.ptr),
},
);
}
};
/// Represents a single image placement on the grid.
/// A placement is a request to render an instance of an image.
pub const Placement = struct {
/// The image being rendered. This MUST be in the image map.
image_id: u32,
image_id: Id,
/// The grid x/y where this placement is located.
x: i32,
@@ -34,8 +647,46 @@ pub const Placement = struct {
source_height: u32,
};
/// Image identifier used to store and lookup images.
///
/// This is tagged by different image types to make it easier to
/// store different kinds of images in the same map without having
/// to worry about ID collisions.
pub const Id = union(enum) {
/// Image sent to the terminal state via the kitty graphics protocol.
/// The value is the ID assigned by the terminal.
kitty: u32,
/// Debug overlay. This is always composited down to a single
/// image for now. In the future we can support layers here if we want.
overlay,
/// Z-ordering tie-breaker for images with the same z value.
pub fn zLessThan(lhs: Id, rhs: Id) bool {
// If our tags aren't the same, we sort by tag.
if (std.meta.activeTag(lhs) != std.meta.activeTag(rhs)) {
return switch (lhs) {
// Kitty images always sort before (lower z) non-kitty images.
.kitty => true,
.overlay => false,
};
}
switch (lhs) {
.kitty => |lhs_id| {
const rhs_id = rhs.kitty;
return lhs_id < rhs_id;
},
// No sensical ordering
.overlay => return false,
}
}
};
/// The map used for storing images.
pub const ImageMap = std.AutoHashMapUnmanaged(u32, struct {
pub const ImageMap = std.AutoHashMapUnmanaged(Id, struct {
image: Image,
transmit_time: std.time.Instant,
});
@@ -221,27 +872,33 @@ pub const Image = union(enum) {
try self.convert(alloc);
}
/// Upload the pending image to the GPU and
/// change the state of this image to ready.
/// Upload the pending image to the GPU and change the state of this
/// image to ready.
pub fn upload(
self: *Image,
alloc: Allocator,
api: *const GraphicsAPI,
) !void {
) (wuffs.Error || error{
/// Texture creation failed, usually a GPU memory issue.
UploadFailed,
})!void {
assert(self.isPending());
// No error recover is required after this call because it just
// converts in place and is idempotent.
try self.prepForUpload(alloc);
// Get our pending info
const p = self.getPending().?;
// Create our texture
const texture = try Texture.init(
const texture = Texture.init(
api.imageTextureOptions(.rgba, true),
@intCast(p.width),
@intCast(p.height),
p.dataSlice(),
);
) catch return error.UploadFailed;
errdefer comptime unreachable;
// Uploaded. We can now clear our data and change our state.
//

View File

@@ -16,8 +16,8 @@ pub fn neverExtendBg(
// because prompts often contain special formatting (such as
// powerline) that looks bad when extended.
switch (row.semantic_prompt) {
.prompt, .prompt_continuation, .input => return true,
.unknown, .command => {},
.prompt, .prompt_continuation => return true,
.none => {},
}
for (0.., cells) |x, *cell| {

View File

@@ -44,7 +44,7 @@ Elvish, on startup, searches for paths defined in `XDG_DATA_DIRS`
variable for `./elvish/lib/*.elv` files and imports them. They are thus
made available for use as modules by way of `use <filename>`.
Ghostty launches Elvish, passing the environment with `XDG_DATA_DIRS`prepended
Ghostty launches Elvish, passing the environment with `XDG_DATA_DIRS` prepended
with `$GHOSTTY_RESOURCES_DIR/src/shell-integration`. It contains
`./elvish/lib/ghostty-integration.elv`. The user can then import it
by `use ghostty-integration` every time after shell startup or
@@ -57,7 +57,7 @@ of your `rc.elv` file:
```elvish
if (eq $E:TERM "xterm-ghostty") {
use ghostty-integration
try { use ghostty-integration } catch { }
}
```
@@ -84,7 +84,8 @@ Nushell's vendor autoload mechanism. Ghostty then automatically imports
the module using the `-e "use ghostty *"` flag when starting Nushell.
Nushell provides many shell features itself, such as `title` and `cursor`,
so our integration focuses on Ghostty-specific features like `sudo`.
so our integration focuses on Ghostty-specific features like `sudo`,
`ssh-env`, and `ssh-terminfo`.
The shell integration is automatically enabled when running Nushell in Ghostty,
but you can also load it manually is shell integration is disabled:

View File

@@ -16,7 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# We need to be in interactive mode to proceed.
if [[ "$-" != *i* ]] ; then builtin return; fi
if [[ "$-" != *i* ]]; then builtin return; fi
# When automatic shell integration is active, we were started in POSIX
# mode and need to manually recreate the bash startup sequence.
@@ -49,7 +49,10 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then
if [[ $__ghostty_bash_flags != *"--noprofile"* ]]; then
[ -r /etc/profile ] && builtin source "/etc/profile"
for __ghostty_rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
[ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; }
[ -r "$__ghostty_rcfile" ] && {
builtin source "$__ghostty_rcfile"
break
}
done
fi
else
@@ -61,7 +64,10 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then
# Void Linux uses /etc/bash/bashrc
# Nixos uses /etc/bashrc
for __ghostty_rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do
[ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; }
[ -r "$__ghostty_rcfile" ] && {
builtin source "$__ghostty_rcfile"
break
}
done
if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi
[ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE"
@@ -101,9 +107,9 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
fi
done
if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then
builtin command sudo "$@";
builtin command sudo "$@"
else
builtin command sudo --preserve-env=TERMINFO "$@";
builtin command sudo --preserve-env=TERMINFO "$@"
fi
}
fi
@@ -127,8 +133,8 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then
while IFS=' ' read -r ssh_key ssh_value; do
case "$ssh_key" in
user) ssh_user="$ssh_value" ;;
hostname) ssh_hostname="$ssh_value" ;;
user) ssh_user="$ssh_value" ;;
hostname) ssh_hostname="$ssh_value" ;;
esac
[[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break
done < <(builtin command ssh -G "$@" 2>/dev/null)
@@ -178,76 +184,121 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then
}
fi
# Import bash-preexec, safe to do multiple times
builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh"
# This is set to 1 when we're executing a command so that we don't
# send prompt marks multiple times.
_ghostty_executing=""
_ghostty_last_reported_cwd=""
function __ghostty_precmd() {
local ret="$?"
if test "$_ghostty_executing" != "0"; then
_GHOSTTY_SAVE_PS1="$PS1"
_GHOSTTY_SAVE_PS2="$PS2"
local ret="$?"
if test "$_ghostty_executing" != "0"; then
_GHOSTTY_SAVE_PS1="$PS1"
_GHOSTTY_SAVE_PS2="$PS2"
# Marks
PS1=$PS1'\[\e]133;B\a\]'
PS2=$PS2'\[\e]133;B\a\]'
# Marks. We need to do fresh line (A) at the beginning of the prompt
# since if the cursor is not at the beginning of a line, the terminal
# will emit a newline.
PS1='\[\e]133;A;redraw=last;cl=line\a\]'$PS1'\[\e]133;B\a\]'
PS2='\[\e]133;A;k=s\a\]'$PS2'\[\e]133;B\a\]'
# bash doesn't redraw the leading lines in a multiline prompt so
# mark the last line as a secondary prompt (k=s) to prevent the
# preceding lines from being erased by ghostty after a resize.
if [[ "${PS1}" == *"\n"* || "${PS1}" == *$'\n'* ]]; then
PS1=$PS1'\[\e]133;A;k=s\a\]'
fi
# Cursor
if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then
[[ "$PS1" != *'\[\e[5 q\]'* ]] && PS1=$PS1'\[\e[5 q\]' # input
[[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset
fi
# Title (working directory)
if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then
PS1=$PS1'\[\e]2;\w\a\]'
fi
# Bash doesn't redraw the leading lines in a multiline prompt so
# we mark the start of each line (after each newline) as a secondary
# prompt. This correctly handles multiline prompts by setting the first
# to primary and the subsequent lines to secondary.
if [[ "${PS1}" == *"\n"* || "${PS1}" == *$'\n'* ]]; then
builtin local __ghostty_mark=$'\\[\\e]133;A;k=s\\a\\]'
PS1="${PS1//$'\n'/$'\n'$__ghostty_mark}"
PS1="${PS1//\\n/\\n$__ghostty_mark}"
fi
if test "$_ghostty_executing" != ""; then
# End of current command. Report its status.
builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID"
# Cursor
if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then
[[ "$PS1" != *'\[\e[5 q\]'* ]] && PS1=$PS1'\[\e[5 q\]' # input
[[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset
fi
# unfortunately bash provides no hooks to detect cwd changes
# in particular this means cwd reporting will not happen for a
# command like cd /test && cat. PS0 is evaluated before cd is run.
if [[ "$_ghostty_last_reported_cwd" != "$PWD" ]]; then
_ghostty_last_reported_cwd="$PWD"
builtin printf "\e]7;kitty-shell-cwd://%s%s\a" "$HOSTNAME" "$PWD"
# Title (working directory)
if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then
PS1=$PS1'\[\e]2;\w\a\]'
fi
fi
# Fresh line and start of prompt.
builtin printf "\e]133;A;aid=%s\a" "$BASHPID"
_ghostty_executing=0
if test "$_ghostty_executing" != ""; then
# End of current command. Report its status.
builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID"
fi
# unfortunately bash provides no hooks to detect cwd changes
# in particular this means cwd reporting will not happen for a
# command like cd /test && cat. PS0 is evaluated before cd is run.
if [[ "$_ghostty_last_reported_cwd" != "$PWD" ]]; then
_ghostty_last_reported_cwd="$PWD"
builtin printf "\e]7;kitty-shell-cwd://%s%s\a" "$HOSTNAME" "$PWD"
fi
# Fresh line and start of prompt.
builtin printf "\e]133;A;redraw=last;cl=line;aid=%s\a" "$BASHPID"
_ghostty_executing=0
}
function __ghostty_preexec() {
builtin local cmd="$1"
builtin local cmd="$1"
PS1="$_GHOSTTY_SAVE_PS1"
PS2="$_GHOSTTY_SAVE_PS2"
PS1="$_GHOSTTY_SAVE_PS1"
PS2="$_GHOSTTY_SAVE_PS2"
# Title (current command)
if [[ -n $cmd && "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then
builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]}"
fi
# Title (current command)
if [[ -n $cmd && "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then
builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]/}"
fi
# End of input, start of output.
builtin printf "\e]133;C;\a"
_ghostty_executing=1
# End of input, start of output.
builtin printf "\e]133;C;\a"
_ghostty_executing=1
}
preexec_functions+=(__ghostty_preexec)
precmd_functions+=(__ghostty_precmd)
if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then
__ghostty_preexec_hook() {
builtin local cmd
cmd=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1)
cmd="${cmd#*[[:digit:]][* ] }" # remove leading history number
[[ -n "$cmd" ]] && __ghostty_preexec "$cmd"
}
# Use function substitution in 5.3+. Otherwise, use command substitution.
# Any output (including escape sequences) goes to the terminal.
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then
# shellcheck disable=SC2016
builtin readonly __ghostty_ps0='${ __ghostty_preexec_hook; }'
else
# shellcheck disable=SC2016
builtin readonly __ghostty_ps0='$(__ghostty_preexec_hook >/dev/tty)'
fi
__ghostty_hook() {
builtin local ret=$?
__ghostty_precmd "$ret"
PS0=$__ghostty_ps0
}
# Append our hook to PROMPT_COMMAND, preserving its existing type.
if [[ ";${PROMPT_COMMAND[*]:-};" != *";__ghostty_hook;"* ]]; then
if [[ -z "${PROMPT_COMMAND[*]}" ]]; then
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
PROMPT_COMMAND=(__ghostty_hook)
else
# shellcheck disable=SC2178
PROMPT_COMMAND="__ghostty_hook"
fi
elif [[ $(builtin declare -p PROMPT_COMMAND 2>/dev/null) == "declare -a "* ]]; then
PROMPT_COMMAND+=(__ghostty_hook)
else
# shellcheck disable=SC2179
PROMPT_COMMAND+="; __ghostty_hook"
fi
fi
else
builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh"
preexec_functions+=(__ghostty_preexec)
precmd_functions+=(__ghostty_precmd)
fi

View File

@@ -1,42 +1,12 @@
{
fn restore-xdg-dirs {
use str
var integration-dir = $E:GHOSTTY_SHELL_INTEGRATION_XDG_DIR
var xdg-dirs = [(str:split ':' $E:XDG_DATA_DIRS)]
var len = (count $xdg-dirs)
use platform
use str
var index = $nil
range $len | each {|dir-index|
if (eq $xdg-dirs[$dir-index] $integration-dir) {
set index = $dir-index
break
}
}
if (eq $nil $index) { return } # will appear as an error
if (== 0 $index) {
set xdg-dirs = $xdg-dirs[1..]
} elif (== (- $len 1) $index) {
set xdg-dirs = $xdg-dirs[0..(- $len 1)]
} else {
# no builtin function for this : )
set xdg-dirs = [ (take $index $xdg-dirs) (drop (+ 1 $index) $xdg-dirs) ]
}
if (== 0 (count $xdg-dirs)) {
unset-env XDG_DATA_DIRS
} else {
set-env XDG_DATA_DIRS (str:join ':' $xdg-dirs)
}
# Clean up XDG_DATA_DIRS by removing GHOSTTY_SHELL_INTEGRATION_XDG_DIR
if (and (has-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR) (has-env XDG_DATA_DIRS)) {
set-env XDG_DATA_DIRS (str:replace $E:GHOSTTY_SHELL_INTEGRATION_XDG_DIR":" "" $E:XDG_DATA_DIRS)
unset-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR
}
if (and (has-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR) (has-env XDG_DATA_DIRS)) {
restore-xdg-dirs
}
}
{
use str
# List of enabled shell integration features
var features = [(str:split ',' $E:GHOSTTY_SHELL_FEATURES)]
@@ -77,24 +47,22 @@
printf "\e]133;D;"$exit-status"\a"
}
fn report-pwd {
use platform
printf "\e]7;kitty-shell-cwd://%s%s\a" (platform:hostname) $pwd
}
fn sudo-with-terminfo {|@args|
var sudoedit = $false
for arg $args {
use str
if (str:has-prefix $arg -) {
if (has-value [e -edit] $arg[1..]) {
if (str:has-prefix $arg --) {
if (eq $arg --edit) {
set sudoedit = $true
break
}
continue
} elif (str:has-prefix $arg -) {
if (str:contains (str:trim-prefix $arg -) e) {
set sudoedit = $true
break
}
} elif (not (str:contains $arg =)) {
break
}
if (not (has-value $arg =)) { break }
}
if (not $sudoedit) { set args = [ --preserve-env=TERMINFO $@args ] }
@@ -180,16 +148,12 @@
defer {
mark-prompt-start
report-pwd
}
set edit:before-readline = (conj $edit:before-readline $mark-prompt-start~)
set edit:after-readline = (conj $edit:after-readline $mark-output-start~)
set edit:after-command = (conj $edit:after-command $mark-output-end~)
if (has-value $features title) {
set after-chdir = (conj $after-chdir {|_| report-pwd })
}
if (has-value $features cursor) {
fn beam { printf "\e[5 q" }
fn block { printf "\e[0 q" }
@@ -207,4 +171,9 @@
if (and (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-) (has-external ssh)) {
edit:add-var ssh~ $ssh-integration~
}
# Report changes to the current directory.
fn report-pwd { printf "\e]7;kitty-shell-cwd://%s%s\a" (platform:hostname) $pwd }
set after-chdir = (conj $after-chdir {|_| report-pwd })
report-pwd
}

View File

@@ -51,6 +51,27 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
set --local features (string split , $GHOSTTY_SHELL_FEATURES)
# Parse the fish version for feature detection.
# Default to 0.0 if version is unavailable or malformed.
set -l fish_major 0
set -l fish_minor 0
if set -q version[1]
set -l fish_ver (string match -r '(\d+)\.(\d+)' -- $version[1])
if set -q fish_ver[2]; and test -n "$fish_ver[2]"
set fish_major "$fish_ver[2]"
end
if set -q fish_ver[3]; and test -n "$fish_ver[3]"
set fish_minor "$fish_ver[3]"
end
end
# Our OSC133A (prompt start) sequence. If we're using Fish >= 4.1
# then it supports click_events so we enable that.
set -g __ghostty_prompt_start_mark "\e]133;A\a"
if test "$fish_major" -gt 4; or test "$fish_major" -eq 4 -a "$fish_minor" -ge 1
set -g __ghostty_prompt_start_mark "\e]133;A;click_events=1\a"
end
if contains cursor $features
# Change the cursor to a beam on prompt.
function __ghostty_set_cursor_beam --on-event fish_prompt -d "Set cursor shape"
@@ -72,14 +93,14 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
# When using sudo shell integration feature, ensure $TERMINFO is set
# and `sudo` is not already a function or alias
if contains sudo $features; and test -n "$TERMINFO"; and test "file" = (type -t sudo 2> /dev/null; or echo "x")
if contains sudo $features; and test -n "$TERMINFO"; and test file = (type -t sudo 2> /dev/null; or echo "x")
# Wrap `sudo` command to ensure Ghostty terminfo is preserved
function sudo -d "Wrap sudo to preserve terminfo"
set --function sudo_has_sudoedit_flags "no"
set --function sudo_has_sudoedit_flags no
for arg in $argv
# Check if argument is '-e' or '--edit' (sudoedit flags)
if string match -q -- "-e" "$arg"; or string match -q -- "--edit" "$arg"
set --function sudo_has_sudoedit_flags "yes"
if string match -q -- -e "$arg"; or string match -q -- --edit "$arg"
set --function sudo_has_sudoedit_flags yes
break
end
# Check if argument is neither an option nor a key-value pair
@@ -87,7 +108,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
break
end
end
if test "$sudo_has_sudoedit_flags" = "yes"
if test "$sudo_has_sudoedit_flags" = yes
command sudo $argv
else
command sudo --preserve-env=TERMINFO $argv
@@ -100,7 +121,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
if contains ssh-env $features; or contains ssh-terminfo $features
function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration"
set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES")
set -l ssh_term "xterm-256color"
set -l ssh_term xterm-256color
set -l ssh_opts
# Configure environment variables for remote session
@@ -134,7 +155,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
# Check if terminfo is already cached
if test -x "$GHOSTTY_BIN_DIR/ghostty"; and "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1
set ssh_term "xterm-ghostty"
set ssh_term xterm-ghostty
else if command -q infocmp
set -l ssh_terminfo
set -l ssh_cpath_dir
@@ -154,7 +175,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
exit 1
' 2>/dev/null
set ssh_term "xterm-ghostty"
set ssh_term xterm-ghostty
set -a ssh_opts -o "ControlPath=$ssh_cpath"
# Cache successful installation
@@ -179,14 +200,14 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
end
# Setup prompt marking
function __ghostty_mark_prompt_start --on-event fish_prompt --on-event fish_cancel --on-event fish_posterror
function __ghostty_mark_prompt_start --on-event fish_prompt --on-event fish_posterror
# If we never got the output end event, then we need to send it now.
if test "$__ghostty_prompt_state" != prompt-start
echo -en "\e]133;D\a"
end
set --global __ghostty_prompt_state prompt-start
echo -en "\e]133;A\a"
echo -en $__ghostty_prompt_start_mark
end
function __ghostty_mark_output_start --on-event fish_preexec

View File

@@ -4,22 +4,97 @@ export module ghostty {
$feature in ($env.GHOSTTY_SHELL_FEATURES | default "" | split row ',')
}
# Wrap `ssh` with Ghostty TERMINFO support
export def --wrapped ssh [...args] {
mut ssh_env = {}
mut ssh_opts = []
# `ssh-env`: use xterm-256color and propagate COLORTERM/TERM_PROGRAM vars
if (has_feature "ssh-env") {
$ssh_env.TERM = "xterm-256color"
$ssh_opts = [
"-o" "SetEnv COLORTERM=truecolor"
"-o" "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"
]
}
# `ssh-terminfo`: auto-install xterm-ghostty terminfo on remote hosts
if (has_feature "ssh-terminfo") {
let ghostty = ($env.GHOSTTY_BIN_DIR? | default "") | path join "ghostty"
let ssh_cfg = ^ssh -G ...$args
| lines
| parse "{key} {value}"
| where key in ["user" "hostname"]
| select key value
| transpose -rd
| default {user: $env.USER hostname: "localhost"}
let ssh_id = $"($ssh_cfg.user)@($ssh_cfg.hostname)"
if (^$ghostty "+ssh-cache" $"--host=($ssh_id)" | complete | $in.exit_code == 0) {
$ssh_env.TERM = "xterm-ghostty"
} else {
$ssh_env.TERM = "xterm-256color"
let terminfo = try {
^infocmp -0 -x xterm-ghostty
} catch {
print -e "infocmp failed, using xterm-256color"
}
if ($terminfo | is-not-empty) {
print $"Setting up xterm-ghostty terminfo on ($ssh_cfg.hostname)..."
let ctrl_path = (
mktemp -td $"ghostty-ssh-($ssh_cfg.user).XXXXXX"
| path join "socket"
)
let remote_args = $ssh_opts ++ [
"-o" "ControlMaster=yes"
"-o" $"ControlPath=($ctrl_path)"
"-o" "ControlPersist=60s"
] ++ $args
$terminfo | ^ssh ...$remote_args '
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
command -v tic >/dev/null 2>&1 || exit 1
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
exit 1'
| complete
| if $in.exit_code == 0 {
^$ghostty "+ssh-cache" $"--add=($ssh_id)" e>| print -e
$ssh_env.TERM = "xterm-ghostty"
$ssh_opts = ($ssh_opts ++ ["-o" $"ControlPath=($ctrl_path)"])
} else {
print -e "terminfo install failed, using xterm-256color"
}
}
}
}
let ssh_args = $ssh_opts ++ $args
with-env $ssh_env {
^ssh ...$ssh_args
}
}
# Wrap `sudo` to preserve Ghostty's TERMINFO environment variable
export def --wrapped sudo [
...args # Arguments to pass to `sudo`
] {
export def --wrapped sudo [...args] {
mut sudo_args = $args
if (has_feature "sudo") {
# Extract just the sudo options (before the command)
let sudo_options = ($args | take until {|arg|
not (($arg | str starts-with "-") or ($arg | str contains "="))
})
# Prepend TERMINFO preservation flag if not using sudoedit
if (not ("-e" in $sudo_options or "--edit" in $sudo_options)) {
$sudo_args = ($args | prepend "--preserve-env=TERMINFO")
# Extract just the sudo options (before the command)
let sudo_options = (
$args | take until {|arg|
not (($arg | str starts-with "-") or ($arg | str contains "="))
}
)
# Prepend TERMINFO preservation flag if not using sudoedit
if (not ("-e" in $sudo_options or "--edit" in $sudo_options)) {
$sudo_args = ($args | prepend "--preserve-env=TERMINFO")
}
}
^sudo ...$sudo_args

View File

@@ -121,7 +121,7 @@ _ghostty_deferred_init() {
fi
fi
builtin local mark1=$'%{\e]133;A\a%}'
builtin local mark1=$'%{\e]133;A;cl=line\a%}'
if [[ -o prompt_percent ]]; then
builtin typeset -g precmd_functions
if [[ ${precmd_functions[-1]} == _ghostty_precmd ]]; then
@@ -132,6 +132,7 @@ _ghostty_deferred_init() {
# asynchronously from a `zle -F` handler might still remove our
# marks. Oh well.
builtin local mark2=$'%{\e]133;A;k=s\a%}'
builtin local markB=$'%{\e]133;B\a%}'
# Add marks conditionally to avoid a situation where we have
# several marks in place. These conditions can have false
# positives and false negatives though.
@@ -139,8 +140,17 @@ _ghostty_deferred_init() {
# - False positive (with prompt_percent): PS1="%(?.$mark1.)"
# - False negative (with prompt_subst): PS1='$mark1'
[[ $PS1 == *$mark1* ]] || PS1=${mark1}${PS1}
[[ $PS1 == *$markB* ]] || PS1=${PS1}${markB}
# Handle multiline prompts by marking continuation lines as
# secondary by replacing newlines with being prefixed
# with k=s
if [[ $PS1 == *$'\n'* ]]; then
PS1=${PS1//$'\n'/$'\n'${mark2}}
fi
# PS2 mark is needed when clearing the prompt on resize
[[ $PS2 == *$mark2* ]] || PS2=${mark2}${PS2}
[[ $PS2 == *$markB* ]] || PS2=${PS2}${markB}
(( _ghostty_state = 2 ))
else
# If our precmd hook is not the last, we cannot rely on prompt
@@ -179,7 +189,10 @@ _ghostty_deferred_init() {
# top. We cannot force prompt_subst on the user though, so we would
# still need this code for the no_prompt_subst case.
PS1=${PS1//$'%{\e]133;A\a%}'}
PS1=${PS1//$'%{\e]133;A;k=s\a%}'}
PS1=${PS1//$'%{\e]133;B\a%}'}
PS2=${PS2//$'%{\e]133;A;k=s\a%}'}
PS2=${PS2//$'%{\e]133;B\a%}'}
# This will work incorrectly in the presence of a preexec hook that
# prints. For example, if MichaelAquilina/zsh-you-should-use installs

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@ all: std.EnumMap(Key, *Screen),
pub fn init(
alloc: Allocator,
opts: Screen.Options,
) !ScreenSet {
) Allocator.Error!ScreenSet {
// We need to initialize our initial primary screen
const screen = try alloc.create(Screen);
errdefer alloc.destroy(screen);
@@ -64,7 +64,7 @@ pub fn getInit(
alloc: Allocator,
key: Key,
opts: Screen.Options,
) !*Screen {
) Allocator.Error!*Screen {
if (self.get(key)) |screen| return screen;
const screen = try alloc.create(Screen);
errdefer alloc.destroy(screen);

View File

@@ -3,6 +3,7 @@ const Selection = @This();
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const page = @import("page.zig");
const point = @import("point.zig");
const PageList = @import("PageList.zig");
@@ -126,7 +127,7 @@ pub fn tracked(self: *const Selection) bool {
/// Convert this selection a tracked selection. It is asserted this is
/// an untracked selection. The tracked selection is returned.
pub fn track(self: *const Selection, s: *Screen) !Selection {
pub fn track(self: *const Selection, s: *Screen) Allocator.Error!Selection {
assert(!self.tracked());
// Track our pins

View File

@@ -10,6 +10,7 @@
const Tabstops = @This();
const std = @import("std");
const tripwire = @import("../tripwire.zig");
const Allocator = std.mem.Allocator;
const testing = std.testing;
const assert = @import("../quirks.zig").inlineAssert;
@@ -58,7 +59,11 @@ inline fn index(col: usize) usize {
return @mod(col, unit_bits);
}
pub fn init(alloc: Allocator, cols: usize, interval: usize) !Tabstops {
pub fn init(
alloc: Allocator,
cols: usize,
interval: usize,
) Allocator.Error!Tabstops {
var res: Tabstops = .{};
try res.resize(alloc, cols);
res.reset(interval);
@@ -114,21 +119,36 @@ pub fn get(self: Tabstops, col: usize) bool {
return unit & mask == mask;
}
const resize_tw = tripwire.module(enum {
dynamic_alloc,
}, resize);
/// Resize this to support up to cols columns.
// TODO: needs interval to set new tabstops
pub fn resize(self: *Tabstops, alloc: Allocator, cols: usize) !void {
// Set our new value
self.cols = cols;
pub fn resize(
self: *Tabstops,
alloc: Allocator,
cols: usize,
) Allocator.Error!void {
const tw = resize_tw;
// Do nothing if it fits.
if (cols <= prealloc_columns) return;
if (cols <= prealloc_columns) {
self.cols = cols;
return;
}
// What we need in the dynamic size
const size = cols - prealloc_columns;
if (size < self.dynamic_stops.len) return;
if (size < self.dynamic_stops.len) {
self.cols = cols;
return;
}
// Note: we can probably try to realloc here but I'm not sure it matters.
try tw.check(.dynamic_alloc);
const new = try alloc.alloc(Unit, size);
errdefer comptime unreachable;
@memset(new, 0);
if (self.dynamic_stops.len > 0) {
fastmem.copy(Unit, new, self.dynamic_stops);
@@ -136,6 +156,7 @@ pub fn resize(self: *Tabstops, alloc: Allocator, cols: usize) !void {
}
self.dynamic_stops = new;
self.cols = cols;
}
/// Return the maximum number of columns this can support currently.
@@ -230,3 +251,21 @@ test "Tabstops: count on 80" {
try testing.expectEqual(@as(usize, 9), count);
}
test "Tabstops: resize alloc failure preserves state" {
// This test verifies that if resize() fails during allocation,
// the original cols value is preserved (not corrupted).
var t: Tabstops = try init(testing.allocator, 80, 8);
defer t.deinit(testing.allocator);
const original_cols = t.cols;
// Trigger allocation failure when resizing beyond prealloc
resize_tw.errorAlways(.dynamic_alloc, error.OutOfMemory);
const result = t.resize(testing.allocator, prealloc_columns * 2);
try testing.expectError(error.OutOfMemory, result);
try resize_tw.end(.reset);
// cols should be unchanged after failed resize
try testing.expectEqual(original_cols, t.cols);
}

File diff suppressed because it is too large Load Diff

View File

@@ -147,6 +147,20 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type {
}
}
/// Returns the total capacity in bytes.
pub fn capacityBytes(self: Self) usize {
return self.bitmap_count * bitmap_bit_size * chunk_size;
}
/// Returns the number of bytes currently in use.
pub fn usedBytes(self: Self, base: anytype) usize {
const bitmaps = self.bitmap.ptr(base);
var free_chunks: usize = 0;
for (bitmaps[0..self.bitmap_count]) |bitmap| free_chunks += @popCount(bitmap);
const total_chunks = self.bitmap_count * bitmap_bit_size;
return (total_chunks - free_chunks) * chunk_size;
}
/// For testing only.
fn isAllocated(self: *Self, base: anytype, slice: anytype) bool {
comptime assert(@import("builtin").is_test);

View File

@@ -168,7 +168,7 @@ pub const Name = enum(u8) {
}
/// Default colors for tagged values.
pub fn default(self: Name) !RGB {
pub fn default(self: Name) error{NoDefaultValue}!RGB {
return switch (self) {
.black => RGB{ .r = 0x1D, .g = 0x1F, .b = 0x21 },
.red => RGB{ .r = 0xCC, .g = 0x66, .b = 0x66 },
@@ -355,7 +355,7 @@ pub const RGB = packed struct(u24) {
/// Parse a color from a floating point intensity value.
///
/// The value should be between 0.0 and 1.0, inclusive.
fn fromIntensity(value: []const u8) !u8 {
fn fromIntensity(value: []const u8) error{InvalidFormat}!u8 {
const i = std.fmt.parseFloat(f64, value) catch {
@branchHint(.cold);
return error.InvalidFormat;
@@ -372,7 +372,7 @@ pub const RGB = packed struct(u24) {
///
/// The string can contain 1, 2, 3, or 4 characters and represents the color
/// value scaled in 4, 8, 12, or 16 bits, respectively.
fn fromHex(value: []const u8) !u8 {
fn fromHex(value: []const u8) error{InvalidFormat}!u8 {
if (value.len == 0 or value.len > 4) {
@branchHint(.cold);
return error.InvalidFormat;
@@ -414,7 +414,7 @@ pub const RGB = packed struct(u24) {
/// where `r`, `g`, and `b` are a single hexadecimal digit.
/// These specify a color with 4, 8, 12, and 16 bits of precision
/// per color channel.
pub fn parse(value: []const u8) !RGB {
pub fn parse(value: []const u8) error{InvalidFormat}!RGB {
if (value.len == 0) {
@branchHint(.cold);
return error.InvalidFormat;

View File

@@ -17,6 +17,7 @@ const parsers = @import("osc/parsers.zig");
const encoding = @import("osc/encoding.zig");
pub const color = parsers.color;
pub const semantic_prompt = parsers.semantic_prompt;
const log = std.log.scoped(.osc);
@@ -41,74 +42,8 @@ pub const Command = union(Key) {
/// in the log.
change_window_icon: [:0]const u8,
/// First do a fresh-line. Then start a new command, and enter prompt mode:
/// Subsequent text (until a OSC "133;B" or OSC "133;I" command) is a
/// prompt string (as if followed by OSC 133;P;k=i\007). Note: I've noticed
/// not all shells will send the prompt end code.
prompt_start: struct {
/// "aid" is an optional "application identifier" that helps disambiguate
/// nested shell sessions. It can be anything but is usually a process ID.
aid: ?[:0]const u8 = null,
/// "kind" tells us which kind of semantic prompt sequence this is:
/// - primary: normal, left-aligned first-line prompt (initial, default)
/// - continuation: an editable continuation line
/// - secondary: a non-editable continuation line
/// - right: a right-aligned prompt that may need adjustment during reflow
kind: enum { primary, continuation, secondary, right } = .primary,
/// If true, the shell will not redraw the prompt on resize so don't erase it.
/// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
redraw: bool = true,
/// Use a special key instead of arrow keys to move the cursor on
/// mouse click. Useful if arrow keys have side-effets like triggering
/// auto-complete. The shell integration script should bind the special
/// key as needed.
/// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
special_key: bool = false,
/// If true, the shell is capable of handling mouse click events.
/// Ghostty will then send a click event to the shell when the user
/// clicks somewhere in the prompt. The shell can then move the cursor
/// to that position or perform some other appropriate action. If false,
/// Ghostty may generate a number of fake key events to move the cursor
/// which is not very robust.
/// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
click_events: bool = false,
},
/// End of prompt and start of user input, terminated by a OSC "133;C"
/// or another prompt (OSC "133;P").
prompt_end: void,
/// The OSC "133;C" command can be used to explicitly end
/// the input area and begin the output area. However, some applications
/// don't provide a convenient way to emit that command.
/// That is why we also specify an implicit way to end the input area
/// at the end of the line. In the case of multiple input lines: If the
/// cursor is on a fresh (empty) line and we see either OSC "133;P" or
/// OSC "133;I" then this is the start of a continuation input line.
/// If we see anything else, it is the start of the output area (or end
/// of command).
end_of_input: struct {
/// The command line that the user entered.
/// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
cmdline: ?[:0]const u8 = null,
},
/// End of current command.
///
/// The exit-code need not be specified if there are no options,
/// or if the command was cancelled (no OSC "133;C"), such as by typing
/// an interrupt/cancel character (typically ctrl-C) during line-editing.
/// Otherwise, it must be an integer code, where 0 means the command
/// succeeded, and other values indicate failure. In additing to the
/// exit-code there may be an err= option, which non-legacy terminals
/// should give precedence to. The err=_value_ option is more general:
/// an empty string is success, and any non-empty value (which need not
/// be an integer) is an error code. So to indicate success both ways you
/// could send OSC "133;D;0;err=\007", though `OSC "133;D;0\007" is shorter.
end_of_command: struct {
exit_code: ?u8 = null,
// TODO: err option
},
/// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
semantic_prompt: SemanticPrompt,
/// Set or get clipboard contents. If data is null, then the current
/// clipboard contents are sent to the pty. If data is set, this
@@ -193,9 +128,33 @@ pub const Command = union(Key) {
/// ConEmu GUI macro (OSC 9;6)
conemu_guimacro: [:0]const u8,
/// ConEmu run process (OSC 9;7)
conemu_run_process: [:0]const u8,
/// ConEmu output environment variable (OSC 9;8)
conemu_output_environment_variable: [:0]const u8,
/// ConEmu XTerm keyboard and output emulation (OSC 9;10)
/// https://conemu.github.io/en/TerminalModes.html
conemu_xterm_emulation: struct {
/// null => do not change
/// false => turn off
/// true => turn on
keyboard: ?bool,
/// null => do not change
/// false => turn off
/// true => turn on
output: ?bool,
},
/// ConEmu comment (OSC 9;11)
conemu_comment: [:0]const u8,
/// Kitty text sizing protocol (OSC 66)
kitty_text_sizing: parsers.kitty_text_sizing.OSC,
pub const SemanticPrompt = parsers.semantic_prompt.Command;
pub const Key = LibEnum(
if (build_options.c_abi) .c else .zig,
// NOTE: Order matters, see LibEnum documentation.
@@ -203,10 +162,7 @@ pub const Command = union(Key) {
"invalid",
"change_window_title",
"change_window_icon",
"prompt_start",
"prompt_end",
"end_of_input",
"end_of_command",
"semantic_prompt",
"clipboard_contents",
"report_pwd",
"mouse_shape",
@@ -221,6 +177,10 @@ pub const Command = union(Key) {
"conemu_progress_report",
"conemu_wait_input",
"conemu_guimacro",
"conemu_run_process",
"conemu_output_environment_variable",
"conemu_xterm_emulation",
"conemu_comment",
"kitty_text_sizing",
},
);
@@ -380,6 +340,7 @@ pub const Parser = struct {
@"119",
@"133",
@"777",
@"1337",
};
pub fn init(alloc: ?Allocator) Parser {
@@ -424,20 +385,21 @@ pub const Parser = struct {
.clipboard_contents,
.color_operation,
.conemu_change_tab_title,
.conemu_comment,
.conemu_guimacro,
.conemu_output_environment_variable,
.conemu_progress_report,
.conemu_run_process,
.conemu_show_message_box,
.conemu_sleep,
.conemu_wait_input,
.end_of_command,
.end_of_input,
.conemu_xterm_emulation,
.hyperlink_end,
.hyperlink_start,
.invalid,
.mouse_shape,
.prompt_end,
.prompt_start,
.report_pwd,
.semantic_prompt,
.show_desktop_notification,
.kitty_text_sizing,
=> {},
@@ -633,8 +595,20 @@ pub const Parser = struct {
else => self.state = .invalid,
},
.@"0",
.@"133",
=> switch (c) {
';' => self.writeToFixed(),
'7' => self.state = .@"1337",
else => self.state = .invalid,
},
.@"1337",
=> switch (c) {
';' => self.writeToFixed(),
else => self.state = .invalid,
},
.@"0",
.@"22",
.@"777",
.@"8",
@@ -711,6 +685,8 @@ pub const Parser = struct {
.@"133" => parsers.semantic_prompt.parse(self, terminator_ch),
.@"777" => parsers.rxvt_extension.parse(self, terminator_ch),
.@"1337" => parsers.iterm2.parse(self, terminator_ch),
};
}
};

Some files were not shown because too many files have changed in this diff Show More