Merge branch 'ghostty-org:main' into i18n-update-translation-lt-for-v1.4

This commit is contained in:
tdslot
2026-09-07 18:07:24 +02:00
committed by GitHub
314 changed files with 64006 additions and 9306 deletions

30
.github/VOUCHED.td vendored
View File

@@ -46,11 +46,13 @@ alexfeijoo44
alexjuca
alosarjos
amadeus
and-rs
andrejdaskalov
anhthang
anmitalidev
anthonyzhoon
aprilnea
arneshbanerjee
arvin7liu
athaapa
atomk
@@ -60,6 +62,7 @@ b0uks
b1nar10
balazs-szucs
barutsrb
basteez
bch
bennettp123
benodiwal
@@ -72,6 +75,7 @@ bo2themax
bousii
brentschroeter
brianc442
by-nelson
c0x0o
carldaws
carlvillads
@@ -97,9 +101,14 @@ deblasis
dervedro
devsunb
diaaeddin
-didouougha slingshotting LLM output
diego-moment
diegoarmstrong
dkinzler
dmehala
dmunozv04
dobbylee
dolzenko
doprz
douglance
douglas
@@ -108,6 +117,7 @@ drepper
dzhlobo
ekaterinepapava
elias8
elitex45
-enkr1
enzowilliam
ephemera
@@ -147,9 +157,11 @@ hulet
i999rri
icodesign
illiakrauchanka
j-c-m
j0hnm4r5
jacobsandlund
jake-stewart
jakeriksen
-jamesarch Bad AI etiquette
jamylak
jarred-sumner
@@ -164,6 +176,7 @@ josephmart
jparise
juniqlim
justonia
jzillmann
karesansui-u
kataokatsuki
kawarimidoll
@@ -173,6 +186,7 @@ khipp
kierancanter
kirwiisp
kjvdven
kleshzz
kloneets
knu
-kody-w
@@ -201,6 +215,7 @@ marrocco-simone
masterflitzer
matkotiric
mattn
mgsloan
micaeljarniac
michielvk
miguelelgallo
@@ -235,6 +250,7 @@ nolinmcfarland
nouritsu
nwehg
ocean6954
ollioddi
oshdubh
otomn
paaloeye
@@ -251,8 +267,10 @@ pluiedev
pouwerkerk
poweruser64
prakhar54-byte
preiter93
prihlop
priyans-hu
pssalman
puzza007
qappell
quinnypig
@@ -265,6 +283,7 @@ rgehan
rhodes-b
rightaditya
rjwittams
rkoten
rmengelbrecht
rmunn
rockorager
@@ -280,23 +299,32 @@ sanjj1
secrus
seruman
seyoungjeong
sghng
shorsher
silveirapf
simonbcn
slowdub
slsrepo
steven-tk
sunshine-syz
svector-anu
svmhdvn
tacherasasi
-tangivis
tasselx
tbrundige
tdgroot
tdslot
thirstycrow
-thomas-trijindev No human in the loop
thoutbeckers
ticclick
tnagatomi
tothedarktowercame
trag1c
tristan957
tsacha
tuananh
turbolent
tweedbeetle
uhojin
@@ -304,6 +332,7 @@ unphased
unsaltedscholar
uzaaft
vancluever
vasilmytsyk
vaughanandrews
-vectorpeak Stupid bot cosplaying as a maintainer
vegerot
@@ -320,4 +349,5 @@ ydah
zenangst
zenyr
zeshi09
zlitus
zubb

View File

@@ -10,7 +10,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Remove old artifacts
uses: c-hive/gha-remove-artifacts@44fc7acaf1b3d0987da0e8d4707a989d80e9554b # v1.4.0
uses: c-hive/gha-remove-artifacts@62c2fbea931baa7dd4a6b73ea5a799984a818f61 # v1.8.0
with:
age: "1 week"
skip-tags: true

View File

@@ -41,7 +41,7 @@ jobs:
mkdir dist
tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz
- uses: flatpak/flatpak-github-actions/flatpak-builder@401fe28a8384095fc1531b9d320b292f0ee45adb # v6.7
- uses: flatpak/flatpak-github-actions/flatpak-builder@79327416609af08178ad73b352877e51450790b3 # v6.8
with:
bundle: com.mitchellh.ghostty
manifest-path: dist/flatpak/com.mitchellh.ghostty.yml

View File

@@ -18,7 +18,7 @@ jobs:
name: Milestone Update
steps:
- name: Set Milestone for PR
uses: hustcer/milestone-action@ebed8d5daafd855a600d7e665c1b130f06d24130 # v3.1
uses: hustcer/milestone-action@2f38355153344ccaaa44b5b5fcff9f604dff1b45 # v3.2
if: github.event.pull_request.merged == true && !contains(github.event.pull_request.title, 'VOUCHED') && !startsWith(github.event.pull_request.title, 'ci:')
with:
action: bind-pr # `bind-pr` is the default action
@@ -26,7 +26,7 @@ jobs:
# Bind milestone to closed issue that has a merged PR fix
- name: Set Milestone for Issue
uses: hustcer/milestone-action@ebed8d5daafd855a600d7e665c1b130f06d24130 # v3.1
uses: hustcer/milestone-action@2f38355153344ccaaa44b5b5fcff9f604dff1b45 # v3.2
if: github.event.issue.state == 'closed'
with:
action: bind-issue

View File

@@ -47,10 +47,10 @@ jobs:
/nix
/zig
- name: Setup Nix
uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"

View File

@@ -64,7 +64,7 @@ jobs:
mkdir blob
mv appcast.xml blob/appcast.xml
- name: Upload Appcast to R2
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
uses: ryand56/r2-upload-action@33ebb7a494b3a8a3c5ed2ea6bbdc72e707090317 # v1.5
with:
r2-account-id: ${{ secrets.CF_R2_RELEASE_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.CF_R2_RELEASE_AWS_KEY }}

View File

@@ -89,11 +89,11 @@ jobs:
/nix
/zig
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -147,7 +147,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -160,7 +160,7 @@ jobs:
- name: Setup Sparkle
env:
SPARKLE_VERSION: 2.9.0
SPARKLE_VERSION: 2.9.6
run: |
mkdir -p .action/sparkle
cd .action/sparkle
@@ -328,7 +328,7 @@ jobs:
- name: Setup Sparkle
env:
SPARKLE_VERSION: 2.9.0
SPARKLE_VERSION: 2.9.6
run: |
mkdir -p .action/sparkle
cd .action/sparkle
@@ -400,7 +400,7 @@ jobs:
mv Ghostty.dmg blob/${GHOSTTY_VERSION}/Ghostty.dmg
mv appcast.xml blob/${GHOSTTY_VERSION}/appcast-staged.xml
- name: Upload to R2
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
uses: ryand56/r2-upload-action@33ebb7a494b3a8a3c5ed2ea6bbdc72e707090317 # v1.5
with:
r2-account-id: ${{ secrets.CF_R2_RELEASE_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.CF_R2_RELEASE_AWS_KEY }}

View File

@@ -42,10 +42,10 @@ jobs:
with:
path: |
/nix
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -219,10 +219,10 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -239,7 +239,7 @@ jobs:
nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password
- name: Update Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@@ -274,10 +274,10 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -294,7 +294,7 @@ jobs:
nix develop -c minisign -S -m libghostty-vt-source.tar.gz -s minisign.key < minisign.password
- name: Update Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@@ -310,7 +310,7 @@ jobs:
mkdir -p blob/${GHOSTTY_COMMIT_LONG}
cp libghostty-vt-source.tar.gz blob/${GHOSTTY_COMMIT_LONG}/libghostty-vt-source.tar.gz
- name: Upload to R2
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
uses: ryand56/r2-upload-action@33ebb7a494b3a8a3c5ed2ea6bbdc72e707090317 # v1.5
with:
r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }}
@@ -356,7 +356,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -379,7 +379,7 @@ jobs:
nix develop -c minisign -S -m ghostty-vt.xcframework.zip -s minisign.key < minisign.password
- name: Update Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@@ -395,7 +395,7 @@ jobs:
mkdir -p blob/${GHOSTTY_COMMIT_LONG}
cp ghostty-vt.xcframework.zip blob/${GHOSTTY_COMMIT_LONG}/ghostty-vt.xcframework.zip
- name: Upload to R2
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
uses: ryand56/r2-upload-action@33ebb7a494b3a8a3c5ed2ea6bbdc72e707090317 # v1.5
with:
r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }}
@@ -409,6 +409,135 @@ jobs:
echo "Release URLs:"
echo " XCFramework: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/ghostty-vt.xcframework.zip"
build-lib-vt-wasm:
needs: [setup]
if: |
needs.setup.outputs.should_skip != 'true' &&
(
github.event_name == 'workflow_dispatch' ||
(
github.repository_owner == 'ghostty-org' &&
github.ref_name == 'main'
)
)
runs-on: namespace-profile-ghostty-sm
env:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build ReleaseFast WASM
run: |
nix develop -c zig build \
-Demit-lib-vt \
-Dtarget=wasm32-freestanding \
-Doptimize=ReleaseFast
- name: Optimize ReleaseFast WASM
run: |
nix develop -c wasm-opt -O3 --strip-dwarf \
--enable-simd \
--enable-bulk-memory \
--enable-sign-ext \
--enable-nontrapping-float-to-int \
--enable-multivalue \
--enable-reference-types \
zig-out/bin/ghostty-vt.wasm \
-o ghostty-vt.wasm
- name: Build ReleaseSmall WASM
run: |
nix develop -c zig build \
-Demit-lib-vt \
-Dtarget=wasm32-freestanding \
-Doptimize=ReleaseSmall
- name: Optimize ReleaseSmall WASM
run: |
nix develop -c wasm-opt -O3 \
--enable-simd \
--enable-bulk-memory \
--enable-sign-ext \
--enable-nontrapping-float-to-int \
--enable-multivalue \
--enable-reference-types \
zig-out/bin/ghostty-vt.wasm \
-o ghostty-vt-small.wasm
- name: Verify WASM simd128
run: |
# wasm-tools names the simd128 proposal "simd". The artifact must
# fail validation when that feature is disabled.
for artifact in ghostty-vt.wasm ghostty-vt-small.wasm; do
nix develop -c wasm-tools validate "${artifact}"
if nix develop -c wasm-tools validate \
--features=-simd "${artifact}"; then
echo "${artifact} unexpectedly validates with simd128 disabled" >&2
exit 1
fi
echo "Verified ${artifact} requires simd128"
done
- name: Sign WASM
run: |
echo -n "${{ secrets.MINISIGN_KEY }}" > minisign.key
echo -n "${{ secrets.MINISIGN_PASSWORD }}" > minisign.password
nix develop -c minisign -S -m ghostty-vt.wasm -s minisign.key < minisign.password
nix develop -c minisign -S -m ghostty-vt-small.wasm -s minisign.key < minisign.password
- name: Update Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
tag_name: tip
target_commitish: ${{ github.sha }}
files: |
ghostty-vt.wasm
ghostty-vt.wasm.minisig
ghostty-vt-small.wasm
ghostty-vt-small.wasm.minisig
token: ${{ secrets.GH_RELEASE_TOKEN }}
- name: Prep R2 Storage
run: |
mkdir -p blob/${GHOSTTY_COMMIT_LONG}
cp ghostty-vt.wasm blob/${GHOSTTY_COMMIT_LONG}/ghostty-vt.wasm
cp ghostty-vt-small.wasm blob/${GHOSTTY_COMMIT_LONG}/ghostty-vt-small.wasm
- name: Upload to R2
uses: ryand56/r2-upload-action@33ebb7a494b3a8a3c5ed2ea6bbdc72e707090317 # v1.5
with:
r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }}
r2-secret-access-key: ${{ secrets.CF_R2_TIP_SECRET_KEY }}
r2-bucket: ghostty-tip
source-dir: blob
destination-dir: ./
- name: Echo Release URLs
run: |
echo "Release URLs:"
echo " WASM (ReleaseFast): https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/ghostty-vt.wasm"
echo " WASM (ReleaseSmall): https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/ghostty-vt-small.wasm"
build-macos:
needs: [setup]
if: |
@@ -448,7 +577,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -462,7 +591,7 @@ jobs:
# Setup Sparkle
- name: Setup Sparkle
env:
SPARKLE_VERSION: 2.9.0
SPARKLE_VERSION: 2.9.6
run: |
mkdir -p .action/sparkle
cd .action/sparkle
@@ -581,7 +710,7 @@ jobs:
# Update Release
- name: Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@@ -616,7 +745,7 @@ jobs:
cp Ghostty.dmg blob/${GHOSTTY_COMMIT_LONG}/Ghostty.dmg
- name: Upload to R2
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
uses: ryand56/r2-upload-action@33ebb7a494b3a8a3c5ed2ea6bbdc72e707090317 # v1.5
with:
r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }}
@@ -640,7 +769,7 @@ jobs:
if: |
github.repository_owner == 'ghostty-org' &&
github.ref_name == 'main'
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
uses: ryand56/r2-upload-action@33ebb7a494b3a8a3c5ed2ea6bbdc72e707090317 # v1.5
with:
r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }}
@@ -659,7 +788,7 @@ jobs:
EOF
- name: Upload Release URLs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v6.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-urls-${{ inputs.pr || '0' }}
path: release-urls.txt
@@ -704,7 +833,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -718,7 +847,7 @@ jobs:
# Setup Sparkle
- name: Setup Sparkle
env:
SPARKLE_VERSION: 2.9.0
SPARKLE_VERSION: 2.9.6
run: |
mkdir -p .action/sparkle
cd .action/sparkle
@@ -830,7 +959,7 @@ jobs:
# Update Release
- name: Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@@ -847,7 +976,7 @@ jobs:
cp ghostty-macos-universal-debug-slow.zip blob/${GHOSTTY_COMMIT_LONG}/ghostty-macos-universal-debug-slow.zip
cp ghostty-macos-universal-debug-slow-dsym.zip blob/${GHOSTTY_COMMIT_LONG}/ghostty-macos-universal-debug-slow-dsym.zip
- name: Upload to R2
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
uses: ryand56/r2-upload-action@33ebb7a494b3a8a3c5ed2ea6bbdc72e707090317 # v1.5
with:
r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }}
@@ -901,7 +1030,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -915,7 +1044,7 @@ jobs:
# Setup Sparkle
- name: Setup Sparkle
env:
SPARKLE_VERSION: 2.9.0
SPARKLE_VERSION: 2.9.6
run: |
mkdir -p .action/sparkle
cd .action/sparkle
@@ -1027,7 +1156,7 @@ jobs:
# Update Release
- name: Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@@ -1044,7 +1173,7 @@ jobs:
cp ghostty-macos-universal-debug-fast.zip blob/${GHOSTTY_COMMIT_LONG}/ghostty-macos-universal-debug-fast.zip
cp ghostty-macos-universal-debug-fast-dsym.zip blob/${GHOSTTY_COMMIT_LONG}/ghostty-macos-universal-debug-fast-dsym.zip
- name: Upload to R2
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
uses: ryand56/r2-upload-action@33ebb7a494b3a8a3c5ed2ea6bbdc72e707090317 # v1.5
with:
r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }}

View File

@@ -94,6 +94,9 @@ jobs:
- build-cmake
- build-flatpak
- build-libghostty-vt
- build-libghostty-vt-freestanding
- build-libghostty-vt-wasm
- build-libghostty-vt-features
- build-libghostty-vt-android
- build-libghostty-vt-macos
- build-libghostty-vt-windows
@@ -169,10 +172,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -227,10 +230,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -263,7 +266,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -295,10 +298,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -364,7 +367,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -401,10 +404,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -439,10 +442,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -556,10 +559,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -590,10 +593,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -616,7 +619,6 @@ jobs:
x86_64-windows-gnu,
# doesn't work yet, we need a way to find msvc libc/c++ headers
# x86_64-windows-msvc
wasm32-freestanding,
]
runs-on: namespace-profile-ghostty-sm
needs: test
@@ -635,10 +637,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -648,6 +650,180 @@ jobs:
nix develop -c zig build -Demit-lib-vt \
-Dtarget=${{ matrix.target }}
build-libghostty-vt-freestanding:
strategy:
matrix:
include:
- target: riscv32-freestanding-eabi
cpu: baseline
- target: thumb-freestanding-eabi
cpu: cortex_m4
runs-on: namespace-profile-ghostty-sm
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build
run: |
nix develop -c zig build -Demit-lib-vt \
-Dtarget=${{ matrix.target }} \
-Dcpu=${{ matrix.cpu }} \
-Doptimize=ReleaseSafe
build-libghostty-vt-wasm:
runs-on: namespace-profile-ghostty-sm
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build ReleaseFast WASM
run: |
nix develop -c zig build \
-Demit-lib-vt \
-Dtarget=wasm32-freestanding \
-Doptimize=ReleaseFast
- name: Optimize ReleaseFast WASM
run: |
nix develop -c wasm-opt -O3 --strip-dwarf \
--enable-simd \
--enable-bulk-memory \
--enable-sign-ext \
--enable-nontrapping-float-to-int \
--enable-multivalue \
--enable-reference-types \
zig-out/bin/ghostty-vt.wasm \
-o ghostty-vt.wasm
- name: Build ReleaseSmall WASM
run: |
nix develop -c zig build \
-Demit-lib-vt \
-Dtarget=wasm32-freestanding \
-Doptimize=ReleaseSmall
- name: Validate WASM ABI manifest
run: |
nix develop -c zig build test-lib-vt-schema \
-Dtarget=wasm32-freestanding \
-Doptimize=ReleaseSmall
- name: Optimize ReleaseSmall WASM
run: |
nix develop -c wasm-opt -O3 \
--enable-simd \
--enable-bulk-memory \
--enable-sign-ext \
--enable-nontrapping-float-to-int \
--enable-multivalue \
--enable-reference-types \
zig-out/bin/ghostty-vt.wasm \
-o ghostty-vt-small.wasm
- name: Verify WASM simd128
run: |
# wasm-tools names the simd128 proposal "simd". The artifact must
# fail validation when that feature is disabled.
for artifact in ghostty-vt.wasm ghostty-vt-small.wasm; do
nix develop -c wasm-tools validate "${artifact}"
if nix develop -c wasm-tools validate \
--features=-simd "${artifact}"; then
echo "${artifact} unexpectedly validates with simd128 disabled" >&2
exit 1
fi
echo "Verified ${artifact} requires simd128"
done
- name: Test WASM allocator
run: |
nix develop -c node test/wasm-alloc.mjs ghostty-vt.wasm
nix develop -c node test/wasm-alloc.mjs ghostty-vt-small.wasm
# Compile-only checks for the -Dvt-features flags so that future changes
# don't regress any feature combination.
build-libghostty-vt-features:
runs-on: namespace-profile-ghostty-sm
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Compile feature flag matrix
run: |
features="snapshot formatter selection render_state input_encode color grid_introspection glyph_protocol kitty_graphics"
configs="-all"
for f in $features; do configs="$configs -all,+$f"; done
for cfg in $configs; do
echo "::group::-Dvt-features=$cfg"
# The wasm lib build compiles the C API export block; the
# test build compiles every module test without running any.
nix develop -c zig build -Demit-lib-vt \
"-Dvt-features=$cfg" \
-Dtarget=wasm32-freestanding
nix develop -c zig build test-lib-vt-build \
"-Dvt-features=$cfg"
echo "::endgroup::"
done
# lib-vt requires macOS runner for macOS/iOS builds because it requires the `apple_sdk` path
build-libghostty-vt-macos:
strategy:
@@ -690,7 +866,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -735,10 +911,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -816,10 +992,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -845,10 +1021,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -878,10 +1054,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -960,10 +1136,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1000,10 +1176,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1068,10 +1244,10 @@ jobs:
# uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# # Install Nix and use that to run our tests so our environment matches exactly.
# - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
# - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
# with:
# nix_path: nixpkgs=channel:nixos-unstable
# - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
# - uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
# with:
# name: ghostty
# authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1134,7 +1310,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1186,7 +1362,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1226,7 +1402,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1277,10 +1453,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1334,10 +1510,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1345,6 +1521,9 @@ jobs:
- name: Test
run: nix develop -c zig build test-lib-vt
- name: Validate native ABI manifest
run: nix develop -c zig build test-lib-vt-schema
test-kaitai:
if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true'
needs: skip
@@ -1359,10 +1538,10 @@ jobs:
path: /nix
# Install Nix so the verifier uses the pinned compiler and runtimes.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1394,10 +1573,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1442,10 +1621,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1477,10 +1656,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1511,7 +1690,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1567,10 +1746,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1598,10 +1777,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1640,10 +1819,10 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1671,10 +1850,10 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1701,10 +1880,10 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1733,7 +1912,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1759,10 +1938,10 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1787,10 +1966,10 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1815,10 +1994,10 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1848,10 +2027,10 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1876,10 +2055,10 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1913,10 +2092,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1975,10 +2154,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"

View File

@@ -29,10 +29,10 @@ jobs:
/zig
- name: Setup Nix
uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
- uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -52,7 +52,7 @@ jobs:
echo "filename=$FILENAME" >> $GITHUB_OUTPUT
- name: Upload to R2
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
uses: ryand56/r2-upload-action@33ebb7a494b3a8a3c5ed2ea6bbdc72e707090317 # v1.5
with:
r2-account-id: ${{ secrets.CF_R2_DEPS_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.CF_R2_DEPS_AWS_KEY }}

View File

@@ -168,6 +168,7 @@
/po/be.po @ghostty-org/be_BY
/po/bg.po @ghostty-org/bg_BG
/po/ca.po @ghostty-org/ca_ES
/po/da.po @ghostty-org/da_DK
/po/de.po @ghostty-org/de_DE
/po/es_AR.po @ghostty-org/es_AR
/po/es_BO.po @ghostty-org/es_BO
@@ -191,6 +192,8 @@
/po/pl.po @ghostty-org/pl_PL
/po/pt_BR.po @ghostty-org/pt_BR
/po/ru.po @ghostty-org/ru_RU
/po/sr.po @ghostty-org/sr_RS
/po/sr@latin.po @ghostty-org/sr_RS
/po/tr.po @ghostty-org/tr_TR
/po/uk.po @ghostty-org/uk_UA
/po/vi.po @ghostty-org/vi_VN

View File

@@ -57,7 +57,8 @@ GENERATE_HTML = YES
HTML_OUTPUT = zig-out/share/ghostty/doc/libghostty
HTML_EXTRA_STYLESHEET = dist/doxygen/ghostty.css
HTML_EXTRA_FILES = dist/doxygen/favicon.png \
dist/doxygen/mobile-nav.js
dist/doxygen/mobile-nav.js \
src/terminal/c/types.schema.json
HTML_COLORSTYLE = DARK
HTML_CODE_FOLDING = NO
HTML_HEADER = dist/doxygen/header.html

View File

@@ -122,3 +122,56 @@ relevant to package maintainers:
often necessary for system packages to specify a specific minimum Linux
version, glibc, etc. Run `zig targets` to a get a full list of available
targets.
## WebAssembly (libghostty-vt)
libghostty-vt can be built for WebAssembly for use in browsers and other
wasm runtimes:
```sh
zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall
```
This produces `zig-out/bin/ghostty-vt.wasm`.
Some notes for packaging the wasm module:
- The build enables the `simd128` feature by default. Every browser engine
has supported it for years (Chrome 91, Firefox 89, Safari 16.4) and it is
a large performance win for VT parsing. If you target an unusual runtime
without SIMD support, opt out with `-Dcpu=generic`.
- Optional feature areas can be compiled out with `-Dvt-features` to
significantly reduce binary size. The flag takes comma-separated
modifications applied to the default all-enabled feature set,
`-Dcpu`-style: `+feature` (or bare `feature`) enables, `-feature`
disables, and the special name `all` refers to every feature. Hyphens
and underscores are interchangeable in feature names. For example, a
read-only terminal viewer only needs the render state API:
```sh
zig build -Demit-lib-vt -Dtarget=wasm32-freestanding \
-Doptimize=ReleaseSmall -Dvt-features=-all,+render-state
```
This roughly halves the compressed module size versus the default
build. An interactive terminal typically wants
`-Dvt-features=-all,+render-state,+input-encode,+selection,+color`.
Disabled features drop both their C API exports and any escape
sequence handling (the sequences are still consumed and safely
ignored). See the `Features` struct in `src/terminal/build_options.zig`
for the full list of features and what each one covers.
- `ReleaseSmall` is the recommended optimization mode for the web. Running
the result through [Binaryen's](https://github.com/WebAssembly/binaryen)
`wasm-opt -O3` shrinks it by roughly a further 10% without hurting
performance.
- `ReleaseFast` measures 10-20% faster than `ReleaseSmall` on escape-heavy
terminal workloads, but the artifact is dominated by DWARF debug info.
If you want the speed, strip it: `wasm-opt -O3 --strip-dwarf` reduces a
ReleaseFast build from over 5MB to roughly 1.1MB (versus roughly 0.8MB
for ReleaseSmall). When invoking `wasm-opt`, pass the feature flags for
what the module uses, e.g. `--enable-simd --enable-bulk-memory
--enable-sign-ext --enable-nontrapping-float-to-int --enable-multivalue
--enable-reference-types`.

View File

@@ -69,6 +69,14 @@ pub fn build(b: *std.Build) !void {
"test-lib-vt",
"Run libghostty-vt tests",
);
const test_lib_vt_build_step = b.step(
"test-lib-vt-build",
"Build libghostty-vt tests without running them (compile check)",
);
const test_lib_vt_schema_step = b.step(
"test-lib-vt-schema",
"Validate the libghostty-vt ABI type manifest",
);
const test_valgrind_step = b.step(
"test-valgrind",
"Run tests under valgrind",
@@ -117,20 +125,36 @@ pub fn build(b: *std.Build) !void {
}
// libghostty-vt
const libghostty_vt_shared = shared: {
const native_freestanding = config.target.result.os.tag == .freestanding and
!config.target.result.cpu.arch.isWasm();
const libghostty_vt_shared: ?buildpkg.GhosttyLibVt = shared: {
if (config.target.result.cpu.arch.isWasm()) {
break :shared try buildpkg.GhosttyLibVt.initWasm(
b,
&mod,
);
}
if (native_freestanding) break :shared null;
break :shared try buildpkg.GhosttyLibVt.initShared(
b,
&mod,
);
};
libghostty_vt_shared.install(b.getInstallStep());
if (libghostty_vt_shared) |shared| {
shared.install(b.getInstallStep());
const type_schema_test = b.addSystemCommand(&.{"python3"});
type_schema_test.addFileArg(b.path("src/terminal/c/types-schema-verify.py"));
type_schema_test.addFileArg(b.path("src/terminal/c/types.schema.json"));
type_schema_test.addFileArg(shared.output);
test_lib_vt_schema_step.dependOn(&type_schema_test.step);
} else {
try test_lib_vt_schema_step.addError(
"cannot execute the ABI manifest for a native freestanding target",
.{},
);
}
// libghostty-vt static lib
const libghostty_vt_static = try buildpkg.GhosttyLibVt.initStatic(
@@ -153,6 +177,15 @@ pub fn build(b: *std.Build) !void {
libghostty_vt_static.output,
static_lib_name,
).step);
if (native_freestanding) {
b.getInstallStep().dependOn(&b.addInstallDirectory(.{
.source_dir = b.path("include/ghostty"),
.install_dir = .header,
.install_subdir = "ghostty",
.include_extensions = &.{".h"},
}).step);
}
}
// libghostty-vt xcframework (Apple only, universal binary).
@@ -312,6 +345,7 @@ pub fn build(b: *std.Build) !void {
const run_cmd = b.addSystemCommand(&.{
"valgrind",
"--leak-check=full",
"--error-exitcode=1",
"--num-callers=50",
b.fmt("--suppressions={s}", .{b.pathFromRoot("valgrind.supp")}),
"--gen-suppressions=all",
@@ -329,6 +363,7 @@ pub fn build(b: *std.Build) !void {
});
const mod_vt_test_run = b.addRunArtifact(mod_vt_test);
test_lib_vt_step.dependOn(&mod_vt_test_run.step);
test_lib_vt_build_step.dependOn(&mod_vt_test.step);
const mod_vt_c_test = b.addTest(.{
.root_module = mod.vt_c,
@@ -336,6 +371,7 @@ pub fn build(b: *std.Build) !void {
});
const mod_vt_c_test_run = b.addRunArtifact(mod_vt_c_test);
test_lib_vt_step.dependOn(&mod_vt_c_test_run.step);
test_lib_vt_build_step.dependOn(&mod_vt_c_test.step);
}
// Tests (skip when building libghostty-vt)
@@ -376,6 +412,7 @@ pub fn build(b: *std.Build) !void {
const valgrind_run = b.addSystemCommand(&.{
"valgrind",
"--leak-check=full",
"--error-exitcode=1",
"--num-callers=50",
b.fmt("--suppressions={s}", .{b.pathFromRoot("valgrind.supp")}),
"--gen-suppressions=all",

View File

@@ -11,8 +11,8 @@
// Aro/translate-c itself.
.translate_c = .{
.lazy = true,
.url = "https://deps.files.ghostty.org/translate_c-80f8b6e4f45a303268717d8e5f4f91d7837138bb.tar.gz",
.hash = "translate_c-0.0.0-Q_BUWmU6BwB_9JKG2l2W7i_mhmYWeRseTGBEHi_YlV5f",
.url = "https://codeberg.org/vancluever/translate-c/archive/4e879eb8aba615de112eabd1231ea6e01920cead.tar.gz",
.hash = "translate_c-0.0.0-Q_BUWhVNBwDOEcIqub4VFPJPB6D9dgwzUMHTX5KWr8Xr",
},
// Zig libs
@@ -66,7 +66,7 @@
.gobject = .{
// https://github.com/ghostty-org/zig-gobject based on zig_gobject
// Temporary until we generate them at build time automatically.
.url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst",
.url = "https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst",
.hash = "gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3",
.lazy = true,
},
@@ -125,8 +125,8 @@
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.android_ndk = .{ .path = "./pkg/android-ndk" },
.iterm2_themes = .{
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz",
.hash = "N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl",
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260831-151010-752a9c0.tgz",
.hash = "N-V-__8AAEFmBABuDGOKxAI6VMg41b9euMZ-z7HS9EcUdaor",
.lazy = true,
},
},

32
build.zig.zon.json generated
View File

@@ -1,8 +1,8 @@
{
"aro-0.0.0-JSD1Qk8rOQDnuVcD4jAwMpHitA6pADRKzQ7M7hKRwxvD": {
"aro-0.0.0-JSD1Qk6lNgDdcDV4Vh7Sfy-34m2TluIVOdPzMmj_0BjX": {
"name": "aro",
"url": "https://github.com/vancluever/arocc/archive/ecbc5c799574e0da2758a961b12efa586007f03c.tar.gz",
"hash": "sha256-rjNfhWjmA/1WR/xuHo4ls4fnDCbsI1VZSvb8SFRvwso="
"url": "https://github.com/vancluever/arocc/archive/f97cdfc3779aec4b242299e2fc9a1c828c3547c6.tar.gz",
"hash": "sha256-G/NNgk7KhJSLdy17ip1igIzpYhAUlzO7ef8r3/iCv/s="
},
"N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr": {
"name": "bindings",
@@ -14,10 +14,10 @@
"url": "https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz",
"hash": "sha256-bMqYlD0amQdmzvYQd8Ca/1k4Bj/heh7+EijlQSttatk="
},
"N-V-__8AAIrfdwARSa-zMmxWwFuwpXf1T3asIN7s5jqi9c1v": {
"N-V-__8AAOgqbADacob-q2_DMQlmgaG4xKHRuW-6PJ4oJzMZ": {
"name": "fontconfig",
"url": "https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz",
"hash": "sha256-O6LdkhWHGKzsXKrxpxYEO1qgVcJ7CB2RSvPMtA3OilU="
"url": "https://gitlab.freedesktop.org/api/v4/projects/890/packages/generic/fontconfig/2.18.3/fontconfig-2.18.3.tar.xz",
"hash": "sha256-T3tVSjjN94wDP2ZsiHHzdJ4UoJT2Wgf2MMke0LQ9NeM="
},
"N-V-__8AAKLKpwC4H27Ps_0iL3bPkQb-z6ZVSrB-x_3EEkub": {
"name": "freetype",
@@ -36,7 +36,7 @@
},
"gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3": {
"name": "gobject",
"url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst",
"url": "https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst",
"hash": "sha256-dyCfm2XjiAk30zccjD6AgKFBdE7IRsJuoqnscfvnWSQ="
},
"N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": {
@@ -59,10 +59,10 @@
"url": "https://deps.files.ghostty.org/N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI.tar.gz",
"hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs="
},
"N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl": {
"N-V-__8AAEFmBABuDGOKxAI6VMg41b9euMZ-z7HS9EcUdaor": {
"name": "iterm2_themes",
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz",
"hash": "sha256-/qxLa9S8bSg2NAbJzwEtZhGvOxaPTAVFNWnBCs/MFBs="
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260831-151010-752a9c0.tgz",
"hash": "sha256-g2U+uDe661v8fVDJG31EdyeW4IwcJDE04Rxe32yNFcU="
},
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
"name": "jetbrains_mono",
@@ -114,10 +114,10 @@
"url": "https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz",
"hash": "sha256-tStvz8Ref6abHwahNiwVVHNETizAmZVVaxVsU7pmV+M="
},
"translate_c-0.0.0-Q_BUWmU6BwB_9JKG2l2W7i_mhmYWeRseTGBEHi_YlV5f": {
"translate_c-0.0.0-Q_BUWhVNBwDOEcIqub4VFPJPB6D9dgwzUMHTX5KWr8Xr": {
"name": "translate_c",
"url": "https://deps.files.ghostty.org/translate_c-80f8b6e4f45a303268717d8e5f4f91d7837138bb.tar.gz",
"hash": "sha256-fB7OsZ2PIijMzVMYg8SzDBtTKX7IZHbEvPuBTdyGtWk="
"url": "https://codeberg.org/vancluever/translate-c/archive/4e879eb8aba615de112eabd1231ea6e01920cead.tar.gz",
"hash": "sha256-/sT7W8Kp+O11xaFBgpb/kDiWzfQ0MuXk/d/TuGq1Am8="
},
"uucode-0.2.0-ZZjBPlK5VADj7fdoq7G8LIHzD5o6FSkcBXXrRWr4jnrA": {
"name": "uucode",
@@ -149,10 +149,10 @@
"url": "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz",
"hash": "sha256-XO3K3egbdeYPI+XoO13SuOtO+5+Peb16NH0UiusFMPg="
},
"N-V-__8AAAzZywE3s51XfsLbP9eyEw57ae9swYB9aGB6fCMs": {
"N-V-__8AAP5JWgCGP_AD0teWpa4krRvE9VPZzvviGdbmN4jI": {
"name": "wuffs",
"url": "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz",
"hash": "sha256-nkzSCr6W5sTG7enDBXEIhgEm574uLD41UVR2wlC+HBM="
"url": "https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz",
"hash": "sha256-F4d9NG95iGUdbGLkWy47BchoCaZELF40YSN0sqlxmhw="
},
"z2d-0.12.1-j5P_Hsw8EQAKyZTQICCQnAH2xYkLDW8k9uefbsYdfPZ-": {
"name": "z2d",

32
build.zig.zon.nix generated
View File

@@ -98,11 +98,11 @@
in
linkFarm name [
{
name = "aro-0.0.0-JSD1Qk8rOQDnuVcD4jAwMpHitA6pADRKzQ7M7hKRwxvD";
name = "aro-0.0.0-JSD1Qk6lNgDdcDV4Vh7Sfy-34m2TluIVOdPzMmj_0BjX";
path = fetchZigArtifact {
name = "aro";
url = "https://github.com/vancluever/arocc/archive/ecbc5c799574e0da2758a961b12efa586007f03c.tar.gz";
hash = "sha256-rjNfhWjmA/1WR/xuHo4ls4fnDCbsI1VZSvb8SFRvwso=";
url = "https://github.com/vancluever/arocc/archive/f97cdfc3779aec4b242299e2fc9a1c828c3547c6.tar.gz";
hash = "sha256-G/NNgk7KhJSLdy17ip1igIzpYhAUlzO7ef8r3/iCv/s=";
unpack = true;
};
}
@@ -125,11 +125,11 @@ in
};
}
{
name = "N-V-__8AAIrfdwARSa-zMmxWwFuwpXf1T3asIN7s5jqi9c1v";
name = "N-V-__8AAOgqbADacob-q2_DMQlmgaG4xKHRuW-6PJ4oJzMZ";
path = fetchZigArtifact {
name = "fontconfig";
url = "https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz";
hash = "sha256-O6LdkhWHGKzsXKrxpxYEO1qgVcJ7CB2RSvPMtA3OilU=";
url = "https://gitlab.freedesktop.org/api/v4/projects/890/packages/generic/fontconfig/2.18.3/fontconfig-2.18.3.tar.xz";
hash = "sha256-T3tVSjjN94wDP2ZsiHHzdJ4UoJT2Wgf2MMke0LQ9NeM=";
unpack = false;
};
}
@@ -164,7 +164,7 @@ in
name = "gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3";
path = fetchZigArtifact {
name = "gobject";
url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst";
url = "https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst";
hash = "sha256-dyCfm2XjiAk30zccjD6AgKFBdE7IRsJuoqnscfvnWSQ=";
unpack = true;
};
@@ -206,11 +206,11 @@ in
};
}
{
name = "N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl";
name = "N-V-__8AAEFmBABuDGOKxAI6VMg41b9euMZ-z7HS9EcUdaor";
path = fetchZigArtifact {
name = "iterm2_themes";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz";
hash = "sha256-/qxLa9S8bSg2NAbJzwEtZhGvOxaPTAVFNWnBCs/MFBs=";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260831-151010-752a9c0.tgz";
hash = "sha256-g2U+uDe661v8fVDJG31EdyeW4IwcJDE04Rxe32yNFcU=";
unpack = false;
};
}
@@ -305,11 +305,11 @@ in
};
}
{
name = "translate_c-0.0.0-Q_BUWmU6BwB_9JKG2l2W7i_mhmYWeRseTGBEHi_YlV5f";
name = "translate_c-0.0.0-Q_BUWhVNBwDOEcIqub4VFPJPB6D9dgwzUMHTX5KWr8Xr";
path = fetchZigArtifact {
name = "translate_c";
url = "https://deps.files.ghostty.org/translate_c-80f8b6e4f45a303268717d8e5f4f91d7837138bb.tar.gz";
hash = "sha256-fB7OsZ2PIijMzVMYg8SzDBtTKX7IZHbEvPuBTdyGtWk=";
url = "https://codeberg.org/vancluever/translate-c/archive/4e879eb8aba615de112eabd1231ea6e01920cead.tar.gz";
hash = "sha256-/sT7W8Kp+O11xaFBgpb/kDiWzfQ0MuXk/d/TuGq1Am8=";
unpack = true;
};
}
@@ -368,11 +368,11 @@ in
};
}
{
name = "N-V-__8AAAzZywE3s51XfsLbP9eyEw57ae9swYB9aGB6fCMs";
name = "N-V-__8AAP5JWgCGP_AD0teWpa4krRvE9VPZzvviGdbmN4jI";
path = fetchZigArtifact {
name = "wuffs";
url = "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz";
hash = "sha256-nkzSCr6W5sTG7enDBXEIhgEm574uLD41UVR2wlC+HBM=";
url = "https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz";
hash = "sha256-F4d9NG95iGUdbGLkWy47BchoCaZELF40YSN0sqlxmhw=";
unpack = false;
};
}

12
build.zig.zon.txt generated
View File

@@ -1,16 +1,17 @@
git+https://github.com/rockorager/libvaxis.git#c1e1f23be38951c425cdf31af455ba23ef178940
git+https://github.com/zigimg/zigimg#d695acd97c02e57bb151e8f659d1280f5cd6ca70
https://codeberg.org/vancluever/translate-c/archive/4e879eb8aba615de112eabd1231ea6e01920cead.tar.gz
https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz
https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz
https://deps.files.ghostty.org/N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI.tar.gz
https://deps.files.ghostty.org/N-V-__8AAFdWDwA0ktbNUi9pFBHCRN4weXIgIfCrVjfGxqgA.tar.gz
https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz
https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz
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-20260803-155300-875a82f.tgz
https://deps.files.ghostty.org/ghostty-themes-release-20260831-151010-752a9c0.tgz
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz
https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz
@@ -22,17 +23,16 @@ https://deps.files.ghostty.org/pixels-12207ff340169c7d40c570b4b6a97db614fe47e0d8
https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566.tar.gz
https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e.tar.gz
https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz
https://deps.files.ghostty.org/translate_c-80f8b6e4f45a303268717d8e5f4f91d7837138bb.tar.gz
https://deps.files.ghostty.org/uucode-2826a37a4562284fdacd8fa029d49509cc9bffcd.tar.gz
https://deps.files.ghostty.org/vaxis-1dbbe575dff4586fe51e3217aa5c3fecdcbb6089.tar.gz
https://deps.files.ghostty.org/wayland-0.6.0-lQa1kqz8AQADQmdNJsNhLoNHcnEGEUjrOaPV-dtEnEmX.tar.gz
https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz
https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz
https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz
https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz
https://deps.files.ghostty.org/z2d-7dbae85c81784dba9988320bf9543ed9a81350c8.tar.gz
https://deps.files.ghostty.org/zf-c35c421f84895193246db06c40683c1a30e616ef.tar.gz
https://deps.files.ghostty.org/zig_js-3c23860e47fdcdc5af805efb7fd0bdac5fd3e9bc.tar.gz
https://deps.files.ghostty.org/zig_objc-c8de82ff80281215ad92900866dab7103a8efa8b.tar.gz
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz
https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst
https://github.com/vancluever/arocc/archive/ecbc5c799574e0da2758a961b12efa586007f03c.tar.gz
https://github.com/vancluever/arocc/archive/f97cdfc3779aec4b242299e2fc9a1c828c3547c6.tar.gz
https://gitlab.freedesktop.org/api/v4/projects/890/packages/generic/fontconfig/2.18.3/fontconfig-2.18.3.tar.xz

View File

@@ -40,13 +40,17 @@ void on_title_changed(GhosttyTerminal terminal, void* userdata) {
//! [effects-title-changed]
//! [effects-clipboard-write]
GhosttyClipboardWriteResult on_clipboard_write(
void on_clipboard_write(
GhosttyTerminal terminal,
void* userdata,
const GhosttyClipboardWrite* write) {
(void)terminal;
(void)userdata;
// The write is synchronous: a real embedder would ask the user for
// permission here (unless write->granted) and the VT stream waits until
// this callback returns. The replied result is sent to the program
// (OSC 5522) through the write_pty callback.
printf(" clipboard write (location=%d, contents=%zu)\n",
(int)write->location, write->contents_len);
if (write->contents_len == 0) {
@@ -66,10 +70,55 @@ GhosttyClipboardWriteResult on_clipboard_write(
printf("\n");
}
return GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS;
GhosttyClipboardWriteReply reply = {
.size = sizeof(reply),
.result = GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS,
.remember = false,
};
write->reply(write, &reply);
}
//! [effects-clipboard-write]
//! [effects-clipboard-read]
void on_clipboard_read(
GhosttyTerminal terminal,
void* userdata,
const GhosttyClipboardRead* read) {
(void)terminal;
(void)userdata;
// The read is synchronous: a real embedder would ask the user for
// permission here (unless read->granted) and the VT stream waits until
// this callback returns. The reply is sent to the program through the
// write_pty callback.
printf(" clipboard read (location=%d, mimes=%zu)\n",
(int)read->location, read->mimes_len);
for (size_t i = 0; i < read->mimes_len; i++) {
printf(" ");
fwrite(read->mimes[i].ptr, 1, read->mimes[i].len, stdout);
printf("\n");
}
// Reply with every requested representation we have. This example only
// has text.
const char* text = "Hello from the clipboard";
GhosttyClipboardContent content = {
.mime = {.ptr = (const uint8_t*)"text/plain", .len = 10},
.data = {.ptr = (const uint8_t*)text, .len = strlen(text)},
};
GhosttyClipboardReadReply reply = {
.size = sizeof(reply),
.result = GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS,
.contents = &content,
.contents_len = 1,
.available = NULL,
.available_len = 0,
.remember = false,
};
read->reply(read, &reply);
}
//! [effects-clipboard-read]
//! [effects-unknown-sequence]
void on_unknown_sequence(
GhosttyTerminal terminal,
@@ -118,6 +167,8 @@ int main() {
(const void *)on_title_changed);
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE,
(const void *)on_clipboard_write);
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ,
(const void *)on_clipboard_read);
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE,
(const void *)on_unknown_sequence);
@@ -154,13 +205,19 @@ int main() {
ghostty_terminal_vt_write(terminal, (const uint8_t*)clipboard_seq,
strlen(clipboard_seq));
// 5. Unsupported APC sequence
// 5. Clipboard read (OSC 52 ; c ; ? ST)
printf("Sending clipboard read:\n");
const char* clipboard_read_seq = "\x1B]52;c;?\x1B\\";
ghostty_terminal_vt_write(terminal, (const uint8_t*)clipboard_read_seq,
strlen(clipboard_read_seq));
// 6. Unsupported APC sequence
printf("Sending unknown APC:\n");
const char* unknown_apc = "\x1B_private-command;payload\x1B\\";
ghostty_terminal_vt_write(terminal, (const uint8_t*)unknown_apc,
strlen(unknown_apc));
// 6. Another bell to show the counter increments
// 7. Another bell to show the counter increments
printf("Sending another BEL:\n");
ghostty_terminal_vt_write(terminal, &bel, 1);

View File

@@ -4,6 +4,24 @@
#include <string.h>
#include <ghostty/vt.h>
typedef struct {
FILE *file;
size_t written;
} OutputWriter;
static bool write_output(void *userdata, const uint8_t *data, size_t len) {
OutputWriter *output = userdata;
size_t offset = 0;
while (offset < len) {
size_t written = fwrite(data + offset, 1, len - offset, output->file);
output->written += written;
offset += written;
if (written == 0) return false;
}
return true;
}
int main() {
// Create a terminal with a small grid
GhosttyTerminal terminal;
@@ -14,8 +32,8 @@ int main() {
// cursor movement and styling sequences.
const char *commands[] = {
"Line 1: Hello World!\r\n", // Simple text on row 1
"Line 2: \033[1mBold\033[0m and " // Bold text on row 2
"\033[4mUnderline\033[0m\r\n",
("Line 2: \033[1mBold\033[0m and " // Bold text on row 2
"\033[4mUnderline\033[0m\r\n"),
"Line 3: placeholder\r\n", // Will be overwritten below
"\033[3;1H", // CUP: move cursor back to row 3, col 1
"\033[2K", // EL: erase the entire line
@@ -39,19 +57,16 @@ int main() {
result = ghostty_formatter_terminal_new(NULL, &formatter, terminal, fmt_opts);
assert(result == GHOSTTY_SUCCESS);
// Format into an allocated buffer
uint8_t *buf = NULL;
size_t len = 0;
result = ghostty_formatter_format_alloc(formatter, NULL, &buf, &len);
// Stream the formatted output directly to stdout. The writer retains the
// exact byte count and reports destination errors through its return value.
OutputWriter output = {.file = stdout};
GhosttyWriter writer = {.write = write_output, .userdata = &output};
printf("Formatted output:\n");
result = ghostty_formatter_format(formatter, writer);
assert(result == GHOSTTY_SUCCESS);
// Print the formatted output
printf("Formatted output (%zu bytes):\n", len);
fwrite(buf, 1, len, stdout);
printf("\n");
printf("\n(%zu bytes)\n", output.written);
// Clean up
ghostty_free(NULL, buf, len);
ghostty_formatter_free(formatter);
ghostty_terminal_free(terminal);
return 0;

View File

@@ -1,7 +1,13 @@
# Example: `ghostty-vt` Paste Utilities
# Example: `ghostty-vt` Paste
This contains a simple example of how to use the `ghostty-vt` paste
utilities to check if paste data is safe and encode it for terminal input.
This contains a simple example of how to paste into a `ghostty-vt`
terminal with `ghostty_terminal_paste`: plain and bracketed (mode 2004)
text pastes, the unsafe-paste confirmation flow, and Kitty clipboard
protocol paste events (mode 5522) including the program's follow-up
clipboard read. The clipboard's data is produced on demand through a
read callback, so only what is actually pasted is ever read, and the
result streams to the pty in chunks. It also shows the terminal-free
building blocks for checking paste safety and encoding paste data.
This uses a `build.zig` and `Zig` to build the C program so that we
can reuse a lot of our build logic and depend directly on our source

View File

@@ -1,7 +1,160 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <ghostty/vt.h>
#define GS(s) ((GhosttyString){.ptr = (const uint8_t*)(s), .len = sizeof(s) - 1})
// Print bytes destined for the pty with control characters made visible.
static void print_escaped(const uint8_t* data, size_t len) {
for (size_t i = 0; i < len; i++) {
switch (data[i]) {
case 0x1b: printf("ESC"); break;
case '\r': printf("\\r"); break;
case '\n': printf("\\n"); break;
default: putchar(data[i]); break;
}
}
}
// The base64 password of the last paste event, captured from the OK
// packet so the example can play the program's side of the protocol.
static char event_pw[128];
// Everything the terminal writes to the running program: the pasted
// text, or the paste event packets when mode 5522 is enabled.
static void on_write_pty(GhosttyTerminal terminal,
void* userdata,
const uint8_t* data,
size_t len) {
(void)terminal;
(void)userdata;
printf(" -> pty (%zu bytes): ", len);
print_escaped(data, len);
printf("\n");
// A paste event's OK packet: OSC 5522 ; type=read:status=OK:pw=<b64> ST
const char* prefix = "\x1b]5522;type=read:status=OK:pw=";
size_t prefix_len = strlen(prefix);
if (len > prefix_len && memcmp(data, prefix, prefix_len) == 0) {
size_t end = prefix_len;
while (end < len && data[end] != 0x1b) end++;
size_t pw_len = end - prefix_len;
if (pw_len < sizeof(event_pw)) {
memcpy(event_pw, data + prefix_len, pw_len);
event_pw[pw_len] = 0;
}
}
}
// Serves clipboard reads. After a paste event the program's read arrives
// with `granted` set, because it carries the event's one-time password,
// so the embedder skips its permission prompt.
static void on_clipboard_read(GhosttyTerminal terminal,
void* userdata,
const GhosttyClipboardRead* read) {
(void)terminal;
(void)userdata;
printf(" clipboard read: name=\"");
fwrite(read->name.ptr, 1, read->name.len, stdout);
printf("\" granted=%s\n", read->granted ? "yes (no prompt needed)" : "no");
const char* text = "hello from the clipboard";
GhosttyClipboardContent content = {
.mime = GS("text/plain"),
.data = {.ptr = (const uint8_t*)text, .len = strlen(text)},
};
GhosttyClipboardReadReply reply = {
.size = sizeof(reply),
.result = GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS,
.contents = &content,
.contents_len = 1,
.available = NULL,
.available_len = 0,
.remember = false,
};
read->reply(read, &reply);
}
// A real embedder would show a dialog here.
static bool confirm_with_user(void) {
printf(" paste could inject commands; user confirmed\n");
return true;
}
//! [terminal-paste]
// What the clipboard holds. A real embedder would keep a handle to the
// pasteboard or its items here; the data is only produced on demand.
typedef struct {
const char* text;
} clipboard_t;
// Produces the data of one representation when the terminal needs it.
// Only the text is ever read: the image is listed on a paste event
// but never requested, so a large image costs nothing to paste.
// Nothing written to the writer is retained, so the data can be
// streamed from anywhere in pieces of any size.
static bool read_clipboard(void* userdata, GhosttyString mime, GhosttyWriter writer) {
clipboard_t* clipboard = userdata;
if (mime.len == strlen("text/plain") &&
memcmp(mime.ptr, "text/plain", mime.len) == 0) {
// Stream the text in small pieces just to show that it works.
const uint8_t* data = (const uint8_t*)clipboard->text;
size_t len = strlen(clipboard->text);
for (size_t offset = 0; offset < len; offset += 4) {
size_t n = len - offset < 4 ? len - offset : 4;
if (!writer.write(writer.userdata, data + offset, n)) return false;
}
return true;
}
printf(" image read requested, which never happens\n");
return false;
}
// Paste whatever the clipboard holds. The terminal applies its own
// state: bracketed paste framing (mode 2004) or a Kitty paste event
// (mode 5522) instead of the text.
static void paste_clipboard(GhosttyTerminal terminal, const char* text) {
clipboard_t clipboard = {.text = text};
GhosttyString mimes[] = {
// The first text representation is what a text paste writes.
GS("text/plain"),
// Listed on a paste event, never read.
GS("image/png"),
};
GhosttyPaste paste = {
.size = sizeof(paste),
.location = GHOSTTY_CLIPBOARD_LOCATION_STANDARD,
.source = GHOSTTY_PASTE_SOURCE_CLIPBOARD,
.mimes = mimes,
.mimes_len = sizeof(mimes) / sizeof(mimes[0]),
.reader = {.read = read_clipboard, .userdata = &clipboard},
.allow_unsafe = false,
};
bool written = false;
GhosttyResult result = ghostty_terminal_paste(terminal, &paste, &written);
if (result == GHOSTTY_REJECTED) {
// The text could inject commands (e.g. a newline outside of a
// bracketed paste). Nothing was written; ask, then retry.
if (!confirm_with_user()) return;
paste.allow_unsafe = true;
result = ghostty_terminal_paste(terminal, &paste, &written);
}
if (result != GHOSTTY_SUCCESS) {
fprintf(stderr, "paste failed: %d\n", (int)result);
return;
}
// Whether the pty got the text or a paste event depends on the
// terminal's modes; either way it went through write_pty above, in
// chunks as the text was read.
printf(" %s\n", written ? "written" : "nothing to paste");
}
//! [terminal-paste]
//! [paste-safety]
void safety_example() {
const char* safe_data = "hello world";
@@ -29,27 +182,87 @@ void encode_example() {
if (result == GHOSTTY_SUCCESS) {
printf("Encoded %zu bytes: ", written);
fwrite(buf, 1, written, stdout);
print_escaped((const uint8_t*)buf, written);
printf("\n");
}
}
//! [paste-encode]
static void vt_write(GhosttyTerminal terminal, const char* seq) {
ghostty_terminal_vt_write(terminal, (const uint8_t*)seq, strlen(seq));
}
int main() {
GhosttyTerminal terminal = NULL;
if (ghostty_terminal_new(NULL, &terminal, 80, 24) != GHOSTTY_SUCCESS) {
fprintf(stderr, "Failed to create terminal\n");
return 1;
}
// Pasted bytes and paste events go to write_pty. Serving clipboard
// reads is what lets the terminal send paste events at all: without
// this callback the program could never read the clipboard, so pastes
// stay text even when mode 5522 is enabled.
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_WRITE_PTY,
(const void*)on_write_pty);
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ,
(const void*)on_clipboard_read);
printf("Plain paste:\n");
paste_clipboard(terminal, "hello world");
printf("Paste with a newline (refused, then confirmed):\n");
paste_clipboard(terminal, "echo hi\n");
// The program enables bracketed paste: newlines are safe inside the
// frame and are preserved.
printf("Bracketed paste (mode 2004):\n");
vt_write(terminal, "\x1b[?2004h");
paste_clipboard(terminal, "line one\nline two");
// The program enables paste events: the clipboard's MIME types are
// listed with a one-time password instead of writing the data.
printf("Paste event (mode 5522):\n");
vt_write(terminal, "\x1b[?5522h");
paste_clipboard(terminal, "hello world");
// Play the program's side: read the clipboard with the password from
// the event. The read arrives granted and the data is served through
// write_pty as base64 without any permission prompt.
if (event_pw[0] != 0) {
printf("Program reads with the event password:\n");
char read_seq[256];
snprintf(read_seq, sizeof(read_seq),
"\x1b]5522;type=read:pw=%s:name=UGFzdGUgZXZlbnQ=;dGV4dC9wbGFpbg==\x1b\\",
event_pw);
vt_write(terminal, read_seq);
}
// Text inserted by other means (IME, drag and drop) is never an event.
printf("IME text with mode 5522 enabled:\n");
{
clipboard_t clipboard = {.text = "committed"};
GhosttyString mime = GS("text/plain");
GhosttyPaste paste = {
.size = sizeof(paste),
.location = GHOSTTY_CLIPBOARD_LOCATION_STANDARD,
.source = GHOSTTY_PASTE_SOURCE_TEXT,
.mimes = &mime,
.mimes_len = 1,
.reader = {.read = read_clipboard, .userdata = &clipboard},
.allow_unsafe = true,
};
bool written = false;
if (ghostty_terminal_paste(terminal, &paste, &written) == GHOSTTY_SUCCESS &&
written) {
printf(" written\n");
}
}
ghostty_terminal_free(terminal);
printf("\nTerminal-free building blocks:\n");
safety_example();
// Test unsafe paste data with bracketed paste end sequence
const char *unsafe_escape = "evil\x1b[201~code";
if (!ghostty_paste_is_safe(unsafe_escape, strlen(unsafe_escape))) {
printf("Data with escape sequence is UNSAFE\n");
}
// Test empty data
const char *empty_data = "";
if (ghostty_paste_is_safe(empty_data, 0)) {
printf("Empty data is safe\n");
}
encode_example();
return 0;

View File

@@ -97,7 +97,8 @@ int main(void) {
// state. These are needed to resolve palette-indexed cell colors.
GhosttyRenderStateColors colors =
GHOSTTY_INIT_SIZED(GhosttyRenderStateColors);
result = ghostty_render_state_colors_get(render_state, &colors);
result = ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_COLORS, &colors);
assert(result == GHOSTTY_SUCCESS);
printf("Background: #%02x%02x%02x\n",
@@ -107,31 +108,16 @@ int main(void) {
//! [render-colors]
//! [render-cursor]
// Read cursor position and visual style from the render state.
bool cursor_visible = false;
ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VISIBLE,
&cursor_visible);
bool cursor_in_viewport = false;
ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE,
&cursor_in_viewport);
if (cursor_visible && cursor_in_viewport) {
uint16_t cx, cy;
ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X, &cx);
ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y, &cy);
GhosttyRenderStateCursorVisualStyle style;
ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VISUAL_STYLE,
&style);
// Read all cursor state in one call.
GhosttyRenderStateCursor cursor =
GHOSTTY_INIT_SIZED(GhosttyRenderStateCursor);
result = ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR, &cursor);
assert(result == GHOSTTY_SUCCESS);
if (cursor.visible && cursor.viewport_has_value) {
const char* style_name = "unknown";
switch (style) {
switch (cursor.visual_style) {
case GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BAR:
style_name = "bar";
break;
@@ -145,7 +131,8 @@ int main(void) {
style_name = "hollow";
break;
}
printf("Cursor at (%u, %u), style: %s\n", cx, cy, style_name);
printf("Cursor at (%u, %u), style: %s\n",
cursor.viewport_x, cursor.viewport_y, style_name);
}
//! [render-cursor]
@@ -165,15 +152,9 @@ int main(void) {
result = ghostty_render_state_row_cells_new(NULL, &cells);
assert(result == GHOSTTY_SUCCESS);
int row_index = 0;
while (ghostty_render_state_row_iterator_next(row_iter)) {
// Check per-row dirty state; a real renderer would skip clean rows.
bool row_dirty = false;
ghostty_render_state_row_get(
row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_DIRTY, &row_dirty);
printf("Row %2d [%s]: ", row_index,
row_dirty ? "dirty" : "clean");
uint16_t row_y = 0;
while (ghostty_render_state_row_iterator_next_dirty(row_iter, &row_y)) {
printf("Row %2u [dirty]: ", row_y);
// Query the row-local selection range. Rows without a selection return
// GHOSTTY_NO_VALUE; selected rows return inclusive start/end columns.
@@ -239,22 +220,13 @@ int main(void) {
}
printf("\n");
// Clear per-row dirty flag after "rendering" it.
bool clean = false;
ghostty_render_state_row_set(
row_iter, GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY, &clean);
row_index++;
}
//! [render-row-iterate]
//! [render-dirty-reset]
// After finishing the frame, reset the global dirty state so the next
// update can report changes accurately.
GhosttyRenderStateDirty clean_state = GHOSTTY_RENDER_STATE_DIRTY_FALSE;
result = ghostty_render_state_set(
render_state, GHOSTTY_RENDER_STATE_OPTION_DIRTY, &clean_state);
// After successfully rendering the complete frame, clear both the global
// and per-row dirty state in one call.
result = ghostty_render_state_clean(render_state);
assert(result == GHOSTTY_SUCCESS);
//! [render-dirty-reset]

View File

@@ -0,0 +1,19 @@
# Example: `ghostty-vt` Terminal Search
This contains a simple example of how to use the `ghostty-vt` search
API from C. It writes content into a terminal, searches it for a
string, navigates between the matches like a find bar, and reads the
viewport matches an embedder would use to draw highlights.
This uses a `build.zig` and `Zig` to build the C program so that we
can reuse a lot of our build logic and depend directly on our source
tree, but Ghostty emits a standard C library that can be used with any
C tooling.
## Usage
Run the program:
```shell-session
zig build run
```

View File

@@ -0,0 +1,42 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const run_step = b.step("run", "Run the app");
const exe_mod = b.createModule(.{
.target = target,
.optimize = optimize,
});
exe_mod.addCSourceFiles(.{
.root = b.path("src"),
.files = &.{"main.c"},
});
// You'll want to use a lazy dependency here so that ghostty is only
// downloaded if you actually need it.
if (b.lazyDependency("ghostty", .{
// Setting simd to false will force a pure static build that
// doesn't even require libc, but it has a significant performance
// penalty. If your embedding app requires libc anyway, you should
// always keep simd enabled.
// .simd = false,
})) |dep| {
exe_mod.linkLibrary(dep.artifact("ghostty-vt"));
}
// Exe
const exe = b.addExecutable(.{
.name = "c_vt_search",
.root_module = exe_mod,
});
b.installArtifact(exe);
// Run
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| run_cmd.addArgs(args);
run_step.dependOn(&run_cmd.step);
}

View File

@@ -0,0 +1,24 @@
.{
.name = .c_vt_search,
.version = "0.0.0",
.fingerprint = 0x91a25a7f6cd3ff58,
.minimum_zig_version = "0.15.1",
.dependencies = .{
// Ghostty dependency. In reality, you'd probably use a URL-based
// dependency like the one showed (and commented out) below this one.
// We use a path dependency here for simplicity and to ensure our
// examples always test against the source they're bundled with.
.ghostty = .{ .path = "../../" },
// Example of what a URL-based dependency looks like:
// .ghostty = .{
// .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz",
// .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s",
// },
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}

View File

@@ -0,0 +1,117 @@
#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <ghostty/vt.h>
//! [search-main]
int main() {
// Create a terminal and fill it with some content to search.
GhosttyTerminal terminal;
GhosttyResult result = ghostty_terminal_new(NULL, &terminal, 80, 24);
assert(result == GHOSTTY_SUCCESS);
const char *lines[] = {
"$ make test\r\n",
"compiling module A... ok\r\n",
"compiling module B... error: missing semicolon\r\n",
"linking... error: undefined symbol\r\n",
"$ grep -n ERROR build.log\r\n",
};
for (size_t i = 0; i < sizeof(lines) / sizeof(lines[0]); i++) {
ghostty_terminal_vt_write(terminal, (const uint8_t *)lines[i],
strlen(lines[i]));
}
// The user opened the find bar, so create a search bound to the
// terminal. It starts idle until it has a needle.
GhosttySearch search;
result = ghostty_search_new(NULL, &search, terminal);
assert(result == GHOSTTY_SUCCESS);
// The user typed a query. Matching is byte-exact except ASCII
// letters, which compare case-insensitively, so "error" also finds
// "ERROR". Retyping just sets the needle again: a changed needle
// restarts the search and an unchanged one keeps its results.
GhosttyString needle = { (const uint8_t *)"error", 5 };
result = ghostty_search_set(search, GHOSTTY_SEARCH_OPT_NEEDLE, &needle);
assert(result == GHOSTTY_SUCCESS);
// Drive the search. Interactive embedders interleave
// ghostty_search_tick() and ghostty_search_feed() with their event
// loop, but for a one-shot search we can just run it to completion.
result = ghostty_search_run(search);
assert(result == GHOSTTY_SUCCESS);
// The total match count, for find bar text like "1 of 3".
size_t total = 0;
result = ghostty_search_get(search, GHOSTTY_SEARCH_DATA_TOTAL_MATCHES,
&total);
assert(result == GHOSTTY_SUCCESS);
printf("%zu matches for \"error\"\n", total);
// The user pressed Enter, so select the next match. Selection starts
// at the newest match, moves toward older content, and wraps around.
// This scrolls the viewport to the match if it isn't visible, per
// the GHOSTTY_SEARCH_OPT_SELECT_SCROLL policy.
while (true) {
result = ghostty_search_set(search, GHOSTTY_SEARCH_OPT_SELECT_NEXT, NULL);
if (result != GHOSTTY_SUCCESS) break;
// Read the selection state in one call. Index 0 is the newest
// match, so a "k of n" find bar renders index + 1.
size_t idx = 0;
GhosttySelection match = GHOSTTY_INIT_SIZED(GhosttySelection);
const GhosttySearchData keys[] = {
GHOSTTY_SEARCH_DATA_SELECTED_INDEX,
GHOSTTY_SEARCH_DATA_SELECTED_MATCH,
};
void *values[] = { &idx, &match };
result = ghostty_search_get_multi(
search, sizeof(keys) / sizeof(keys[0]), keys, values, NULL);
assert(result == GHOSTTY_SUCCESS);
printf("selected %zu of %zu\n", idx + 1, total);
// Wrapped back around to the first match: stop.
if (idx + 1 == total) break;
}
// Each frame while the find bar is open, feed to catch up with any
// terminal changes and then read the viewport matches to draw
// highlights. The list can include matches just past the viewport
// when they share a page with it, so convert each endpoint to
// viewport coordinates and skip matches outside the visible rows.
result = ghostty_search_feed(search);
assert(result == GHOSTTY_SUCCESS);
GhosttySelection viewport_storage[64];
GhosttySelectionBuffer viewport = {
.ptr = viewport_storage,
.cap = sizeof(viewport_storage) / sizeof(viewport_storage[0]),
};
result = ghostty_search_get(search, GHOSTTY_SEARCH_DATA_VIEWPORT_MATCHES,
&viewport);
assert(result == GHOSTTY_SUCCESS);
for (size_t i = 0; i < viewport.len; i++) {
GhosttyPointCoordinate start, end;
if (ghostty_terminal_point_from_grid_ref(
terminal, &viewport_storage[i].start, GHOSTTY_POINT_TAG_VIEWPORT,
&start) != GHOSTTY_SUCCESS) continue;
if (ghostty_terminal_point_from_grid_ref(
terminal, &viewport_storage[i].end, GHOSTTY_POINT_TAG_VIEWPORT,
&end) != GHOSTTY_SUCCESS) continue;
if (start.y >= 24 || end.y >= 24) continue;
// A real embedder draws a highlight rect from start to end here.
printf("highlight rows %u-%u, cols %u-%u\n",
(unsigned)start.y, (unsigned)end.y,
(unsigned)start.x, (unsigned)end.x);
}
// Closing the find bar. The search borrows the terminal, but the
// two can be freed in either order.
ghostty_search_free(search);
ghostty_terminal_free(terminal);
return 0;
}
//! [search-main]

View File

@@ -163,6 +163,7 @@
let wasmMemory = null;
let encoderPtr = null;
let lastKeyEvent = null;
let typeLayout = null;
async function loadWasm() {
try {
@@ -184,6 +185,12 @@
wasmInstance = wasmModule.instance;
wasmMemory = wasmInstance.exports.memory;
const jsonPtr = wasmInstance.exports.ghostty_type_json();
const jsonStr = new TextDecoder().decode(
new Uint8Array(wasmMemory.buffer, jsonPtr, wasmMemory.buffer.byteLength - jsonPtr)
).split('\0')[0];
typeLayout = JSON.parse(jsonStr);
return true;
} catch (e) {
@@ -199,6 +206,15 @@
return wasmMemory.buffer;
}
function readUsize(ptr) {
const view = new DataView(getBuffer());
switch (typeLayout.abi.usize_size) {
case 4: return view.getUint32(ptr, true);
case 8: return Number(view.getBigUint64(ptr, true));
default: throw new Error('unsupported size_t width');
}
}
function formatHex(bytes) {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
@@ -415,16 +431,28 @@
function encodeKeyEvent(event) {
if (!encoderPtr) return null;
const usizeSize = typeLayout.abi.usize_size;
let eventPtrPtr = 0;
let eventPtr = 0;
let utf8Ptr = 0;
let utf8Length = 0;
let requiredPtr = 0;
let required = 0;
let bufPtr = 0;
let writtenPtr = 0;
try {
// Create key event
const eventPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
eventPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
const result = wasmInstance.exports.ghostty_key_event_new(0, eventPtrPtr);
if (result !== 0) {
throw new Error(`ghostty_key_event_new failed with result ${result}`);
}
const eventPtr = new DataView(getBuffer()).getUint32(eventPtrPtr, true);
eventPtr = wasmInstance.exports.ghostty_wasm_take_opaque(eventPtrPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(eventPtrPtr);
eventPtrPtr = 0;
// Get action from radio buttons
const actionRadio = document.querySelector('input[name="action"]:checked');
@@ -458,9 +486,10 @@
// Set UTF-8 text from the key event (the actual character produced)
if (event.key.length === 1) {
const utf8Bytes = new TextEncoder().encode(event.key);
const utf8Ptr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(utf8Bytes.length);
utf8Length = utf8Bytes.length;
utf8Ptr = wasmInstance.exports.ghostty_wasm_alloc(utf8Length);
new Uint8Array(getBuffer()).set(utf8Bytes, utf8Ptr);
wasmInstance.exports.ghostty_key_event_set_utf8(eventPtr, utf8Ptr, utf8Bytes.length);
wasmInstance.exports.ghostty_key_event_set_utf8(eventPtr, utf8Ptr, utf8Length);
}
// Set unshifted codepoint
@@ -470,15 +499,15 @@
}
// Encode the key event
const requiredPtr = wasmInstance.exports.ghostty_wasm_alloc_usize();
requiredPtr = wasmInstance.exports.ghostty_wasm_alloc(usizeSize);
wasmInstance.exports.ghostty_key_encoder_encode(
encoderPtr, eventPtr, 0, 0, requiredPtr
);
const required = new DataView(getBuffer()).getUint32(requiredPtr, true);
required = readUsize(requiredPtr);
const bufPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(required);
const writtenPtr = wasmInstance.exports.ghostty_wasm_alloc_usize();
bufPtr = wasmInstance.exports.ghostty_wasm_alloc(required);
writtenPtr = wasmInstance.exports.ghostty_wasm_alloc(usizeSize);
const encodeResult = wasmInstance.exports.ghostty_key_encoder_encode(
encoderPtr, eventPtr, bufPtr, required, writtenPtr
);
@@ -487,7 +516,7 @@
return null; // No encoding for this key
}
const written = new DataView(getBuffer()).getUint32(writtenPtr, true);
const written = readUsize(writtenPtr);
const encoded = new Uint8Array(getBuffer()).slice(bufPtr, bufPtr + written);
return {
@@ -498,6 +527,13 @@
} catch (e) {
console.error('Encoding error:', e);
return null;
} finally {
wasmInstance.exports.ghostty_wasm_free(writtenPtr, usizeSize);
wasmInstance.exports.ghostty_wasm_free(bufPtr, required);
wasmInstance.exports.ghostty_wasm_free(requiredPtr, usizeSize);
wasmInstance.exports.ghostty_wasm_free(utf8Ptr, utf8Length);
wasmInstance.exports.ghostty_key_event_free(eventPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(eventPtrPtr);
}
}
@@ -555,13 +591,14 @@
if (!encoderPtr) return;
const flags = getKittyFlags();
const flagsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const flagsPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
new DataView(getBuffer()).setUint8(flagsPtr, flags);
wasmInstance.exports.ghostty_key_encoder_setopt(
encoderPtr,
5, // GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS
flagsPtr
);
wasmInstance.exports.ghostty_wasm_free(flagsPtr, 1);
// Re-encode last key with new flags
reencodeLastKey();
@@ -649,7 +686,8 @@
throw new Error(`ghostty_key_encoder_new failed with result ${result}`);
}
encoderPtr = new DataView(getBuffer()).getUint32(encoderPtrPtr, true);
encoderPtr = wasmInstance.exports.ghostty_wasm_take_opaque(encoderPtrPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(encoderPtrPtr);
// Set kitty flags based on checkboxes
updateEncoderFlags();

View File

@@ -106,6 +106,7 @@
<script>
let wasmInstance = null;
let wasmMemory = null;
let typeLayout = null;
async function loadWasm() {
try {
@@ -124,6 +125,12 @@
wasmInstance = wasmModule.instance;
wasmMemory = wasmInstance.exports.memory;
const jsonPtr = wasmInstance.exports.ghostty_type_json();
const jsonStr = new TextDecoder().decode(
new Uint8Array(wasmMemory.buffer, jsonPtr, wasmMemory.buffer.byteLength - jsonPtr)
).split('\0')[0];
typeLayout = JSON.parse(jsonStr);
return true;
} catch (e) {
@@ -252,18 +259,22 @@
throw new Error(`ghostty_sgr_new failed with result ${result}`);
}
const parserPtr = new DataView(getBuffer()).getUint32(parserPtrPtr, true);
const parserPtr = wasmInstance.exports.ghostty_wasm_take_opaque(parserPtrPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(parserPtrPtr);
// Allocate and set parameters
const paramsPtr = wasmInstance.exports.ghostty_wasm_alloc_u16_array(params.length);
const paramsByteLength = params.length * Uint16Array.BYTES_PER_ELEMENT;
const paramsPtr = wasmInstance.exports.ghostty_wasm_alloc(paramsByteLength);
const paramsView = new Uint16Array(getBuffer(), paramsPtr, params.length);
params.forEach((p, i) => paramsView[i] = p);
// Allocate and set separators (or use null if empty)
let sepsPtr = 0;
const sepsByteLength = separators.length > 0 ? params.length : 0;
if (separators.length > 0) {
sepsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(separators.length);
const sepsView = new Uint8Array(getBuffer(), sepsPtr, separators.length);
sepsPtr = wasmInstance.exports.ghostty_wasm_alloc(sepsByteLength);
const sepsView = new Uint8Array(getBuffer(), sepsPtr, sepsByteLength);
sepsView.fill(0);
separators.forEach((s, i) => sepsView[i] = s.charCodeAt(0));
}
@@ -289,7 +300,8 @@
output += 'm\n\n';
// Iterate through attributes
const attrPtr = wasmInstance.exports.ghostty_wasm_alloc_sgr_attribute();
const attrSize = typeLayout.types.GhosttySgrAttribute.size;
const attrPtr = wasmInstance.exports.ghostty_wasm_alloc(attrSize);
let count = 0;
while (wasmInstance.exports.ghostty_sgr_next(parserPtr, attrPtr)) {
@@ -313,9 +325,9 @@
case SGR_ATTR_TAGS.DIRECT_COLOR_FG: {
// Use ghostty_color_rgb_get to extract RGB components
const rPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const gPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const bPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const rPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const gPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const bPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
wasmInstance.exports.ghostty_color_rgb_get(valuePtr, rPtr, gPtr, bPtr);
@@ -325,17 +337,17 @@
output += `Foreground RGB = (${r}, ${g}, ${b})\n`;
wasmInstance.exports.ghostty_wasm_free_u8(rPtr);
wasmInstance.exports.ghostty_wasm_free_u8(gPtr);
wasmInstance.exports.ghostty_wasm_free_u8(bPtr);
wasmInstance.exports.ghostty_wasm_free(rPtr, 1);
wasmInstance.exports.ghostty_wasm_free(gPtr, 1);
wasmInstance.exports.ghostty_wasm_free(bPtr, 1);
break;
}
case SGR_ATTR_TAGS.DIRECT_COLOR_BG: {
// Use ghostty_color_rgb_get to extract RGB components
const rPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const gPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const bPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const rPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const gPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const bPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
wasmInstance.exports.ghostty_color_rgb_get(valuePtr, rPtr, gPtr, bPtr);
@@ -345,17 +357,17 @@
output += `Background RGB = (${r}, ${g}, ${b})\n`;
wasmInstance.exports.ghostty_wasm_free_u8(rPtr);
wasmInstance.exports.ghostty_wasm_free_u8(gPtr);
wasmInstance.exports.ghostty_wasm_free_u8(bPtr);
wasmInstance.exports.ghostty_wasm_free(rPtr, 1);
wasmInstance.exports.ghostty_wasm_free(gPtr, 1);
wasmInstance.exports.ghostty_wasm_free(bPtr, 1);
break;
}
case SGR_ATTR_TAGS.UNDERLINE_COLOR: {
// Use ghostty_color_rgb_get to extract RGB components
const rPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const gPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const bPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const rPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const gPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const bPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
wasmInstance.exports.ghostty_color_rgb_get(valuePtr, rPtr, gPtr, bPtr);
@@ -365,9 +377,9 @@
output += `Underline color RGB = (${r}, ${g}, ${b})\n`;
wasmInstance.exports.ghostty_wasm_free_u8(rPtr);
wasmInstance.exports.ghostty_wasm_free_u8(gPtr);
wasmInstance.exports.ghostty_wasm_free_u8(bPtr);
wasmInstance.exports.ghostty_wasm_free(rPtr, 1);
wasmInstance.exports.ghostty_wasm_free(gPtr, 1);
wasmInstance.exports.ghostty_wasm_free(bPtr, 1);
break;
}
@@ -415,7 +427,9 @@
outputDiv.textContent = output;
// Cleanup
wasmInstance.exports.ghostty_wasm_free_sgr_attribute(attrPtr);
wasmInstance.exports.ghostty_wasm_free(attrPtr, attrSize);
wasmInstance.exports.ghostty_wasm_free(paramsPtr, paramsByteLength);
wasmInstance.exports.ghostty_wasm_free(sepsPtr, sepsByteLength);
wasmInstance.exports.ghostty_sgr_free(parserPtr);
} catch (e) {

View File

@@ -156,7 +156,7 @@
// Look up a field's offset and DataView setter from the type layout JSON.
function fieldInfo(structName, fieldName) {
const field = typeLayout[structName].fields[fieldName];
const field = typeLayout.types[structName].fields[fieldName];
return field;
}
@@ -166,9 +166,16 @@
switch (field.type) {
case 'u8': case 'bool': view.setUint8(field.offset, value); break;
case 'u16': view.setUint16(field.offset, value, true); break;
case 'u32': case 'enum': view.setUint32(field.offset, value, true); break;
case 'u32': view.setUint32(field.offset, value, true); break;
case 'u64': view.setBigUint64(field.offset, BigInt(value), true); break;
default: throw new Error(`Unsupported field type: ${field.type}`);
default: {
const type = typeLayout.types[field.type];
if (type?.kind === 'enum' && type.underlying === 'i32') {
view.setInt32(field.offset, value, true);
break;
}
throw new Error(`Unsupported field type: ${field.type}`);
}
}
}
@@ -176,6 +183,15 @@
return wasmMemory.buffer;
}
function readUsize(ptr) {
const view = new DataView(getBuffer());
switch (typeLayout.abi.usize_size) {
case 4: return view.getUint32(ptr, true);
case 8: return Number(view.getBigUint64(ptr, true));
default: throw new Error('unsupported size_t width');
}
}
// Parse escape sequences in the input string (e.g. \x1b, \r, \n)
function parseEscapes(str) {
return str
@@ -186,18 +202,18 @@
.replace(/\\\\/g, '\\');
}
// GHOSTTY_FORMATTER_FORMAT_PLAIN = 0
const GHOSTTY_FORMATTER_FORMAT_PLAIN = 0;
// GHOSTTY_SUCCESS = 0
const GHOSTTY_SUCCESS = 0;
function run() {
const outputDiv = document.getElementById('output');
try {
const GHOSTTY_FORMATTER_FORMAT_PLAIN =
typeLayout.types.GhosttyFormatterFormat.values.PLAIN;
const GHOSTTY_SUCCESS =
typeLayout.types.GhosttyResult.values.SUCCESS;
const cols = parseInt(document.getElementById('cols').value, 10);
const rows = parseInt(document.getElementById('rows').value, 10);
const vtText = parseEscapes(document.getElementById('vtInput').value);
const usizeSize = typeLayout.abi.usize_size;
// Allocate pointer to receive the terminal handle
const termPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
@@ -213,19 +229,19 @@
throw new Error(`ghostty_terminal_new failed with result ${newResult}`);
}
const termPtr = new DataView(getBuffer()).getUint32(termPtrPtr, true);
const termPtr = wasmInstance.exports.ghostty_wasm_take_opaque(termPtrPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(termPtrPtr);
// Write VT data to the terminal
const vtBytes = new TextEncoder().encode(vtText);
const dataPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(vtBytes.length);
const dataPtr = wasmInstance.exports.ghostty_wasm_alloc(vtBytes.length);
new Uint8Array(getBuffer()).set(vtBytes, dataPtr);
wasmInstance.exports.ghostty_terminal_vt_write(termPtr, dataPtr, vtBytes.length);
wasmInstance.exports.ghostty_wasm_free_u8_array(dataPtr, vtBytes.length);
wasmInstance.exports.ghostty_wasm_free(dataPtr, vtBytes.length);
// Create a plain-text formatter
const FMT_OPTS_SIZE = typeLayout['GhosttyFormatterTerminalOptions'].size;
const fmtOptsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(FMT_OPTS_SIZE);
const FMT_OPTS_SIZE = typeLayout.types.GhosttyFormatterTerminalOptions.size;
const fmtOptsPtr = wasmInstance.exports.ghostty_wasm_alloc(FMT_OPTS_SIZE);
new Uint8Array(getBuffer(), fmtOptsPtr, FMT_OPTS_SIZE).fill(0);
const fmtOptsView = new DataView(getBuffer(), fmtOptsPtr, FMT_OPTS_SIZE);
setField(fmtOptsView, 'GhosttyFormatterTerminalOptions', 'size', FMT_OPTS_SIZE);
@@ -235,12 +251,12 @@
// Set the nested sized-struct `size` fields for extra and extra.screen
const extraOffset = fieldInfo('GhosttyFormatterTerminalOptions', 'extra').offset;
const extraSize = typeLayout['GhosttyFormatterTerminalExtra'].size;
const extraSize = typeLayout.types.GhosttyFormatterTerminalExtra.size;
const extraSizeField = fieldInfo('GhosttyFormatterTerminalExtra', 'size');
fmtOptsView.setUint32(extraOffset + extraSizeField.offset, extraSize, true);
const screenOffset = fieldInfo('GhosttyFormatterTerminalExtra', 'screen').offset;
const screenSize = typeLayout['GhosttyFormatterScreenExtra'].size;
const screenSize = typeLayout.types.GhosttyFormatterScreenExtra.size;
const screenSizeField = fieldInfo('GhosttyFormatterScreenExtra', 'size');
fmtOptsView.setUint32(extraOffset + screenOffset + screenSizeField.offset, screenSize, true);
@@ -248,19 +264,19 @@
const fmtResult = wasmInstance.exports.ghostty_formatter_terminal_new(
0, fmtPtrPtr, termPtr, fmtOptsPtr
);
wasmInstance.exports.ghostty_wasm_free_u8_array(fmtOptsPtr, FMT_OPTS_SIZE);
wasmInstance.exports.ghostty_wasm_free(fmtOptsPtr, FMT_OPTS_SIZE);
if (fmtResult !== GHOSTTY_SUCCESS) {
wasmInstance.exports.ghostty_terminal_free(termPtr);
throw new Error(`ghostty_formatter_terminal_new failed with result ${fmtResult}`);
}
const fmtPtr = new DataView(getBuffer()).getUint32(fmtPtrPtr, true);
const fmtPtr = wasmInstance.exports.ghostty_wasm_take_opaque(fmtPtrPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(fmtPtrPtr);
// Format with alloc
const outPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
const outLenPtr = wasmInstance.exports.ghostty_wasm_alloc_usize();
const outLenPtr = wasmInstance.exports.ghostty_wasm_alloc(usizeSize);
const formatResult = wasmInstance.exports.ghostty_formatter_format_alloc(
fmtPtr, 0, outPtrPtr, outLenPtr
);
@@ -271,8 +287,8 @@
throw new Error(`ghostty_formatter_format_alloc failed with result ${formatResult}`);
}
const outPtr = new DataView(getBuffer()).getUint32(outPtrPtr, true);
const outLen = new DataView(getBuffer()).getUint32(outLenPtr, true);
const outPtr = wasmInstance.exports.ghostty_wasm_take_opaque(outPtrPtr);
const outLen = readUsize(outLenPtr);
const outBytes = new Uint8Array(getBuffer(), outPtr, outLen);
const outText = new TextDecoder().decode(outBytes);
@@ -288,7 +304,7 @@
// Clean up
wasmInstance.exports.ghostty_free(0, outPtr, outLen);
wasmInstance.exports.ghostty_wasm_free_opaque(outPtrPtr);
wasmInstance.exports.ghostty_wasm_free_usize(outLenPtr);
wasmInstance.exports.ghostty_wasm_free(outLenPtr, usizeSize);
wasmInstance.exports.ghostty_formatter_free(fmtPtr);
wasmInstance.exports.ghostty_terminal_free(termPtr);

26
flake.lock generated
View File

@@ -23,11 +23,11 @@
]
},
"locked": {
"lastModified": 1782657028,
"narHash": "sha256-PHTCpYZCMzJYS3phhywqRAZphKVr2zjvlGYa+H20ZZ4=",
"lastModified": 1787661347,
"narHash": "sha256-THmnBAdAIV+jT348f3r/vyoeb6ilhcVLwVXkBU7Pai8=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "4ad9aaae70c9aaab504127f926c0fa9cfbc2b365",
"rev": "52c3a5881c821ad7367bbde075e52b76cdb378ab",
"type": "github"
},
"original": {
@@ -38,15 +38,15 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1782545840,
"narHash": "sha256-CAi8oAZaE6pTkcYQBnnOlvmfMgG/p1AO0FohKKN3J7I=",
"rev": "3d46470bb3030020f7e1361f33514854f5bfa86d",
"lastModified": 1787424095,
"narHash": "sha256-SpMiSe9OSfWseGAupsdcED3He9mTYTbGMtWb7EVt6pE=",
"rev": "174eb786fb68e3a13e4e535a3deea479a0c07a6a",
"type": "tarball",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1023445.3d46470bb303/nixexprs.tar.xz"
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1059855.174eb786fb68/nixexprs.tar.zst"
},
"original": {
"type": "tarball",
"url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"
"url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.zst"
}
},
"root": {
@@ -88,11 +88,11 @@
]
},
"locked": {
"lastModified": 1782609341,
"narHash": "sha256-OfCPJFS/2Z2ZyjE4adR7PL+ZEqvDQFi2XNI1L8s6wKU=",
"lastModified": 1787617283,
"narHash": "sha256-jiM1glpDL5ASrqvcWyacI+c1wZo3g6AUhdo6M09AoiY=",
"owner": "mitchellh",
"repo": "zig-overlay",
"rev": "5386fea92c45b154bbeb52d14ef5504f82eda86c",
"rev": "da55b9fe9260fb3eb1456c3ee6064b8f738e4ba9",
"type": "github"
},
"original": {
@@ -108,11 +108,11 @@
]
},
"locked": {
"lastModified": 1784117571,
"lastModified": 1784080626,
"narHash": "sha256-Hvnmnuu0VpHiZl+8z+UkMoxC7JZJvRYBqu2Asb0ZJOE=",
"owner": "jcollie",
"repo": "zon2nix",
"rev": "0ce628fb78309cdb13d098ae9c6fdbf87c75d25b",
"rev": "776b5f6864a607c141d7966421b433fa1657ba9a",
"type": "github"
},
"original": {

View File

@@ -6,10 +6,10 @@
# glibc versions used by our dependencies from Nix are compatible with the
# system glibc that the user is building for.
#
# We are currently on nixpkgs-unstable to get Zig 0.15 for our package.nix and
# Gnome 49/Gtk 4.20.
# We are currently on nixpkgs-unstable to get Zig 0.16 for our package.nix,
# Gnome 50/Gtk 4.22, and fontconfig 2.18.
#
nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz";
nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.zst";
# Used for shell.nix
flake-compat = {
@@ -76,25 +76,7 @@
devShells = forAllPlatforms (pkgs: {
default =
pkgs.callPackage ./nix/devShell.nix
(let
libfyaml =
if pkgs.stdenv.hostPlatform.isDarwin
then
pkgs.libfyaml.overrideAttrs (prev: {
# Manually fix libfyaml.pc until NixOS/nixpkgs#515614 is available
postInstall =
(prev.postInstall or "")
+ ''
substituteInPlace "$dev/lib/pkgconfig/libfyaml.pc" \
--replace-fail " none required" ""
'';
})
else pkgs.libfyaml;
appstream = pkgs.appstream.override {libfyaml = libfyaml;};
libadwaita = pkgs.libadwaita.override {appstream = appstream;};
blueprint-compiler = pkgs.blueprint-compiler.override {libadwaita = libadwaita;};
in {
{
zig = zig.packages.${pkgs.stdenv.hostPlatform.system}."0.16.0";
wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {};
zon2nix = zon2nix;
@@ -107,9 +89,7 @@
wcwidth = pyfinal.callPackage ./nix/pkgs/wcwidth.nix {};
};
};
inherit appstream libadwaita blueprint-compiler;
});
};
});
packages =

View File

@@ -1,9 +1,9 @@
[
{
"type": "archive",
"url": "https://github.com/vancluever/arocc/archive/ecbc5c799574e0da2758a961b12efa586007f03c.tar.gz",
"dest": "vendor/p/aro-0.0.0-JSD1Qk8rOQDnuVcD4jAwMpHitA6pADRKzQ7M7hKRwxvD",
"sha256": "aa54487bd727e8fa36744ff0a824dedb2f4ca45f4c2d4d9d2437cf5fd3d0ad1a"
"url": "https://github.com/vancluever/arocc/archive/f97cdfc3779aec4b242299e2fc9a1c828c3547c6.tar.gz",
"dest": "vendor/p/aro-0.0.0-JSD1Qk6lNgDdcDV4Vh7Sfy-34m2TluIVOdPzMmj_0BjX",
"sha256": "42c8cab9135b12518e6af7dc5e461f055c78b863adca8ed7774f2fb7fe9a7784"
},
{
"type": "archive",
@@ -19,9 +19,9 @@
},
{
"type": "archive",
"url": "https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz",
"dest": "vendor/p/N-V-__8AAIrfdwARSa-zMmxWwFuwpXf1T3asIN7s5jqi9c1v",
"sha256": "3ba2dd92158718acec5caaf1a716043b5aa055c27b081d914af3ccb40dce8a55"
"url": "https://gitlab.freedesktop.org/api/v4/projects/890/packages/generic/fontconfig/2.18.3/fontconfig-2.18.3.tar.xz",
"dest": "vendor/p/N-V-__8AAOgqbADacob-q2_DMQlmgaG4xKHRuW-6PJ4oJzMZ",
"sha256": "4f7b554a38cdf78c033f666c8871f3749e14a094f65a07f630c91ed0b43d35e3"
},
{
"type": "archive",
@@ -43,7 +43,7 @@
},
{
"type": "archive",
"url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst",
"url": "https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst",
"dest": "vendor/p/gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3",
"sha256": "9014db570db06e3fbfcfadb7b32aa8addacc6969e701650683c1c74428adba6c"
},
@@ -73,9 +73,9 @@
},
{
"type": "archive",
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz",
"dest": "vendor/p/N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl",
"sha256": "feac4b6bd4bc6d28363406c9cf012d6611af3b168f4c05453569c10acfcc141b"
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260831-151010-752a9c0.tgz",
"dest": "vendor/p/N-V-__8AAEFmBABuDGOKxAI6VMg41b9euMZ-z7HS9EcUdaor",
"sha256": "83653eb837baeb5bfc7d50c91b7d44772796e08c1c243134e11c5edf6c8d15c5"
},
{
"type": "archive",
@@ -139,9 +139,9 @@
},
{
"type": "archive",
"url": "https://deps.files.ghostty.org/translate_c-80f8b6e4f45a303268717d8e5f4f91d7837138bb.tar.gz",
"dest": "vendor/p/translate_c-0.0.0-Q_BUWmU6BwB_9JKG2l2W7i_mhmYWeRseTGBEHi_YlV5f",
"sha256": "38c9e59d58f36e3481f6fa38f11fe59c63c12492fccf399ca56f3dfcce17b53e"
"url": "https://codeberg.org/vancluever/translate-c/archive/4e879eb8aba615de112eabd1231ea6e01920cead.tar.gz",
"dest": "vendor/p/translate_c-0.0.0-Q_BUWhVNBwDOEcIqub4VFPJPB6D9dgwzUMHTX5KWr8Xr",
"sha256": "8fb0f5c6a3e8b3bf0e35225d3abc1230bcf6f1fdf162467f1f747ec2fa5eda07"
},
{
"type": "archive",
@@ -181,9 +181,9 @@
},
{
"type": "archive",
"url": "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz",
"dest": "vendor/p/N-V-__8AAAzZywE3s51XfsLbP9eyEw57ae9swYB9aGB6fCMs",
"sha256": "9e4cd20abe96e6c4c6ede9c3057108860126e7be2e2c3e35515476c250be1c13"
"url": "https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz",
"dest": "vendor/p/N-V-__8AAP5JWgCGP_AD0teWpa4krRvE9VPZzvviGdbmN4jI",
"sha256": "17877d346f7988651d6c62e45b2e3b05c86809a6442c5e34612374b2a9719a1c"
},
{
"type": "archive",

View File

@@ -75,19 +75,56 @@ typedef enum {
typedef enum {
GHOSTTY_CLIPBOARD_STANDARD,
GHOSTTY_CLIPBOARD_SELECTION,
GHOSTTY_CLIPBOARD_PRIMARY,
} ghostty_clipboard_e;
// One representation of clipboard contents. The data is binary-safe with
// an explicit length; it is not necessarily null-terminated.
typedef struct {
const char *mime;
const char *data;
size_t len;
} ghostty_clipboard_content_s;
// The payload for completing a clipboard read request. See
// ghostty_surface_complete_clipboard_request.
typedef struct {
const ghostty_clipboard_content_s *contents;
size_t contents_len;
const char *const *available;
size_t available_len;
bool confirmed;
bool remember;
} ghostty_clipboard_complete_s;
// The payload of a clipboard read confirmation request: the would-be
// completion contents plus the information shown in the permission
// prompt. See ghostty_runtime_confirm_read_clipboard_cb.
typedef struct {
const ghostty_clipboard_content_s *contents;
size_t contents_len;
const char *const *available;
size_t available_len;
const char *name;
bool can_remember;
} ghostty_clipboard_confirm_s;
typedef enum {
GHOSTTY_CLIPBOARD_REQUEST_PASTE,
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ,
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE,
GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ,
GHOSTTY_CLIPBOARD_REQUEST_KITTY_WRITE,
GHOSTTY_CLIPBOARD_REQUEST_LIST,
} ghostty_clipboard_request_e;
// apprt.ClipboardReadResult
typedef enum {
GHOSTTY_CLIPBOARD_READ_STARTED,
GHOSTTY_CLIPBOARD_READ_UNAVAILABLE,
GHOSTTY_CLIPBOARD_READ_UNSUPPORTED,
} ghostty_clipboard_read_result_e;
typedef enum {
GHOSTTY_MOUSE_RELEASE,
GHOSTTY_MOUSE_PRESS,
@@ -1023,12 +1060,16 @@ typedef struct {
} ghostty_action_s;
typedef void (*ghostty_runtime_wakeup_cb)(void*);
typedef bool (*ghostty_runtime_read_clipboard_cb)(void*,
ghostty_clipboard_e,
void*);
typedef ghostty_clipboard_read_result_e (*ghostty_runtime_read_clipboard_cb)(
void*,
ghostty_clipboard_e,
void*,
const char* const*,
size_t,
bool);
typedef void (*ghostty_runtime_confirm_read_clipboard_cb)(
void*,
const char*,
const ghostty_clipboard_confirm_s*,
void*,
ghostty_clipboard_request_e);
typedef void (*ghostty_runtime_write_clipboard_cb)(void*,
@@ -1178,10 +1219,12 @@ GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t,
uint16_t);
GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t);
GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t);
GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t,
const char*,
void*,
bool);
GHOSTTY_API void ghostty_surface_complete_clipboard_request(
ghostty_surface_t,
const ghostty_clipboard_complete_s*,
void*);
GHOSTTY_API void ghostty_surface_deny_clipboard_request(ghostty_surface_t,
void*);
GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t);
GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*);
GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t,

View File

@@ -32,9 +32,10 @@
* - @ref render "Render State" - Incremental render state updates for custom renderers
* - @ref formatter "Formatter" - Format terminal content as plain text, VT sequences, or HTML
* - @ref snapshot "Terminal Snapshot" - Encode and incrementally restore terminal state
* - @ref search "Search" - Search terminal contents, including scrollback
* - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences
* - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences
* - @ref paste "Paste Utilities" - Validate paste data safety
* - @ref paste "Paste" - Paste into a terminal, validate and encode paste data
* - @ref unicode "Unicode Utilities" - Codepoint properties for text layout
* - @ref build_info "Build Info" - Query compile-time build configuration
* - @ref allocator "Memory Management" - Memory management and custom allocators
@@ -53,12 +54,13 @@
* - @ref c-vt/src/main.c - OSC parser example
* - @ref c-vt-encode-key/src/main.c - Key encoding example
* - @ref c-vt-encode-mouse/src/main.c - Mouse encoding example
* - @ref c-vt-paste/src/main.c - Paste safety check example
* - @ref c-vt-paste/src/main.c - Paste example
* - @ref c-vt-sgr/src/main.c - SGR parser example
* - @ref c-vt-formatter/src/main.c - Terminal formatter example
* - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs
* - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example
* - @ref c-vt-compression/src/main.c - Idle scrollback compression example
* - @ref c-vt-search/src/main.c - Terminal search example
*
*/
@@ -83,8 +85,10 @@
*/
/** @example c-vt-paste/src/main.c
* This example demonstrates how to use the paste utilities to check if
* paste data is safe before sending it to the terminal.
* This example demonstrates how to paste into a terminal, including the
* unsafe-paste confirmation flow and Kitty clipboard protocol paste events
* (mode 5522), as well as the terminal-free paste safety and encoding
* utilities.
*/
/** @example c-vt-sgr/src/main.c
@@ -123,6 +127,12 @@
* PNG decoder callback and send a Kitty Graphics Protocol image.
*/
/** @example c-vt-search/src/main.c
* This example demonstrates how to search terminal contents for a
* string, navigate between the matches like a find bar, and read the
* viewport matches used to draw highlights.
*/
#ifndef GHOSTTY_VT_H
#define GHOSTTY_VT_H
@@ -154,6 +164,7 @@ extern "C" {
#include <ghostty/vt/paste.h>
#include <ghostty/vt/point.h>
#include <ghostty/vt/screen.h>
#include <ghostty/vt/search.h>
#include <ghostty/vt/selection.h>
#include <ghostty/vt/size_report.h>
#include <ghostty/vt/snapshot.h>

View File

@@ -31,14 +31,17 @@
* function that accepts one,** and libghostty will use a default allocator.
* The default allocator will be libc malloc/free if libc is linked.
* Otherwise, a custom allocator is used (currently Zig's SMP allocator)
* that doesn't require any external dependencies.
* that doesn't require any external dependencies. On native freestanding
* targets, the default allocator always fails instead.
*
* ## Basic Usage
*
* For simple use cases, you can ignore this interface entirely by passing NULL
* as the allocator parameter to functions that accept one. This will use the
* default allocator (typically libc malloc/free, if libc is linked, but
* we provide our own default allocator if libc isn't linked).
* we provide our own default allocator if libc isn't linked). Native
* freestanding builds must provide a custom allocator for operations that
* allocate memory.
*
* To use a custom allocator:
* 1. Implement the GhosttyAllocatorVtable function pointers
@@ -76,7 +79,8 @@
*
* If you're not going to use a custom allocator, you can ignore all of
* this. All functions that take an allocator pointer allow NULL to use a
* default allocator.
* default allocator. Native freestanding builds must provide an allocator
* for operations that allocate memory.
*
* The interface is based on the Zig allocator interface. I'll say up front
* that it is easy to look at this interface and think "wow, this is really
@@ -183,7 +187,8 @@ typedef struct {
* For functions that take an allocator pointer, a NULL pointer indicates
* that the default allocator should be used. The default allocator will
* be libc malloc/free if we're linking to libc. If libc isn't linked,
* a custom allocator is used (currently Zig's SMP allocator).
* a custom allocator is used (currently Zig's SMP allocator). On native
* freestanding targets, the default allocator always fails instead.
*
* @ingroup allocator
*

View File

@@ -11,6 +11,8 @@
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/types.h>
/* DA1 conformance levels (Pp parameter). */
#define GHOSTTY_DA_CONFORMANCE_VT100 1
#define GHOSTTY_DA_CONFORMANCE_VT101 1

View File

@@ -11,6 +11,7 @@
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/io.h>
#include <ghostty/vt/selection.h>
#include <ghostty/vt/types.h>
#include <ghostty/vt/terminal.h>
@@ -137,6 +138,30 @@ GHOSTTY_API GhosttyResult ghostty_formatter_terminal_new(
GhosttyTerminal terminal,
GhosttyFormatterTerminalOptions options);
/**
* Run the formatter and stream output to a writer.
*
* Each call formats the current terminal state and invokes the writer
* synchronously as output becomes available. The callback may be called more
* than once and must not call formatter or terminal APIs using the same
* formatter or its terminal.
*
* If an error occurs, the writer may already contain a partial formatted
* output. The operation cannot be resumed from that partial output. This
* function does not flush or make the caller's destination durable.
*
* @param formatter The formatter handle (must not be NULL)
* @param writer Destination writer whose write callback must not be NULL
* @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the writer rejects
* output, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or
* GHOSTTY_INVALID_VALUE if an argument is invalid
*
* @ingroup formatter
*/
GHOSTTY_API GhosttyResult ghostty_formatter_format(
GhosttyFormatter formatter,
GhosttyWriter writer);
/**
* Run the formatter and produce output into the caller-provided buffer.
*

View File

@@ -10,6 +10,7 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/types.h>
/** @defgroup io I/O
*
@@ -98,6 +99,49 @@ typedef struct {
void* userdata;
} GhosttyWriter;
/**
* Read one MIME-typed representation of some content, streaming its
* bytes to a writer.
*
* The library calls this with the MIME type of the representation it
* needs. The callback writes all of that representation's data to
* @p writer, in as many calls to `writer.write(writer.userdata, data,
* len)` as is convenient (one call with everything or many small
* pieces both work), and returns true. Nothing written is retained
* beyond each write call, so the data may be borrowed from anywhere:
* a pasteboard item, a file being read, a stream.
*
* Returning false reports that the data could not be read. If the
* writer refuses a write (returns false), stop and return false
* without writing more.
*
* All pointer arguments, the mime, and the writer are borrowed and
* valid only for the duration of the callback. The callback is
* invoked synchronously on the calling thread. The API receiving the
* GhosttyMimeReader defines which MIME types are requested, how many
* times, and any consistency requirements across repeated reads.
*
* @param userdata Opaque userdata from GhosttyMimeReader
* @param mime The MIME type of the representation to read
* @param writer Where to write the data; valid only during this call
* @return true once all the data was written, false if it could not
* be read or the writer refused a write
*/
typedef bool (*GhosttyMimeReaderFn)(
void* userdata,
GhosttyString mime,
GhosttyWriter writer);
/**
* A MIME-typed content source callback and its opaque context.
*
* The struct is passed by value. @p read must be non-NULL.
*/
typedef struct {
GhosttyMimeReaderFn read;
void* userdata;
} GhosttyMimeReader;
#ifdef __cplusplus
}
#endif

View File

@@ -399,6 +399,12 @@ typedef enum GHOSTTY_ENUM_TYPED {
* time, before the image is stored. Consumers can upload this
* directly to the GPU without any decode step.
*
* For an animated image (Kitty graphics animation, actions a=f/a=a)
* this is the pixel data of the current animation frame. The
* image's GHOSTTY_KITTY_IMAGE_DATA_GENERATION changes whenever the
* current frame changes, so generation-keyed caches remain
* coherent.
*
* Output type: const uint8_t **
*/
GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR = 7,

View File

@@ -94,6 +94,7 @@ extern "C" {
#define GHOSTTY_MODE_COLOR_SCHEME_REPORT (ghostty_mode_new(2031, false)) /**< Report color scheme */
#define GHOSTTY_MODE_VISIBILITY_REPORT (ghostty_mode_new(2033, false)) /**< Report terminal visibility */
#define GHOSTTY_MODE_IN_BAND_RESIZE (ghostty_mode_new(2048, false)) /**< In-band size reports */
#define GHOSTTY_MODE_PASTE_EVENTS (ghostty_mode_new(5522, false)) /**< Kitty clipboard protocol paste events */
/** @} */
/**

View File

@@ -63,6 +63,10 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20,
GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21,
GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22,
GHOSTTY_OSC_COMMAND_KITTY_CLIPBOARD_PROTOCOL = 23,
GHOSTTY_OSC_COMMAND_KITTY_DND_PROTOCOL = 24,
GHOSTTY_OSC_COMMAND_CONTEXT_SIGNAL = 25,
GHOSTTY_OSC_COMMAND_KITTY_DESKTOP_NOTIFICATION = 26,
GHOSTTY_OSC_COMMAND_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyOscCommandType;

View File

@@ -1,26 +1,67 @@
/**
* @file paste.h
*
* Paste utilities - validate and encode paste data for terminal input.
* Paste - paste into a terminal, and validate and encode paste data.
*/
#ifndef GHOSTTY_VT_PASTE_H
#define GHOSTTY_VT_PASTE_H
/** @defgroup paste Paste Utilities
/** @defgroup paste Paste
*
* Utilities for validating and encoding paste data for terminal input.
* Pasting into a terminal, plus the terminal-free utilities for
* validating and encoding paste data.
*
* ## Basic Usage
* ## Pasting into a Terminal
*
* Use ghostty_paste_is_safe() to check if paste data contains potentially
* dangerous sequences before sending it to the terminal.
* What a paste writes to the pty depends on the terminal's state, so
* the recommended way to paste is ghostty_terminal_paste(). The embedder
* hands over the MIME types the clipboard holds (just `text/plain` for
* an ordinary paste), a GhosttyMimeReader that produces the data of
* any one of them, and where the paste came from, and the terminal
* decides how its current modes apply:
*
* Use ghostty_paste_encode() to encode paste data for writing to the pty,
* - If Kitty clipboard protocol paste events (mode 5522,
* GHOSTTY_MODE_PASTE_EVENTS) are enabled, the paste was user-initiated
* (GHOSTTY_PASTE_SOURCE_CLIPBOARD), and a clipboard_read callback is
* installed, the terminal sends the program a paste event listing the
* clipboard's MIME types with a one-time password instead of the data.
* The program then reads what it wants through the clipboard_read
* callback, which arrives with `granted` set so no permission prompt
* is needed. No data is read for the event.
* - Otherwise the first text representation is written: unsafe control
* bytes are replaced with spaces, and it is wrapped in bracketed paste
* sequences if mode 2004 (GHOSTTY_MODE_BRACKETED_PASTE) is enabled, or
* has its newlines converted to carriage returns if not.
*
* The data is pulled through GhosttyPaste::reader only when a
* representation is actually pasted (so a clipboard holding a large
* image next to some text costs nothing), and the encoded bytes
* stream to the write_pty callback (GHOSTTY_TERMINAL_OPT_WRITE_PTY)
* in chunks as they are produced, never in one piece. The callback
* may be invoked several times for a single paste; the pieces must be
* written to the pty in order.
*
* Text that could inject commands (a newline when unbracketed, or the
* bracketed paste terminator when bracketed) is refused with
* GHOSTTY_REJECTED and nothing written unless GhosttyPaste::allow_unsafe
* is set. The usual flow is to call once, confirm with the user on
* GHOSTTY_REJECTED, and call again with `allow_unsafe` set. Each call
* reads the text at most once and buffers it whole while the rule is
* applied, so the source needs no stability across reads (the
* confirmed retry simply pastes whatever the source holds then) and a
* refused or failed paste writes nothing at all.
*
* @snippet c-vt-paste/src/main.c terminal-paste
*
* ## Building Blocks
*
* For embedders that encode without a terminal, ghostty_paste_is_safe()
* checks if paste data contains potentially dangerous sequences
* (conservatively, regardless of terminal state) and
* ghostty_paste_encode() encodes paste data for writing to the pty,
* including bracketed paste wrapping and unsafe byte stripping.
*
* ## Examples
*
* ### Safety Check
*
* @snippet c-vt-paste/src/main.c paste-safety
@@ -35,11 +76,119 @@
#include <stdbool.h>
#include <stddef.h>
#include <ghostty/vt/types.h>
#include <ghostty/vt/io.h>
#include <ghostty/vt/terminal.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* Why a paste happened.
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** The user pasted from a clipboard: keybind, menu, middle click. */
GHOSTTY_PASTE_SOURCE_CLIPBOARD = 0,
/**
* Text inserted some other way: IME commit, drag and drop, scripted
* input. Always written as text, never as a paste event, matching
* kitty. This is not a way to opt out of paste events; an embedder
* that doesn't want them doesn't install a clipboard_read callback.
*/
GHOSTTY_PASTE_SOURCE_TEXT = 1,
GHOSTTY_PASTE_SOURCE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyPasteSource;
/**
* A paste of clipboard contents into the terminal.
*
* This is a sized struct; set `size` to `sizeof(GhosttyPaste)`. The
* MIME type array and the strings it points to are borrowed only for
* the duration of the ghostty_terminal_paste() call, as is everything
* the reader produces.
*/
typedef struct {
/** Size of this struct in bytes. */
size_t size;
/**
* The clipboard the contents came from. Reported to the program on a
* paste event (the selection and primary locations are both reported
* as the primary selection, the protocol knows only two); no effect
* on a text paste.
*/
GhosttyClipboardLocation location;
/** Why this paste happened. */
GhosttyPasteSource source;
/**
* Borrowed array of the MIME types of the representations available,
* in preferred order. A text paste reads and writes the first entry
* with a text MIME type such as "text/plain" and ignores the rest. A
* paste event lists every entry and reads none. May be NULL when
* mimes_len is zero, which is nothing to paste.
*/
const GhosttyString* mimes;
/** Number of entries in mimes. */
size_t mimes_len;
/**
* Produces the data of a representation on demand. Required when
* mimes_len is nonzero.
*
* Called at most once per ghostty_terminal_paste() call: for the
* text representation being pasted, never for anything else and
* never for a paste event. The MIME type requested is always an
* entry of `mimes`, passed through exactly as given there (the same
* pointer and length), so the callback may identify the
* representation by pointer or by content. A false return fails the
* paste with GHOSTTY_IO_ERROR.
*/
GhosttyMimeReader reader;
/**
* Write text that could inject commands. Call with false, confirm
* with the user on GHOSTTY_REJECTED, and call again with true.
*/
bool allow_unsafe;
} GhosttyPaste;
/**
* Paste into the terminal according to its current state: a Kitty
* clipboard protocol paste event if mode 5522 is enabled and a
* clipboard_read callback is installed, otherwise the text framed per
* mode 2004. See the group documentation for the full behavior. Output
* streams through the write_pty callback in chunks. The viewport is not
* scrolled; that is up to the embedder, as for key input.
*
* A paste event records a session grant for its one-time password only
* once the event is written; a failed call never leaves a grant for an
* event that was never sent.
*
* @param terminal The terminal handle
* @param paste The paste request, borrowed for the duration of the call
* @param[out] out_written On success, whether anything was written to
* the pty (the encoded text or a paste event). False means
* there was nothing to paste: no non-empty text
* representation. May be NULL.
* @return GHOSTTY_SUCCESS on success (see @p out_written);
* GHOSTTY_REJECTED if the text could inject commands and
* GhosttyPaste::allow_unsafe is false (nothing was written);
* GHOSTTY_INVALID_VALUE for a NULL terminal or paste, MIME
* types without a reader, or when no write_pty callback is
* installed; GHOSTTY_OUT_OF_MEMORY; GHOSTTY_IO_ERROR if the
* reader failed or there is no secure entropy source to mint a
* paste event password (wasm32-freestanding without
* GHOSTTY_SYS_OPT_RANDOM_SECURE set). Errors write nothing.
*/
GHOSTTY_API GhosttyResult ghostty_terminal_paste(
GhosttyTerminal terminal,
const GhosttyPaste* paste,
bool* out_written);
/**
* Check if paste data is safe to paste into the terminal.
*
@@ -49,7 +198,9 @@ extern "C" {
* to exit bracketed paste mode and inject commands
*
* This check is conservative and considers data unsafe regardless of
* current terminal state.
* current terminal state. ghostty_terminal_paste() applies the
* terminal-state-aware rule itself (newlines are safe inside a
* bracketed paste); use this to apply the stricter rule on top.
*
* @param data The paste data to check (must not be NULL)
* @param len The length of the data in bytes
@@ -74,6 +225,9 @@ GHOSTTY_API bool ghostty_paste_is_safe(const char* data, size_t len);
* GHOSTTY_OUT_OF_SPACE and sets the required size in @p out_written.
* The caller can then retry with a sufficiently sized buffer.
*
* This is the encoder ghostty_terminal_paste() uses for a text paste;
* use it directly when there is no terminal to paste into.
*
* @param data The paste data to encode (modified in place, may be NULL)
* @param data_len The length of the input data in bytes
* @param bracketed Whether bracketed paste mode is active

View File

@@ -9,6 +9,8 @@
#include <stdint.h>
#include <ghostty/vt/types.h>
#ifdef __cplusplus
extern "C" {
#endif

View File

@@ -63,7 +63,10 @@ extern "C" {
* tracking which rows in a partially dirty frame have changed.
*
* The user of the render state API is expected to unset both of these.
* The `update` call does not unset dirty state, it only updates it.
* The `update` call does not unset dirty state, it only updates it. After
* successfully rendering a complete frame, use ghostty_render_state_clean()
* to unset both layers in one call. The granular setters remain available
* for callers that only consume part of a frame.
*
* An extremely important detail: setting one dirty state doesn't unset
* the other. For example, setting the global dirty state to false does not
@@ -199,6 +202,14 @@ typedef enum GHOSTTY_ENUM_TYPED {
/** Whether the cursor is on the tail of a wide character (bool).
* Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */
GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_WIDE_TAIL = 17,
/** All cursor state in one sized struct (GhosttyRenderStateCursor).
* Initialize the output with GHOSTTY_INIT_SIZED before querying. */
GHOSTTY_RENDER_STATE_DATA_CURSOR = 18,
/** All render-state colors in one sized struct (GhosttyRenderStateColors).
* Initialize the output with GHOSTTY_INIT_SIZED before querying. */
GHOSTTY_RENDER_STATE_DATA_COLORS = 19,
GHOSTTY_RENDER_STATE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyRenderStateData;
@@ -236,6 +247,22 @@ typedef enum GHOSTTY_ENUM_TYPED {
/** Row-local selected cell range (GhosttyRenderStateRowSelection). */
GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION = 4,
/** A borrowed view of the raw cell values for the current row
* (GhosttyCellsView). One value per column, identical to querying
* GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW for each cell. The view
* is only valid as long as the underlying render state is not
* updated; it is unsafe to use after updating the render state.
*
* This is the bulk alternative to iterating cells one at a time.
* It lets callers with expensive call boundaries (e.g. WebAssembly
* embedders) read an entire row with a single call.
*
* Bit positions aren't protected by ABI, so callers should parse them
* out of the manifest from `ghostty_type_json`. Callers with access
* to the C header or without high FFI costs should use `ghostty_cell_get`.
*/
GHOSTTY_RENDER_STATE_ROW_DATA_CELLS_RAW = 5,
GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyRenderStateRowData;
@@ -273,17 +300,59 @@ typedef struct {
uint16_t end_x;
} GhosttyRenderStateRowSelection;
/**
* Render-state cursor information.
*
* This struct uses the sized-struct ABI pattern. Initialize with
* GHOSTTY_INIT_SIZED(GhosttyRenderStateCursor) before querying
* GHOSTTY_RENDER_STATE_DATA_CURSOR.
*
* When viewport_has_value is false, viewport_x, viewport_y, and wide_tail
* contain undefined data and must not be read.
*
* @ingroup render
*/
typedef struct {
/** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateCursor). */
size_t size;
/** Whether the cursor is visible within the viewport. */
bool viewport_has_value;
/** Cursor viewport x position in cells. */
uint16_t viewport_x;
/** Cursor viewport y position in cells. */
uint16_t viewport_y;
/** Whether the cursor is on the tail of a wide character. */
bool wide_tail;
/** Whether the cursor is visible based on terminal modes. */
bool visible;
/** Whether the cursor should blink based on terminal modes. */
bool blinking;
/** Whether the cursor is at a password input field. */
bool password_input;
/** The visual style of the cursor. */
GhosttyRenderStateCursorVisualStyle visual_style;
} GhosttyRenderStateCursor;
/**
* Render-state color information.
*
* This struct uses the sized-struct ABI pattern. Initialize with
* GHOSTTY_INIT_SIZED(GhosttyRenderStateColors) before calling
* ghostty_render_state_colors_get().
* GHOSTTY_INIT_SIZED(GhosttyRenderStateColors) before querying
* GHOSTTY_RENDER_STATE_DATA_COLORS.
*
* Example:
* @code
* GhosttyRenderStateColors colors = GHOSTTY_INIT_SIZED(GhosttyRenderStateColors);
* GhosttyResult result = ghostty_render_state_colors_get(state, &colors);
* GhosttyResult result = ghostty_render_state_get(
* state, GHOSTTY_RENDER_STATE_DATA_COLORS, &colors);
* @endcode
*
* @ingroup render
@@ -408,6 +477,23 @@ GHOSTTY_API GhosttyResult ghostty_render_state_begin_update(GhosttyRenderState s
*/
GHOSTTY_API GhosttyResult ghostty_render_state_end_update(GhosttyRenderState state);
/**
* Mark all dirty render-state data as consumed.
*
* This sets the global dirty state to GHOSTTY_RENDER_STATE_DIRTY_FALSE and
* clears every per-row dirty flag. It is idempotent and does not modify cell
* contents or dirty state owned by the terminal. Call this only after a
* complete frame has been rendered successfully; partial consumers should
* use ghostty_render_state_set() and ghostty_render_state_row_set() instead.
*
* @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is
* NULL
*
* @ingroup render
*/
GHOSTTY_API GhosttyResult ghostty_render_state_clean(GhosttyRenderState state);
/**
* Get a value from a render state.
*
@@ -417,8 +503,9 @@ GHOSTTY_API GhosttyResult ghostty_render_state_end_update(GhosttyRenderState sta
* @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param data The data kind to query
* @param[out] out Pointer to receive the queried value
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is
* NULL or `data` is not a recognized enum value
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or
* `out` is NULL, `data` is not a recognized enum value, or a sized
* output struct is smaller than `sizeof(size_t)`
*
* @ingroup render
*/
@@ -473,24 +560,6 @@ GHOSTTY_API GhosttyResult ghostty_render_state_set(GhosttyRenderState state,
GhosttyRenderStateOption option,
const void* value);
/**
* Get the current color information from a render state.
*
* This writes as many fields as fit in the caller-provided sized struct.
* `out_colors->size` must be set by the caller (typically via
* GHOSTTY_INIT_SIZED(GhosttyRenderStateColors)).
*
* @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param[out] out_colors Sized output struct to receive render-state colors
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or
* `out_colors` is NULL, or if `out_colors->size` is smaller than
* `sizeof(size_t)`
*
* @ingroup render
*/
GHOSTTY_API GhosttyResult ghostty_render_state_colors_get(GhosttyRenderState state,
GhosttyRenderStateColors* out_colors);
/**
* Create a new row iterator instance.
*
@@ -521,7 +590,8 @@ GHOSTTY_API void ghostty_render_state_row_iterator_free(GhosttyRenderStateRowIte
/**
* Move a render-state row iterator to the next row.
*
* Returns true if the iterator moved successfully and row data is
* Rows are visited contiguously in ascending viewport order, starting at
* y = 0. Returns true if the iterator moved successfully and row data is
* available to read at the new position.
*
* @param iterator The iterator handle to advance (may be NULL)
@@ -532,12 +602,36 @@ GHOSTTY_API void ghostty_render_state_row_iterator_free(GhosttyRenderStateRowIte
*/
GHOSTTY_API bool ghostty_render_state_row_iterator_next(GhosttyRenderStateRowIterator iterator);
/**
* Move a render-state row iterator to the next row requiring a redraw.
*
* If the global dirty state is GHOSTTY_RENDER_STATE_DIRTY_FALSE, this returns
* false. If it is GHOSTTY_RENDER_STATE_DIRTY_PARTIAL, clean rows are skipped.
* If it is GHOSTTY_RENDER_STATE_DIRTY_FULL, every remaining row is returned
* regardless of its per-row dirty flag. Rows are returned in ascending
* viewport order. This function does not clear any dirty state.
*
* @param iterator The iterator handle to advance (NULL returns false)
* @param[out] out_y Receives the viewport y coordinate when true is returned
* (NULL returns false); it is not modified when false is
* returned
* @return true if advanced to a row requiring a redraw, false if an argument
* is NULL or the iterator has reached the end of the effective dirty
* rows
*
* @ingroup render
*/
GHOSTTY_API bool ghostty_render_state_row_iterator_next_dirty(
GhosttyRenderStateRowIterator iterator,
uint16_t* out_y);
/**
* Get a value from the current row in a render-state row iterator.
*
* The `out` pointer must point to a value of the type corresponding to the
* requested data kind (see GhosttyRenderStateRowData).
* Call ghostty_render_state_row_iterator_next() at least once before
* Call ghostty_render_state_row_iterator_next() or
* ghostty_render_state_row_iterator_next_dirty() at least once before
* calling this function.
*
* @param iterator The iterator handle to query (NULL returns GHOSTTY_INVALID_VALUE)
@@ -586,7 +680,8 @@ GHOSTTY_API GhosttyResult ghostty_render_state_row_get_multi(
*
* The `value` pointer must point to a value of the type corresponding to the
* requested option kind (see GhosttyRenderStateRowOption).
* Call ghostty_render_state_row_iterator_next() at least once before
* Call ghostty_render_state_row_iterator_next() or
* ghostty_render_state_row_iterator_next_dirty() at least once before
* calling this function.
*
* @param iterator The iterator handle to update (NULL returns GHOSTTY_INVALID_VALUE)

View File

@@ -21,19 +21,21 @@ extern "C" {
* Terminal screen cell and row types.
*
* These types represent the contents of a terminal screen. A GhosttyCell
* is a single grid cell and a GhosttyRow is a single row. Both are opaque
* values whose fields are accessed via ghostty_cell_get() and
* ghostty_row_get() respectively.
* is a single grid cell and a GhosttyRow is a single row. Cell fields can
* be accessed via ghostty_cell_get() or decoded from the packed layout in
* ghostty_type_json(). Rows are opaque and accessed via ghostty_row_get().
*
* @{
*/
/**
* Opaque cell value.
* Packed cell value.
*
* Represents a single terminal cell. The internal layout is opaque and
* must be queried via ghostty_cell_get(). Obtain cell values from
* terminal query APIs.
* Represents a single terminal cell. Portable callers can query fields via
* ghostty_cell_get(). Boundary-sensitive callers can decode the packed value
* using the GhosttyCell descriptor returned by ghostty_type_json(). The
* manifest is authoritative for the linked build; hardcoding bit positions
* is unsupported.
*
* @ingroup screen
*/
@@ -50,6 +52,24 @@ typedef uint64_t GhosttyCell;
*/
typedef uint64_t GhosttyRow;
/**
* A borrowed view of contiguous raw cell values.
*
* The memory is not owned by this struct. The pointer is only valid
* for the lifetime documented by the API that produces it. Each value
* can be queried via ghostty_cell_get() or decoded using the GhosttyCell
* packed descriptor returned by ghostty_type_json().
*
* @ingroup screen
*/
typedef struct {
/** Pointer to len contiguous cell values. */
const GhosttyCell* ptr;
/** Number of cells. */
size_t len;
} GhosttyCellsView;
/**
* Cell content tag.
*

501
include/ghostty/vt/search.h Normal file
View File

@@ -0,0 +1,501 @@
/**
* @file search.h
*
* Search terminal contents, including scrollback, for a string.
*/
#ifndef GHOSTTY_VT_SEARCH_H
#define GHOSTTY_VT_SEARCH_H
#include <stddef.h>
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/selection.h>
#include <ghostty/vt/types.h>
#ifdef __cplusplus
extern "C" {
#endif
/** @defgroup search Search
*
* Search a terminal for a string, covering the active area and
* scrollback of both the primary and alternate screens.
*
* A GhosttySearch searches the terminal it was created with for a
* needle set with GHOSTTY_SEARCH_OPT_NEEDLE. It handles the hard
* parts of terminal search internally: results stay in sync with the
* live screens, survive primary/alternate screen switches (entering
* and leaving a fullscreen app such as vim does not restart a
* scrollback search), and recover from resize, reflow, resets, and
* scrollback pruning.
*
* A search starts idle. Setting the needle starts the search,
* changing it restarts the search from scratch, and clearing it
* returns the search to idle. Matching is byte-exact except ASCII
* letters, which compare case-insensitively.
*
* ## Driving a search
*
* Searching a large scrollback takes time, so the work is split into
* small steps the caller drives so that the caller can control
* performance more directly:
*
* - ghostty_search_tick() makes a bounded amount of progress on data
* the search has already copied. It never touches the terminal,
* meaning it can be safely called from a thread.
* - ghostty_search_feed() reads the terminal to copy in more data and
* pick up terminal changes. Feeding is the only way the search
* learns that the terminal changed, so keep feeding periodically
* while the search is in use. This requires exclusive terminal access.
* - ghostty_search_run() is a blocking convenience that feeds and
* ticks until the search is caught up.
*
* GHOSTTY_SEARCH_STATUS_COMPLETE means the search is caught up with
* the terminal as of the last feed. It never means finished forever,
* since later terminal writes require another feed to be seen.
*
* ## Matches are selections
*
* Every match is returned as a GhosttySelection snapshot with
* rectangle set to false, so the existing selection APIs all work on
* matches: ghostty_terminal_selection_format_buf() to copy the
* matched text, ghostty_terminal_point_from_grid_ref() with
* GHOSTTY_POINT_TAG_VIEWPORT to position highlight rectangles,
* ghostty_terminal_selection_contains() for hit testing, and
* ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to make
* a match the terminal's selection.
*
* Returned matches follow the usual snapshot lifetime rules: they are
* only valid until the next operation that modifies the terminal,
* including ghostty_terminal_vt_write(), resize, reset, and free.
* Read matches after a feed, use them before the terminal changes
* again, and re-read them rather than caching them. The selected
* match is kept accurate internally across terminal changes, so the
* safe way to follow a match is to re-read
* GHOSTTY_SEARCH_DATA_SELECTED_MATCH after each feed.
*
* ## Lifetime
*
* The search borrows the terminal it was created with and never frees
* it. Any number of searches, alongside other terminal readers such
* as formatters and render states, may share one terminal.
*
* The search and its terminal can be freed in either order. Freeing
* the search first releases tracked state it holds within the
* terminal. If the terminal is freed first, the search detects this:
* calls that need the terminal return GHOSTTY_INVALID_VALUE, reads
* return whatever the search last saw, and ghostty_search_free()
* releases only search-owned memory. A search cannot be rebound, so
* searching another terminal means creating a new search.
*
* ## Threading
*
* The library creates no threads. Calls on one GhosttySearch are not
* safe to make concurrently with each other, so the caller must
* serialize them.
*
* Functions that touch the terminal (ghostty_search_new(),
* ghostty_search_feed(), ghostty_search_run(), ghostty_search_set()
* with the needle and select options, and ghostty_search_free()) must
* also be serialized with all other access to the same terminal.
*
* Everything else (ghostty_search_tick(), ghostty_search_get(), and
* ghostty_search_get_multi()) only touches memory owned by the search
* and is safe to call while another thread modifies the terminal.
* This split is how Ghostty runs search on a background thread: tick
* freely, and take the terminal lock only to feed. Reading returned
* match values is always safe, but passing them to APIs that take the
* terminal follows the terminal serialization rule above.
*
* ## Example
*
* @snippet c-vt-search/src/main.c search-main
*
* @{
*/
/**
* Progress state of a search.
*
* @ingroup search
*/
typedef enum GHOSTTY_ENUM_TYPED {
/**
* ghostty_search_tick() can make progress without terminal access.
*/
GHOSTTY_SEARCH_STATUS_RUNNING = 0,
/**
* Blocked until ghostty_search_feed(). This is also the state right
* after a needle is set, since the search has not yet seen the
* terminal.
*/
GHOSTTY_SEARCH_STATUS_FEED_REQUIRED = 1,
/**
* Caught up with the terminal state as of the last feed. This never
* means finished forever, since later terminal writes require
* another feed to be seen. A search with no needle set also reports
* complete, since there is nothing to look for.
*/
GHOSTTY_SEARCH_STATUS_COMPLETE = 2,
GHOSTTY_SEARCH_STATUS_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySearchStatus;
/**
* Scroll policy applied when a match becomes selected via
* GHOSTTY_SEARCH_OPT_SELECT_NEXT or GHOSTTY_SEARCH_OPT_SELECT_PREV.
*
* @ingroup search
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** Scroll the viewport so the match is visible, only if it is not
* already visible. This is the default. */
GHOSTTY_SEARCH_SCROLL_IF_NEEDED = 0,
/** Never scroll the viewport. */
GHOSTTY_SEARCH_SCROLL_NONE = 1,
GHOSTTY_SEARCH_SCROLL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySearchScroll;
/**
* Data fields readable with ghostty_search_get(). The output value
* type is documented per field.
*
* All reads reflect the terminal's active screen as of the last feed.
* When the running application switches to the alternate screen, the
* next feed switches counts, matches, and selection to that screen's
* results. Primary screen results, including completed scrollback
* searches, are retained and restored on the way back.
*
* @ingroup search
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** Current search status: GhosttySearchStatus*. */
GHOSTTY_SEARCH_DATA_STATUS = 0,
/**
* The needle this search is looking for: GhosttyString*. The bytes
* are borrowed from the search and remain valid until the needle is
* changed or the search is freed. Returns GHOSTTY_NO_VALUE when no
* needle is set.
*/
GHOSTTY_SEARCH_DATA_NEEDLE = 1,
/**
* Total matches found so far on the active screen: size_t*. Zero
* until the first feed.
*/
GHOSTTY_SEARCH_DATA_TOTAL_MATCHES = 2,
/**
* Index of the selected match: size_t*. This indexes the newest to
* oldest ordering of GHOSTTY_SEARCH_DATA_MATCHES, where 0 is the
* newest match, so a "k of n" find bar renders index + 1 of
* GHOSTTY_SEARCH_DATA_TOTAL_MATCHES. Returns GHOSTTY_NO_VALUE when
* nothing is selected.
*/
GHOSTTY_SEARCH_DATA_SELECTED_INDEX = 3,
/**
* The selected match: GhosttySelection*. This is an untracked
* snapshot with standard GhosttySelection lifetime rules. Returns
* GHOSTTY_NO_VALUE when nothing is selected.
*/
GHOSTTY_SEARCH_DATA_SELECTED_MATCH = 4,
/**
* All matches on the active screen, ordered newest to oldest, from
* the bottom of the active area up through scrollback:
* GhosttySelectionBuffer*. Set ptr to NULL with cap 0 to query the
* required capacity. An undersized buffer returns
* GHOSTTY_OUT_OF_SPACE with the required capacity in len.
*/
GHOSTTY_SEARCH_DATA_MATCHES = 5,
/**
* Matches on the pages covering the viewport, for drawing highlight
* rectangles: GhosttySelectionBuffer*. The list is computed during
* feeds and cached, so it reflects the viewport as of the last
* feed.
*
* Matches are found a page at a time, so the list can include
* matches slightly outside the visible viewport when they share a
* page with it. Ghostty's own renderer behaves the same way.
* Converting each match to viewport coordinates with
* ghostty_terminal_point_from_grid_ref() clips this naturally: skip
* matches that fail the conversion or whose row is beyond the
* visible row count.
*/
GHOSTTY_SEARCH_DATA_VIEWPORT_MATCHES = 6,
/** Current scroll policy: GhosttySearchScroll*. */
GHOSTTY_SEARCH_DATA_SELECT_SCROLL = 7,
GHOSTTY_SEARCH_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySearchData;
/**
* Options writable with ghostty_search_set(). The value type, and
* what a NULL value means, is documented per option.
*
* @ingroup search
*/
typedef enum GHOSTTY_ENUM_TYPED {
/**
* Set the needle to search for: const GhosttyString*. The bytes are
* copied, so the caller's memory does not need to outlive the call.
* Matching is byte-exact except ASCII letters, which compare
* case-insensitively.
*
* Changing the needle restarts the search from scratch and drops
* all results. As an exception, setting a needle equal to the current
* one (compared the same way as matching) keeps existing results,
* so find bars can resubmit freely. A NULL or empty value clears
* the needle and returns the search to idle.
*
* Replacing or clearing a needle releases tracked state held
* within the terminal, so the caller must serialize this with all
* other access to the same terminal. Returns GHOSTTY_INVALID_VALUE
* after the terminal was freed.
*/
GHOSTTY_SEARCH_OPT_NEEDLE = 0,
/**
* Select the next match, moving toward older content: from the
* bottom of the screen upward into history, the direction a search
* from the prompt usually wants. Wraps around past the oldest
* match.
*
* The value must be NULL. It is reserved for future use.
*
* This catches up with the terminal first, so it is safe to call at
* any time relative to feeds. The viewport scrolls to the newly
* selected match according to GHOSTTY_SEARCH_OPT_SELECT_SCROLL.
* This reads the terminal, so the caller must serialize it with all
* other access to the same terminal. Returns GHOSTTY_NO_VALUE when
* there are no matches.
*/
GHOSTTY_SEARCH_OPT_SELECT_NEXT = 1,
/**
* Select the previous match, moving toward newer content, wrapping
* around past the newest match. Otherwise identical to
* GHOSTTY_SEARCH_OPT_SELECT_NEXT.
*/
GHOSTTY_SEARCH_OPT_SELECT_PREV = 2,
/**
* Set the scroll policy applied by the select options: const
* GhosttySearchScroll*. The policy persists until changed. A NULL
* value resets it to GHOSTTY_SEARCH_SCROLL_IF_NEEDED. This only
* modifies search-owned state and never reads the terminal.
*/
GHOSTTY_SEARCH_OPT_SELECT_SCROLL = 3,
GHOSTTY_SEARCH_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySearchOption;
/**
* Create a search bound to a terminal.
*
* The search borrows the terminal and never frees it. The search and
* the terminal can be freed in either order; see ghostty_search_free().
*
* The search starts idle with no needle: it reports
* GHOSTTY_SEARCH_STATUS_COMPLETE and finds nothing. Set
* GHOSTTY_SEARCH_OPT_NEEDLE to start searching.
*
* Creation is cheap and does not read terminal contents, but it
* registers the search with the terminal so the two can be freed in
* any order. The caller must serialize this call with all other
* access to the same terminal.
*
* @param allocator Allocator, or NULL for the default allocator
* @param out_search Receives the created search handle
* @param terminal Terminal to bind the search to
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if
* out_search or terminal is invalid, or GHOSTTY_OUT_OF_MEMORY
* if allocation fails
*
* @ingroup search
*/
GHOSTTY_API GhosttyResult ghostty_search_new(
const GhosttyAllocator* allocator,
GhosttySearch* out_search,
GhosttyTerminal terminal);
/**
* Free a search.
*
* If the bound terminal is still alive, this releases tracked state
* the search holds within it, so the caller must serialize this call
* with all other access to the same terminal. If the terminal was
* already freed, the search has been detached and this releases only
* search-owned memory. Passing NULL is allowed and is a no-op.
*
* @param search Search handle to free
*
* @ingroup search
*/
GHOSTTY_API void ghostty_search_free(GhosttySearch search);
/**
* Make a bounded amount of search progress.
*
* This only works on data the search has already copied and never
* reads the terminal, so it is safe to call while another thread
* modifies the terminal. Call it in a loop while the status is
* GHOSTTY_SEARCH_STATUS_RUNNING. When the status becomes
* GHOSTTY_SEARCH_STATUS_FEED_REQUIRED, call ghostty_search_feed() to
* unblock it.
*
* @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param[out] out_status Receives the status after the tick (may be NULL)
* @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if
* search is NULL
*
* @ingroup search
*/
GHOSTTY_API GhosttyResult ghostty_search_tick(
GhosttySearch search,
GhosttySearchStatus* out_status);
/**
* Read the terminal to update the search.
*
* Each feed catches the search up with the terminal: it reconciles
* the tracked screens against the live ones, re-scans the active
* area, refreshes the viewport match list, gives the scrollback
* searcher its next chunk of data, and prunes results that scrollback
* eviction invalidated. Feeding is also the only way the search
* learns about terminal changes, so keep feeding periodically while
* the search is in use, even after it reports complete.
*
* This reads the terminal, so the caller must serialize it with all
* other access to the same terminal. Each call does a bounded amount
* of work so that any caller-held terminal lock is held only briefly.
*
* @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE)
* @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if
* search is NULL or the terminal was freed
*
* @ingroup search
*/
GHOSTTY_API GhosttyResult ghostty_search_feed(GhosttySearch search);
/**
* Feed and tick until the search is caught up with the terminal.
*
* This is a blocking convenience for one-shot and single-threaded
* embedders. It always performs at least one feed, so it also picks
* up any terminal changes since the last feed, then loops until the
* status is GHOSTTY_SEARCH_STATUS_COMPLETE. Searching a large
* scrollback can take a while, so interactive embedders should drive
* ghostty_search_tick() and ghostty_search_feed() themselves.
*
* This reads the terminal for the entire call, so the caller must
* serialize it with all other access to the same terminal.
*
* @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE)
* @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if
* search is NULL or the terminal was freed
*
* @ingroup search
*/
GHOSTTY_API GhosttyResult ghostty_search_run(GhosttySearch search);
/**
* Write an option to a search.
*
* The value type, and what a NULL value means, depends on the option
* and is documented by GhosttySearchOption. The needle and select
* options touch the terminal, so the caller must serialize those
* calls with all other access to the same terminal.
* GHOSTTY_SEARCH_OPT_SELECT_SCROLL only modifies search-owned state.
*
* @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param option Option to write
* @param value Pointer to the input value for the option. The meaning
* of NULL is documented per option.
* @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if a select
* option found no matches, GHOSTTY_OUT_OF_MEMORY if
* allocation fails, or GHOSTTY_INVALID_VALUE if search,
* option, or value is invalid or the option needs a terminal
* that was already freed
*
* @ingroup search
*/
GHOSTTY_API GhosttyResult ghostty_search_set(
GhosttySearch search,
GhosttySearchOption option,
const void* value);
/**
* Read a data field from a search.
*
* The output value type depends on data and is documented by
* GhosttySearchData. This never reads the terminal, so it is safe to
* call while another thread modifies the terminal. Returned
* selections are untracked snapshots with standard GhosttySelection
* lifetime rules.
*
* @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param data Data field to read
* @param value Output pointer whose type depends on data
* @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the
* requested data has no value, GHOSTTY_OUT_OF_SPACE if a
* provided GhosttySelectionBuffer is too small (required
* capacity in its len), GHOSTTY_OUT_OF_MEMORY if collecting
* viewport matches fails, or GHOSTTY_INVALID_VALUE if search,
* data, or value is invalid
*
* @ingroup search
*/
GHOSTTY_API GhosttyResult ghostty_search_get(
GhosttySearch search,
GhosttySearchData data,
void* value);
/**
* Read multiple data fields from a search in a single call.
*
* This is an optimization over calling ghostty_search_get() multiple
* times. Each entry in values must point to storage of the type
* documented by the corresponding GhosttySearchData key.
*
* If any individual read fails, the function returns that error and
* writes the index of the failing key to out_written when out_written
* is non-NULL. Earlier keys have already been written. On success,
* out_written receives count when non-NULL. A too-small
* GhosttySelectionBuffer stops the batch with GHOSTTY_OUT_OF_SPACE at
* that key's index with the required capacity in its len, so order
* buffer-valued keys after scalar keys.
*
* @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param count Number of data fields to read
* @param keys Data fields to read (must not be NULL)
* @param values Output pointers corresponding to keys (must not be NULL)
* @param out_written Optional number of fields read, or failing index
* on error
* @return GHOSTTY_SUCCESS on success, or the first failing read's
* result
*
* @ingroup search
*/
GHOSTTY_API GhosttyResult ghostty_search_get_multi(
GhosttySearch search,
size_t count,
const GhosttySearchData* keys,
void** values,
size_t* out_written);
/** @} */
#ifdef __cplusplus
}
#endif
#endif /* GHOSTTY_VT_SEARCH_H */

View File

@@ -116,6 +116,29 @@ typedef struct {
bool rectangle;
} GhosttySelection;
/**
* A caller-provided buffer of selections.
*
* This follows the same conventions as GhosttyBuffer: ptr may be NULL with
* cap 0 to query the required capacity. APIs that fill this type set len to
* the number of entries written on GHOSTTY_SUCCESS, or to the required entry
* capacity on GHOSTTY_OUT_OF_SPACE.
*
* @ingroup selection
*/
typedef struct {
/** Destination buffer for selections. May be NULL when cap is 0 to query
* the required capacity. */
GhosttySelection* ptr;
/** Capacity of ptr in entries. */
size_t cap;
/** Entries written on success, or required entry capacity on
* GHOSTTY_OUT_OF_SPACE. */
size_t len;
} GhosttySelectionBuffer;
/**
* Options for deriving a word selection from a terminal grid reference.
*

View File

@@ -319,31 +319,6 @@ GHOSTTY_API GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute
GHOSTTY_API GhosttySgrAttributeValue* ghostty_sgr_attribute_value(
GhosttySgrAttribute* attr);
#ifdef __wasm__
/**
* Allocate memory for an SGR attribute (WebAssembly only).
*
* This is a convenience function for WebAssembly environments to allocate
* memory for an SGR attribute structure that can be passed to ghostty_sgr_next.
*
* @return Pointer to the allocated attribute structure
*
* @ingroup wasm
*/
GHOSTTY_API GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void);
/**
* Free memory for an SGR attribute (WebAssembly only).
*
* Frees memory allocated by ghostty_wasm_alloc_sgr_attribute.
*
* @param attr Pointer to the attribute structure to free
*
* @ingroup wasm
*/
GHOSTTY_API void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr);
#endif
#ifdef __cplusplus
}
#endif

View File

@@ -7,6 +7,7 @@
#ifndef GHOSTTY_VT_SNAPSHOT_H
#define GHOSTTY_VT_SNAPSHOT_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
@@ -131,13 +132,34 @@ typedef enum GHOSTTY_ENUM_TYPED {
* state. The decoder default matches the largest built-in APC protocol
* buffer limit, currently 65 MiB.
*
* This is an input validation limit only. It does not configure continuation
* tracking on a terminal returned by the decoder.
* This is primarily an input validation limit. When
* GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION is true, the same value
* also becomes the continuation tracking limit on the returned terminal.
*
* Input type: size_t *
*/
GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_CONTINUATION_BYTES = 0,
/**
* Retain the decoded continuation on the returned terminal.
*
* When true, terminals returned by ghostty_snapshot_decoder_ready() and
* ghostty_snapshot_decoder_decode() use
* GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_CONTINUATION_BYTES as their continuation
* tracking limit. The existing ghostty_terminal_continuation_* APIs can then
* export the exact unfinished VT or UTF-8 input restored from the snapshot.
*
* This is false by default. A maximum continuation size of zero leaves
* tracking disabled. With a nonzero maximum, tracking remains enabled even
* when the decoded continuation is empty. Exporting an empty continuation
* does not disable it. Callers that do not need ongoing tracking must still
* set GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to zero after export and
* before writing post-snapshot input.
*
* Input type: bool *
*/
GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION = 1,
GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySnapshotDecoderOption;
@@ -222,6 +244,15 @@ typedef enum GHOSTTY_ENUM_TYPED {
*/
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_REMAINING = 7,
/**
* Whether decoded continuation tracking is retained on returned terminals.
*
* This value is available in every non-failed decoder state.
*
* Output type: bool *
*/
GHOSTTY_SNAPSHOT_DECODER_DATA_RETAIN_CONTINUATION = 8,
GHOSTTY_SNAPSHOT_DECODER_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySnapshotDecoderData;
@@ -397,10 +428,16 @@ GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_set(
* immediately usable for rendering and live input. Older scrollback remains
* to be restored with ghostty_snapshot_decoder_next().
*
* The restored parser state may be unfinished, but terminal continuation
* tracking is disabled; GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES returns
* zero. The decoder's continuation option is an input limit, not terminal
* runtime policy.
* The restored parser state may be unfinished. By default, terminal
* continuation tracking is disabled and
* GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES returns zero. When
* GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION is true, the decoder's
* maximum continuation size is applied to the terminal, and the terminal
* continuation APIs export the exact current continuation when that limit is
* nonzero. Tracking remains enabled even if the exported continuation is
* empty. Callers that do not need ongoing tracking must set
* GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to zero after export and before
* writing any post-snapshot bytes, because later input may change it.
*
* The caller must keep the returned terminal alive until FINISH validates or
* the decoder is freed. The decoder borrows this terminal handle while it
@@ -455,8 +492,15 @@ GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_next(
* FINISH. It may only be called before decoding starts. Bytes following FINISH
* are left unread. On success terminal receives a caller-owned terminal with
* its persistent VT stream restored. Continuation tracking on the returned
* terminal is disabled and GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES
* returns zero. terminal is set to NULL on every error.
* terminal is disabled by default. When
* GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION is true, the decoder's
* maximum continuation size is applied to the terminal, and the terminal
* continuation APIs export the exact current continuation when that limit is
* nonzero. Tracking remains enabled even if the exported continuation is
* empty. Callers that do not need ongoing tracking must set
* GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to zero after export and before
* writing any post-snapshot bytes, because later input may change it.
* terminal is set to NULL on every error.
* A decoding, I/O, or allocation error after input consumption begins poisons
* the decoder, after which it must be freed. An invalid argument or
* lifecycle error detected before the operation consumes input does not

View File

@@ -124,6 +124,24 @@ typedef bool (*GhosttySysDecodePngFn)(
size_t data_len,
GhosttySysImage* out);
/**
* Callback type for secure random bytes.
*
* Fills @p buf with @p len cryptographically secure random bytes. The
* library uses this for secrets, so it must be a real CSPRNG (getrandom,
* arc4random_buf, BCryptGenRandom, crypto.getRandomValues, ...); a
* predictable source is a security hole.
*
* @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA
* @param buf Buffer to fill
* @param len Number of bytes to fill
* @return true if the buffer was filled, false if no entropy is available
*/
typedef bool (*GhosttySysRandomSecureFn)(
void* userdata,
uint8_t* buf,
size_t len);
/**
* System option identifiers for ghostty_sys_set().
*/
@@ -165,6 +183,21 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: GhosttySysLogFn (function pointer, or NULL)
*/
GHOSTTY_SYS_OPT_LOG = 2,
/**
* Override the secure random source.
*
* By default the library draws secure random bytes from the
* platform (getrandom or arc4random_buf on POSIX, CNG on Windows).
* Targets without one, such as wasm32-freestanding, have no default
* and operations that need entropy fail with GHOSTTY_IO_ERROR until
* this is set. When set,
* it is used instead of the platform source on every target. When
* cleared (NULL value), the platform default is restored.
*
* Input type: GhosttySysRandomSecureFn (function pointer, or NULL)
*/
GHOSTTY_SYS_OPT_RANDOM_SECURE = 3,
GHOSTTY_SYS_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySysOption;

View File

@@ -86,16 +86,17 @@ extern "C" {
*
* | Option | Callback Type | Trigger |
* |-----------------------------------------|-----------------------------------|-------------------------------------------|
* | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty |
* | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | VT query and mode reports written back to the PTY |
* | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) |
* | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 |
* | `GHOSTTY_TERMINAL_OPT_PWD_CHANGED` | `GhosttyTerminalPwdChangedFn` | Pwd change via OSC 7 / OSC 9 / OSC 1337 |
* | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) |
* | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) |
* | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) |
* | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS query (CSI 14/16/18 t) or mode 2048 enable |
* | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) |
* | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)|
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 |
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 / OSC 5522 |
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ` | `GhosttyTerminalClipboardReadFn` | Clipboard read via OSC 52 "?" / OSC 5522 |
* | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 |
* | `GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT` | `GhosttyTerminalProgressReportFn` | Progress report via OSC 9;4 |
* | `GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE` | `GhosttyTerminalUnknownSequenceFn` | Unsupported sequence identifier |
@@ -112,6 +113,9 @@ extern "C" {
* ### Defining a clipboard_write callback
* @snippet c-vt-effects/src/main.c effects-clipboard-write
*
* ### Defining a clipboard_read callback
* @snippet c-vt-effects/src/main.c effects-clipboard-read
*
* ### Defining an unknown_sequence callback
* @snippet c-vt-effects/src/main.c effects-unknown-sequence
*
@@ -459,38 +463,7 @@ typedef struct {
} GhosttyClipboardContent;
/**
* A semantic, atomic clipboard write.
*
* This is a sized struct. The callback must only access fields present in the
* size reported by `size`. The request, contents array, MIME strings, and
* data strings are all borrowed and valid only for the callback duration.
*
* All entries in `contents` are representations of the same logical value
* and must be committed atomically. A `contents_len` of zero requests that
* the destination be cleared. This is distinct from a content entry whose data
* has zero length.
*
* @ingroup terminal
*/
typedef struct {
/** Size of this struct in bytes. */
size_t size;
/** Clipboard destination. */
GhosttyClipboardLocation location;
/** Borrowed array of MIME representations. */
const GhosttyClipboardContent* contents;
/** Number of entries in contents; zero means clear the destination. */
size_t contents_len;
} GhosttyClipboardWrite;
/**
* Result of a clipboard write callback.
*
* Protocols without write acknowledgements, including OSC 52 and iTerm2
* OSC 1337 Copy, ignore this result.
* Result of a clipboard write reply.
*
* @ingroup terminal
*/
@@ -515,28 +488,335 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_CLIPBOARD_WRITE_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyClipboardWriteResult;
/**
* The reply to a clipboard write request.
*
* This is a sized struct; set `size` to `sizeof(GhosttyClipboardWriteReply)`.
* The reply is borrowed only for the duration of the reply call and may be
* freed as soon as it returns.
*
* The result answers the program with the matching protocol status for
* protocols with a write acknowledgement (OSC 5522: DONE, EPERM, ENOSYS,
* EBUSY, EINVAL, EIO); protocols without one (OSC 52, OSC 1337 Copy)
* discard the reply. `remember` is ignored on any result other than
* GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS.
*
* @ingroup terminal
*/
typedef struct {
/** Size of this struct in bytes. */
size_t size;
/** Outcome of the write. */
GhosttyClipboardWriteResult result;
/**
* Record a session grant so future requests from the same program skip
* the permission prompt. Only honored on success when
* GhosttyClipboardWrite::can_remember is set.
*/
bool remember;
} GhosttyClipboardWriteReply;
typedef struct GhosttyClipboardWrite GhosttyClipboardWrite;
/**
* Function type used to answer a clipboard write request. Obtained from
* GhosttyClipboardWrite::reply; see that struct for the contract.
*
* @param write The request being answered
* @param reply The reply, borrowed only for the duration of this call
*
* @ingroup terminal
*/
typedef void (*GhosttyClipboardWriteReplyFn)(
const GhosttyClipboardWrite* write,
const GhosttyClipboardWriteReply* reply);
/**
* A synchronous request to write clipboard contents.
*
* This is a sized struct. The callback must only access fields present in the
* size reported by `size`. The request, contents array, MIME strings, and
* data strings are all borrowed and valid only for the callback duration.
*
* All entries in `contents` are representations of the same logical value
* and must be committed atomically. A `contents_len` of zero requests that
* the destination be cleared. This is distinct from a content entry whose data
* has zero length.
*
* The write is answered by calling `reply` with this request and a
* GhosttyClipboardWriteReply. This must happen within the clipboard write
* request callback. This struct is only valid during that time. Calling
* `reply` more than once is safely ignored. Returning without replying
* denies the write.
*
* @ingroup terminal
*/
struct GhosttyClipboardWrite {
/** Size of this struct in bytes. */
size_t size;
/** Clipboard destination. */
GhosttyClipboardLocation location;
/** Borrowed array of MIME representations. */
const GhosttyClipboardContent* contents;
/** Number of entries in contents; zero means clear the destination. */
size_t contents_len;
/**
* Name of the writing program for permission prompts, if the protocol
* carries one. Empty otherwise.
*/
GhosttyString name;
/**
* True if the terminal already holds a session grant for this request
* The embedder should skip any permission prompt and perform the write.
*/
bool granted;
/**
* True if the program supplied a session password, so the embedder may
* offer to remember the user's decision through
* GhosttyClipboardWriteReply::remember. When false, remember is ignored.
*/
bool can_remember;
/** Terminal-owned reply state. Do not access. */
const void* ctx;
/** Answer the write; see the struct documentation. */
GhosttyClipboardWriteReplyFn reply;
};
/**
* Callback function type for clipboard_write.
*
* Called synchronously for a complete logical clipboard write. Protocol
* details such as OSC 52 selectors, base64 encoding, multipart chunks,
* aliases, and terminators are normalized before this callback is invoked.
* OSC 52 and iTerm2 OSC 1337 Copy writes therefore use the same callback
* shape. OSC 52 clipboard read requests ("?") are always ignored and never
* forwarded to this callback.
* The embedder may ask for permission to write or perform the write
* async, but the callback itself is synchronous and the reply function
* must be called during the lifetime of this function. While this callback
* is active the VT stream is paused.
*
* Answer by calling `write->reply(write, &reply)` before returning. See
* GhosttyClipboardWrite for the full contract.
*
* The request may carry an optional program name requesting the write
* and the state of prior permission granted. If `can_remember` is set
* the response may set the `remember` flag and future requests from this
* same program will be "granted" and the embedder can skip permission
* requests.
*
* Clipboard read requests (OSC 52 "?" and OSC 5522 reads) are delivered
* to GhosttyTerminalClipboardReadFn instead.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
* @param write Borrowed atomic clipboard write request
* @return The result of attempting the clipboard write
*
* @ingroup terminal
*/
typedef GhosttyClipboardWriteResult (*GhosttyTerminalClipboardWriteFn)(
typedef void (*GhosttyTerminalClipboardWriteFn)(
GhosttyTerminal terminal,
void* userdata,
const GhosttyClipboardWrite* write);
/**
* Result of a clipboard read reply.
*
* @ingroup terminal
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** The clipboard was read; the reply carries its contents. */
GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS = 0,
/** The clipboard read was denied by policy or the user. */
GHOSTTY_CLIPBOARD_READ_RESULT_DENIED = 1,
/** The embedder cannot read this clipboard. */
GHOSTTY_CLIPBOARD_READ_RESULT_UNSUPPORTED = 2,
/** The clipboard is temporarily unavailable. */
GHOSTTY_CLIPBOARD_READ_RESULT_BUSY = 3,
/** Reading the clipboard failed due to an I/O error. */
GHOSTTY_CLIPBOARD_READ_RESULT_IO_ERROR = 4,
GHOSTTY_CLIPBOARD_READ_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyClipboardReadResult;
/**
* The reply to a clipboard read request.
*
* This is a sized struct; set `size` to `sizeof(GhosttyClipboardReadReply)`.
* All arrays and the strings they point to are borrowed only for the
* duration of the reply call and may be freed as soon as it returns.
*
* Any result other than GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS answers the
* program with an empty clipboard (OSC 52) or the matching protocol status
* (OSC 5522: EPERM, ENOSYS, EBUSY, EIO); the other fields are ignored in
* that case. On success, `contents` should carry one representation per
* requested MIME type (GhosttyClipboardRead::mimes) that the clipboard
* has; unrequested representations are ignored. Protocols that carry a
* single text value (OSC 52) use the first entry with a text MIME type
* such as "text/plain".
*
* @ingroup terminal
*/
typedef struct {
/** Size of this struct in bytes. */
size_t size;
/** Outcome of the read. */
GhosttyClipboardReadResult result;
/** Borrowed array of MIME representations of the clipboard contents. */
const GhosttyClipboardContent* contents;
/** Number of entries in contents. */
size_t contents_len;
/**
* Borrowed array of all MIME types available on the clipboard. Only
* used when GhosttyClipboardRead::list is set; may be NULL otherwise.
*/
const GhosttyString* available;
/** Number of entries in available. */
size_t available_len;
/**
* Record a session grant so future requests from the same program skip
* the permission prompt. Only honored on success when
* GhosttyClipboardRead::can_remember is set.
*/
bool remember;
} GhosttyClipboardReadReply;
typedef struct GhosttyClipboardRead GhosttyClipboardRead;
/**
* Function type used to answer a clipboard read request. Obtained from
* GhosttyClipboardRead::reply; see that struct for the contract.
*
* @param read The request being answered
* @param reply The reply, borrowed only for the duration of this call
*
* @ingroup terminal
*/
typedef void (*GhosttyClipboardReadReplyFn)(
const GhosttyClipboardRead* read,
const GhosttyClipboardReadReply* reply);
/**
* A synchronous request to read clipboard contents.
*
* This is a sized struct. The callback must only access fields present in the
* size reported by `size`. The request is borrowed and valid only for the
* callback duration.
*
* The read is answered by calling `reply` with this request and a
* GhosttyClipboardReadReply. This must happen before the callback returns;
* the request is invalid afterwards. Calling `reply` more than once is
* ignored. Returning without replying answers the program with an empty
* clipboard (OSC 52) or EPERM (OSC 5522).
*
* @ingroup terminal
*/
struct GhosttyClipboardRead {
/** Size of this struct in bytes. */
size_t size;
/** Clipboard to read. */
GhosttyClipboardLocation location;
/**
* Borrowed array of the MIME types the program wants, in order of
* preference. Protocols that only carry text (OSC 52) request
* "text/plain". NULL when mimes_len is zero.
*/
const GhosttyString* mimes;
/** Number of entries in mimes. */
size_t mimes_len;
/**
* True if the program also wants the list of MIME types available on the
* clipboard, delivered through GhosttyClipboardReadReply::available.
*/
bool list;
/**
* Name of the requesting program for permission prompts, if the protocol
* carries one. Empty otherwise.
*/
GhosttyString name;
/**
* True if the terminal already holds a session grant for this request
* (kitty clipboard protocol passwords). The embedder should skip any
* permission prompt and serve the read.
*
* Always false when mimes_len is zero: such a request is served
* without a prompt (see the callback docs), so the terminal never
* consults grants for it and a one-time password is preserved for
* the follow-up data read.
*/
bool granted;
/**
* True if the program supplied a session password, so the embedder may
* offer to remember the user's decision through
* GhosttyClipboardReadReply::remember. When false, remember is ignored.
*/
bool can_remember;
/** Terminal-owned reply state. Do not access. */
const void* ctx;
/** Answer the read; see the struct documentation. */
GhosttyClipboardReadReplyFn reply;
};
/**
* Callback function type for clipboard_read.
*
* Called synchronously when the running program requests clipboard contents
* via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read.
* Answering lets the program read the user's clipboard, so the embedder is
* expected to mediate consent. Because the read is synchronous, an embedder
* that needs to ask the user must block (for example by running a modal
* prompt) until it has an answer; the VT stream waits until the callback
* returns.
*
* Answer by calling `read->reply(read, &reply)` before returning. See
* GhosttyClipboardRead for the full contract.
*
* OSC 5522 requests carry the program's MIME list, name, and password grant
* state; a reply that sets `remember` records a session grant so later
* requests with the same password arrive with `granted` set. Kitty itself
* serves a request for only the targets listing (`list` with no `mimes`)
* without prompting, and embedders are expected to do the same; the
* terminal never consults grants for such requests (`granted` is false
* and one-time passwords are not consumed).
*
* Installing this callback also enables Kitty paste events (mode 5522):
* ghostty_terminal_paste() sends the program an event instead of the text,
* and the program's follow-up read arrives here with `granted` set since
* the user already pasted. See ghostty_terminal_paste().
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
* @param read Borrowed clipboard read request
*
* @ingroup terminal
*/
typedef void (*GhosttyTerminalClipboardReadFn)(
GhosttyTerminal terminal,
void* userdata,
const GhosttyClipboardRead* read);
/**
* A request to show a desktop notification.
*
@@ -688,16 +968,18 @@ typedef GhosttyString (*GhosttyTerminalEnquiryFn)(GhosttyTerminal terminal,
void* userdata);
/**
* Callback function type for size queries (XTWINOPS).
* Callback function type for terminal size reports.
*
* Called in response to XTWINOPS size queries (CSI 14/16/18 t).
* Called in response to XTWINOPS size queries (CSI 14/16/18 t) and when VT
* input enables in-band size reports (mode 2048).
* Return true and fill *out_size with the current terminal geometry,
* or return false to silently ignore the query.
* or return false to suppress the report.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
* @param[out] out_size Pointer to store the terminal size information
* @return true if size was filled, false to ignore the query
* @return true if size was filled, false to suppress the XTWINOPS response or
* mode 2048 report
*
* @ingroup terminal
*/
@@ -749,9 +1031,9 @@ typedef void (*GhosttyTerminalPwdChangedFn)(GhosttyTerminal terminal,
* Callback function type for write_pty.
*
* Called when the terminal needs to write data back to the pty, for
* example in response to a device status report or mode query. The
* data is only valid for the duration of the call; callers must copy
* it if it needs to persist.
* example in response to a device status report, mode query, or VT-driven
* mode 2048 enable. The data is only valid for the duration of the call;
* callers must copy it if it needs to persist.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
@@ -818,8 +1100,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Callback invoked when the terminal needs to write data back
* to the pty (e.g. in response to a DECRQM query or device
* status report). Set to NULL to ignore such sequences.
* to the pty (e.g. in response to a DECRQM query, device status
* report, or VT-driven mode 2048 enable). Set to NULL to ignore such
* sequences.
*
* Input type: GhosttyTerminalWritePtyFn
*/
@@ -1062,10 +1345,11 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Callback invoked when the running program performs a clipboard write.
* OSC 52 and iTerm2 OSC 1337 Copy writes are normalized to an atomic set
* of decoded MIME representations. Set to NULL to ignore clipboard writes.
* Clipboard read requests are always ignored; see
* GhosttyTerminalClipboardWriteFn.
* OSC 52, iTerm2 OSC 1337 Copy, and Kitty clipboard (OSC 5522) writes
* are normalized to an atomic set of decoded MIME representations. Set
* to NULL to ignore clipboard writes (Kitty clipboard writes are then
* refused with ENOSYS). Clipboard read requests are delivered to
* GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ instead.
*
* Input type: GhosttyTerminalClipboardWriteFn
*/
@@ -1227,6 +1511,40 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: GhosttyString*
*/
GHOSTTY_TERMINAL_OPT_TERMINFO_NAME = 37,
/**
* Callback invoked when the running program requests clipboard contents
* via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read. The
* read is synchronous and must be answered before the callback returns.
* Set to NULL (the default) to ignore OSC 52 read requests and refuse
* OSC 5522 reads with EPERM.
*
* Input type: GhosttyTerminalClipboardReadFn
*/
GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ = 38,
/**
* Set the maximum total decoded bytes a single Kitty clipboard protocol
* (OSC 5522) write transaction may accumulate. The limit is captured
* when a transaction begins; an in-flight transaction keeps the limit
* it started with.
*
* Data beyond the limit fails the whole transaction with EFBIG. The
* transaction is discarded, later write-related packets are ignored
* until a new write begins, and nothing reaches the clipboard write
* callback.
*
* Transactions are buffered in memory, so this limit bounds how much
* memory a single write can make the terminal allocate. Pass SIZE_MAX
* to remove the limit. A NULL value pointer reverts to the built-in
* default of 64MiB, the minimum required by the protocol.
*
* This limit doesn't apply to OSC 52 writes, which are bounded by the
* maximum length of an escape sequence instead.
*
* Input type: size_t*
*/
GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES = 39,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;
@@ -1624,6 +1942,15 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Output type: bool *
*/
GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT = 39,
/**
* The configured maximum decoded bytes per Kitty clipboard protocol
* (OSC 5522) write transaction. See
* GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES.
*
* Output type: size_t *
*/
GHOSTTY_TERMINAL_DATA_CLIPBOARD_WRITE_MAX_BYTES = 40,
GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalData;

View File

@@ -39,18 +39,21 @@
* The Zig side backs all C enums with c_int, so the C declarations
* must use int as their underlying type to maintain ABI compatibility.
*
* C23 (detected via __STDC_VERSION__ >= 202311L) supports explicit
* enum underlying types with `enum : int { ... }`. For pre-C23
* compilers, which are free to choose any type that can represent
* all values (C11 §6.7.2.2), we add an INT_MAX sentinel as the last
* entry to force the compiler to use int.
* C++11 and C23 support explicit enum underlying types with
* `enum : int { ... }`. Clang and GCC 13+ also support this syntax as
* an extension in older C language modes, so use it when available.
*
* Other pre-C23 C compilers are free to choose any type that can
* represent all values (C11 §6.7.2.2). For those compilers, we add an
* INT_MAX sentinel as the last entry so the compatible type must be
* able to represent INT_MAX. The exact compatible type and its
* signedness remain implementation-defined in this fallback.
*
* INT_MAX is used rather than a fixed constant like 0xFFFFFFFF
* because enum constants must have type int (which is signed).
* Values above INT_MAX overflow signed int and are a constraint
* violation in standard C; compilers that accept them interpret them
* as negative values via two's complement, which can collide with
* legitimate negative enum values.
* because enum constants must have type int in pre-C23 C. Values above
* INT_MAX are a constraint violation there; compilers that accept them
* may interpret them as negative values via two's complement, which can
* collide with legitimate negative enum values.
*
* Usage:
* @code
@@ -61,7 +64,18 @@
* } Foo;
* @endcode
*/
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L
#if defined(__cplusplus) && \
(__cplusplus >= 201103L || (defined(_MSC_VER) && _MSC_VER >= 1700))
#define GHOSTTY_ENUM_TYPED : int
#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L
#define GHOSTTY_ENUM_TYPED : int
#elif defined(__clang__)
#if __has_extension(c_fixed_enum)
#define GHOSTTY_ENUM_TYPED : int
#else
#define GHOSTTY_ENUM_TYPED
#endif
#elif defined(__GNUC__) && __GNUC__ >= 13
#define GHOSTTY_ENUM_TYPED : int
#else
#define GHOSTTY_ENUM_TYPED
@@ -86,6 +100,12 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_IO_ERROR = -5,
/** Operation failed because encoded input exceeded a configured limit */
GHOSTTY_LIMIT_EXCEEDED = -6,
/**
* Operation was rejected by a safety check (e.g. pasted text that could
* inject commands). Nothing was done. Confirm with the user and retry
* with the operation's allow flag set.
*/
GHOSTTY_REJECTED = -7,
GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyResult;
@@ -168,6 +188,19 @@ typedef struct GhosttyRenderStateRowIteratorImpl* GhosttyRenderStateRowIterator;
*/
typedef struct GhosttyRenderStateRowCellsImpl* GhosttyRenderStateRowCells;
/**
* Opaque handle to a terminal search.
*
* A search is bound to the terminal it was created with. It borrows the
* terminal, so it never frees it, and the search must be freed with
* ghostty_search_free(). If the terminal is freed first, the search
* detects this: calls that need the terminal fail cleanly and the
* search can still be freed.
*
* @ingroup search
*/
typedef struct GhosttySearchImpl* GhosttySearch;
/**
* Opaque handle to an SGR parser instance.
*
@@ -305,33 +338,64 @@ typedef struct {
* opts.trim = true;
* @endcode
*/
#ifdef __cplusplus
#define GHOSTTY_INIT_SIZED(type) \
([]() noexcept { \
type value{}; \
value.size = sizeof(value); \
return value; \
}())
#else
#define GHOSTTY_INIT_SIZED(type) \
((type){ .size = sizeof(type) })
#endif
/**
* Return a pointer to a null-terminated JSON string describing the
* layout of every C API struct for the current target.
* Return the versioned libghostty-vt C type manifest for the current target.
*
* This is primarily useful for language bindings that can't easily
* set C struct fields and need to do so via byte offsets. For example,
* WebAssembly modules can't share struct definitions with the host.
* The manifest defines all the public types available in the linked
* build. The types contain their layouts, enum values, union fields, and more.
*
* Language bindings, such as WebAssembly hosts, should obtain offsets,
* sizes, alignments, array shapes, enum constants, and tagged-union arms from
* this manifest rather than hardcoding them. Consumers should reject unknown
* schema versions and verify the descriptors they require at initialization.
*
* Packed type descriptors define fields using `lsb` and `width`. `lsb` is
* relative to bit zero of the containing numerical value; for nested packed
* layouts it is relative to the immediate containing field. Tagged packed
* unions select an inline arm layout using the named tag field. These layouts
* describe the current linked build and are not a cross-version stability
* promise.
*
* The formal format is defined by the
* <a href="types.schema.json">libghostty-vt ABI manifest JSON Schema</a>.
*
* Example (abbreviated):
* @code{.json}
* {
* "GhosttyMouseEncoderSize": {
* "size": 40,
* "align": 8,
* "fields": {
* "size": { "offset": 0, "size": 8, "type": "u64" },
* "screen_width": { "offset": 8, "size": 4, "type": "u32" },
* "screen_height": { "offset": 12, "size": 4, "type": "u32" },
* "cell_width": { "offset": 16, "size": 4, "type": "u32" },
* "cell_height": { "offset": 20, "size": 4, "type": "u32" },
* "padding_top": { "offset": 24, "size": 4, "type": "u32" },
* "padding_bottom": { "offset": 28, "size": 4, "type": "u32" },
* "padding_right": { "offset": 32, "size": 4, "type": "u32" },
* "padding_left": { "offset": 36, "size": 4, "type": "u32" }
* "schema": 1,
* "abi": {
* "target": "wasm32", "os": "freestanding", "environment": "none",
* "pointer_size": 4, "usize_size": 4, "max_alignment": 16,
* "endian": "little"
* },
* "types": {
* "GhosttyRenderStateData": {
* "kind": "enum", "size": 4, "align": 4,
* "underlying": "i32", "prefix": "GHOSTTY_RENDER_STATE_DATA_",
* "values": { "INVALID": 0, "DIRTY": 3, "MAX_VALUE": 2147483647 }
* },
* "GhosttyStyleColor": {
* "kind": "struct", "size": 16, "align": 8,
* "fields": {
* "tag": { "offset": 0, "size": 4,
* "type": "GhosttyStyleColorTag" },
* "value": { "offset": 8, "size": 8,
* "type": "GhosttyStyleColorValue", "tag": "tag",
* "arms": { "NONE": null, "PALETTE": "palette",
* "RGB": "rgb" } }
* }
* }
* }
* }

View File

@@ -10,12 +10,12 @@
#ifdef __wasm__
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/types.h>
/** @defgroup wasm WebAssembly Utilities
*
* Convenience functions for allocating various types in WebAssembly builds.
* Convenience functions for working with the low-level C ABI in WebAssembly
* builds.
* **These are only available the libghostty-vt wasm module.**
*
* Ghostty relies on pointers to various types for ABI compatibility, and
@@ -28,37 +28,64 @@
* your custom allocator. This is a very rare use case in the WebAssembly
* world so these are optimized for simplicity.
*
* Use ghostty_wasm_alloc() and ghostty_wasm_free() for host-owned scratch
* buffers and ABI values. Dynamic-language hosts can use ghostty_type_json()
* to discover pointer and size_t widths, maximum alignment, byte order, and
* the size and alignment of public C structs. Do not mix allocation families:
* buffers returned by libghostty-vt allocating APIs must still be released
* with ghostty_free(), and opaque handles must be released with their
* type-specific destructor.
*
* ## Memory growth
*
* An exported function may grow Wasm linear memory when it allocates. Numeric
* pointers and handles remain valid, but JavaScript ArrayBuffer, DataView, and
* typed-array objects created before the growth may no longer cover the live
* memory. Reacquire `exports.memory.buffer` immediately before every host-side
* memory access. A host that caches views should recreate them whenever either
* the buffer identity or its byte length changes.
*
* ## Example Usage
*
* Here's a simple example of using the Wasm utilities with the key encoder:
* Here's a simple example that creates a terminal, writes bytes, and safely
* handles memory growth:
*
* @code
* const { exports } = wasmInstance;
* const view = new DataView(wasmMemory.buffer);
* const memory = exports.memory;
* let cachedBuffer = null;
* let cachedLength = 0;
* let cachedBytes = null;
*
* // Create key encoder
* const encoderPtr = exports.ghostty_wasm_alloc_opaque();
* exports.ghostty_key_encoder_new(null, encoderPtr);
* const encoder = view.getUint32(encoder, true);
* function bytes() {
* const buffer = memory.buffer;
* if (buffer !== cachedBuffer || buffer.byteLength !== cachedLength) {
* cachedBuffer = buffer;
* cachedLength = buffer.byteLength;
* cachedBytes = new Uint8Array(buffer);
* }
* return cachedBytes;
* }
*
* // Configure encoder with Kitty protocol flags
* const flagsPtr = exports.ghostty_wasm_alloc_u8();
* view.setUint8(flagsPtr, 0x1F);
* exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr);
* function check(result) {
* if (result !== 0) throw new Error(`libghostty-vt error: ${result}`);
* }
*
* // Allocate output buffer and size pointer
* const bufferSize = 32;
* const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize);
* const writtenPtr = exports.ghostty_wasm_alloc_usize();
* // One slot can be reused for every opaque-handle constructor.
* const slot = exports.ghostty_wasm_alloc_opaque();
* if (slot === 0) throw new Error("out of memory");
* check(exports.ghostty_terminal_new(0, slot, 80, 24));
* const terminal = exports.ghostty_wasm_take_opaque(slot);
*
* // Encode the key event
* exports.ghostty_key_encoder_encode(
* encoder, eventPtr, bufPtr, bufferSize, writtenPtr
* );
* const input = new TextEncoder().encode("Hello, world!");
* const inputPtr = exports.ghostty_wasm_alloc(input.length);
* if (inputPtr === 0) throw new Error("out of memory");
* bytes().set(input, inputPtr); // Acquires the current memory after alloc.
* exports.ghostty_terminal_vt_write(terminal, inputPtr, input.length);
*
* // Read encoded output
* const bytesWritten = view.getUint32(writtenPtr, true);
* const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten);
* exports.ghostty_wasm_free(inputPtr, input.length);
* exports.ghostty_terminal_free(terminal);
* exports.ghostty_wasm_free_opaque(slot);
* @endcode
*
* @remark The code above is pretty ugly! This is the lowest level interface
@@ -68,9 +95,36 @@
* @{
*/
/**
* Allocate caller-owned storage for a Wasm ABI value or scratch buffer.
*
* The returned address is aligned to the target's maximum C ABI alignment,
* reported as `abi.max_alignment` by ghostty_type_json(). The memory is
* uninitialized. A zero-length request returns NULL.
*
* The returned pointer must be released with ghostty_wasm_free() using the
* exact same length.
*
* @param len Number of bytes to allocate
* @return Pointer to allocated storage, or NULL if len is zero or allocation
* failed
* @ingroup wasm
*/
GHOSTTY_API void* ghostty_wasm_alloc(size_t len);
/**
* Free storage allocated by ghostty_wasm_alloc().
*
* @param ptr Pointer to free, or NULL (NULL is safely ignored)
* @param len Original allocation length passed to ghostty_wasm_alloc()
* @ingroup wasm
*/
GHOSTTY_API void ghostty_wasm_free(void *ptr, size_t len);
/**
* Allocate an opaque pointer. This can be used for any opaque pointer
* types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc.
* types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. The allocated slot
* is initialized to NULL and may be reused across constructors.
*
* @return Pointer to allocated opaque pointer, or NULL if allocation failed
* @ingroup wasm
@@ -86,72 +140,18 @@ GHOSTTY_API void** ghostty_wasm_alloc_opaque(void);
GHOSTTY_API void ghostty_wasm_free_opaque(void **ptr);
/**
* Allocate an array of uint8_t values.
* Take an opaque handle from an out-parameter slot.
*
* @param len Number of uint8_t elements to allocate
* @return Pointer to allocated array, or NULL if allocation failed
* Returns the handle currently stored in @p slot and resets the slot to NULL.
* This function does not allocate, free the returned handle, or free the slot.
* Always check the GhosttyResult returned by the function that populated the
* slot before calling this function.
*
* @param slot Pointer to an opaque out-parameter slot, or NULL
* @return Stored opaque handle, or NULL if slot or its value is NULL
* @ingroup wasm
*/
GHOSTTY_API uint8_t* ghostty_wasm_alloc_u8_array(size_t len);
/**
* Free an array allocated by ghostty_wasm_alloc_u8_array().
*
* @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
* @param len Length of the array (must match the length passed to alloc)
* @ingroup wasm
*/
GHOSTTY_API void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len);
/**
* Allocate an array of uint16_t values.
*
* @param len Number of uint16_t elements to allocate
* @return Pointer to allocated array, or NULL if allocation failed
* @ingroup wasm
*/
GHOSTTY_API uint16_t* ghostty_wasm_alloc_u16_array(size_t len);
/**
* Free an array allocated by ghostty_wasm_alloc_u16_array().
*
* @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
* @param len Length of the array (must match the length passed to alloc)
* @ingroup wasm
*/
GHOSTTY_API void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len);
/**
* Allocate a single uint8_t value.
*
* @return Pointer to allocated uint8_t, or NULL if allocation failed
* @ingroup wasm
*/
GHOSTTY_API uint8_t* ghostty_wasm_alloc_u8(void);
/**
* Free a uint8_t allocated by ghostty_wasm_alloc_u8().
*
* @param ptr Pointer to free, or NULL (NULL is safely ignored)
* @ingroup wasm
*/
GHOSTTY_API void ghostty_wasm_free_u8(uint8_t *ptr);
/**
* Allocate a single size_t value.
*
* @return Pointer to allocated size_t, or NULL if allocation failed
* @ingroup wasm
*/
GHOSTTY_API size_t* ghostty_wasm_alloc_usize(void);
/**
* Free a size_t allocated by ghostty_wasm_alloc_usize().
*
* @param ptr Pointer to free, or NULL (NULL is safely ignored)
* @ingroup wasm
*/
GHOSTTY_API void ghostty_wasm_free_usize(size_t *ptr);
GHOSTTY_API void* ghostty_wasm_take_opaque(void **slot);
/** @} */

View File

@@ -226,7 +226,7 @@
</parameter>
</command>
<command name="send key" code="GhstSKey" description="Send a keyboard event to a terminal.">
<command name="send key" code="GhstSKey" description="Send a keyboard event to a terminal. Keys are translated once through the current keyboard layout. Dead keys do not compose with subsequent keys.">
<cocoa class="GhosttyScriptKeyEventCommand"/>
<direct-parameter type="text" description="The key name (e.g. &quot;enter&quot;, &quot;a&quot;, &quot;space&quot;)."/>
<parameter name="action" code="GKeA" type="input action" optional="yes" description="Press or release (default: press).">

View File

@@ -1099,8 +1099,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.5.1;
kind = exactVersion;
version = 2.9.6;
};
};
/* End XCRemoteSwiftPackageReference section */

View File

@@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "21d8df80440b1ca3b65fa82e40782f1e5a9e6ba2",
"version" : "2.9.0"
"revision" : "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a",
"version" : "2.9.6"
}
}
],

View File

@@ -263,6 +263,12 @@ class AppDelegate: NSObject,
name: .ghosttyConfigDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardSelectionDidChange(_:)),
name: NSTextInputContext.keyboardSelectionDidChangeNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(ghosttyBellDidRing(_:)),
@@ -649,6 +655,11 @@ class AppDelegate: NSObject,
ghosttyConfigDidChange(config: config)
}
@MainActor @objc private func keyboardSelectionDidChange(_ notification: Notification) {
syncMenuShortcuts(ghostty.config)
TerminalController.all.forEach { $0.relabelTabs() }
}
@objc private func ghosttyBellDidRing(_ notification: Notification) {
if ghostty.config.bellFeatures.contains(.system) {
NSSound.beep()
@@ -1335,13 +1346,9 @@ extension AppDelegate {
return .terminateLater
} else {
let alert = NSAlert()
alert.messageText = "You have \(controllersNeedConfirmation.count) windows with running processes. Do you want to review these windows before quitting?"
alert.informativeText = "If you don't review your windows, any running processes will be terminated"
alert.addButton(withTitle: "Review Windows...")
alert.addButton(withTitle: "Terminate Processes")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
let alert = NSAlert.reviewWindowsAlert(
messageText: "You have \(controllersNeedConfirmation.count) windows with running processes. Do you want to review these windows before quitting?"
)
switch alert.runModal() {
case .alertFirstButtonReturn:

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24765" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="25095" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24765"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="25095"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
@@ -99,6 +99,7 @@
<action selector="openConfig:" target="bbz-4X-AYv" id="X65-fg-iWU"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="GOd-qX-exa"/>
<menuItem title="Reload Configuration" id="KKH-XX-5py">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>

View File

@@ -39,6 +39,6 @@ struct CyclingIconView: View {
iconImage
.resizable()
.aspectRatio(contentMode: .fit)
.scaledToFit()
}
}

View File

@@ -74,7 +74,7 @@ struct NewTerminalIntent: AppIntent {
// If we were given a working directory then open that directory
if let url = workingDirectory?.fileURL {
let dir = url.hasDirectoryPath ? url : url.deletingLastPathComponent()
config.workingDirectory = dir.path(percentEncoded: false)
config.workingDirectory = dir.pathWithoutTrailingSlash
}
// Parse environment variables from KEY=VALUE format

View File

@@ -11,12 +11,6 @@ final class ScriptKeyEventCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let keyName = directParameter as? String else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing key name."
return nil
}
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
@@ -35,10 +29,63 @@ final class ScriptKeyEventCommand: NSScriptCommand {
return nil
}
guard let key = Ghostty.Input.Key(rawValue: keyName) else {
let keyEvent: Ghostty.Input.KeyEvent
do {
keyEvent = try Self.parse(
directParameter: directParameter,
evaluatedArguments: evaluatedArguments,
translationMods: surface.keyTranslationMods,
)
} catch ArgumentError.missingKey {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing key name."
return nil
} catch let ArgumentError.unknownKey(keyName) {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown key name: \(keyName)"
return nil
} catch let ArgumentError.unknownModifiers(modsString) {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown modifier in: \(modsString)"
return nil
} catch {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Invalid key event."
return nil
}
surface.sendKeyEvent(keyEvent)
return nil
}
}
extension ScriptKeyEventCommand {
enum ArgumentError: Error, Equatable {
case missingKey
case unknownKey(String)
case unknownModifiers(String)
}
/// Parse the scripting arguments for `send key` into the key event to
/// deliver to the surface.
///
/// - Parameters:
/// - directParameter: The command's direct parameter (the key name).
/// - evaluatedArguments: The command's evaluated arguments.
/// - translationMods: Maps the event's modifiers to the subset that
/// participates in text translation for the target surface.
static func parse(
directParameter: Any?,
evaluatedArguments: [String: Any]?,
translationMods: (Ghostty.Input.Mods) -> Ghostty.Input.Mods = { $0 },
) throws -> Ghostty.Input.KeyEvent {
guard let keyName = directParameter as? String else {
throw ArgumentError.missingKey
}
guard let key = Ghostty.Input.Key(rawValue: keyName) else {
throw ArgumentError.unknownKey(keyName)
}
let action: Ghostty.Input.Action
@@ -55,22 +102,18 @@ final class ScriptKeyEventCommand: NSScriptCommand {
let mods: Ghostty.Input.Mods
if let modsString = evaluatedArguments?["modifiers"] as? String {
guard let parsed = Ghostty.Input.Mods(scriptModifiers: modsString) else {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown modifier in: \(modsString)"
return nil
throw ArgumentError.unknownModifiers(modsString)
}
mods = parsed
} else {
mods = []
}
let keyEvent = Ghostty.Input.KeyEvent(
key: key,
return Ghostty.Input.KeyEvent(
synthesizing: key,
action: action,
mods: mods
mods: mods,
translationMods: translationMods(mods),
)
surface.sendKeyEvent(keyEvent)
return nil
}
}

View File

@@ -47,13 +47,16 @@ class ClipboardConfirmationController: NSWindowController {
switch confirmation.kind {
case .paste:
window.title = "Warning: Potentially Unsafe Paste"
case .osc_52_read, .osc_52_write:
case .osc_52_read, .osc_52_write, .kitty_read, .kitty_write:
window.title = "Authorize Clipboard Access"
}
window.contentView = NSHostingView(rootView: ClipboardConfirmationView(
contents: confirmation.contents,
request: confirmation.kind,
programName: confirmation.programName,
canRemember: confirmation.canRemember,
previewImage: confirmation.previewImage,
delegate: delegate
))
}

View File

@@ -2,7 +2,7 @@ import SwiftUI
/// This delegate is notified of the completion result of the clipboard confirmation dialog.
protocol ClipboardConfirmationViewDelegate: AnyObject {
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action)
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, remember: Bool)
}
/// The SwiftUI view for showing a clipboard confirmation dialog.
@@ -15,11 +15,13 @@ struct ClipboardConfirmationView: View {
switch (action, reason) {
case (.cancel, .paste):
return "Cancel"
case (.cancel, .osc_52_read), (.cancel, .osc_52_write):
case (.cancel, .osc_52_read), (.cancel, .osc_52_write),
(.cancel, .kitty_read), (.cancel, .kitty_write):
return "Deny"
case (.confirm, .paste):
return "Paste"
case (.confirm, .osc_52_read), (.confirm, .osc_52_write):
case (.confirm, .osc_52_read), (.confirm, .osc_52_write),
(.confirm, .kitty_read), (.confirm, .kitty_write):
return "Allow"
}
}
@@ -31,9 +33,24 @@ struct ClipboardConfirmationView: View {
/// The type of the clipboard request
let request: Ghostty.ClipboardRequest
/// The human friendly name of the requesting program, when the
/// protocol carries one.
var programName: String?
/// True when the user's decision may be remembered as a session
/// grant, showing the remember toggle.
var canRemember: Bool = false
/// An image decoded from the request contents, shown scaled in
/// place of most of the text area when present.
var previewImage: NSImage?
/// Optional delegate to get results. If this is nil, then this view will never close on its own.
weak var delegate: ClipboardConfirmationViewDelegate?
/// Whether the user's decision should be remembered for the session.
@State private var remember: Bool = false
/// Used to track if we should rehide on disappear
@State private var cursorHiddenCount: UInt = 0
@@ -46,14 +63,27 @@ struct ClipboardConfirmationView: View {
.padding()
.frame(alignment: .center)
Text(request.text())
Text(request.text(name: programName))
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
TextEditor(text: .constant(contents))
.focusable(false)
.font(.system(.body, design: .monospaced))
if let previewImage {
Image(nsImage: previewImage)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal)
} else {
TextEditor(text: .constant(contents))
.focusable(false)
.font(.system(.body, design: .monospaced))
}
if canRemember {
Toggle("Remember this choice for the session", isOn: $remember)
.padding(.top, 4)
}
HStack {
Spacer()
@@ -87,10 +117,10 @@ struct ClipboardConfirmationView: View {
}
private func onCancel() {
delegate?.clipboardConfirmationComplete(.cancel)
delegate?.clipboardConfirmationComplete(.cancel, remember: false)
}
private func onPaste() {
delegate?.clipboardConfirmationComplete(.confirm)
delegate?.clipboardConfirmationComplete(.confirm, remember: remember)
}
}

View File

@@ -20,7 +20,7 @@ struct CommandOption: Identifiable, Hashable {
/// Whether to visually emphasize this option.
let emphasis: Bool
/// Sort key for stable ordering when titles are equal.
let sortKey: AnySortKey?
let sortKey: ObjectIdentifier?
/// The action to perform when this option is selected.
let action: () -> Void
@@ -33,7 +33,7 @@ struct CommandOption: Identifiable, Hashable {
leadingColor: Color? = nil,
badge: String? = nil,
emphasis: Bool = false,
sortKey: AnySortKey? = nil,
sortKey: ObjectIdentifier? = nil,
action: @escaping () -> Void
) {
self.title = title

View File

@@ -1,6 +1,21 @@
import SwiftUI
import GhosttyKit
func sortedTerminalPaletteOptions(_ options: [CommandOption]) -> [CommandOption] {
options.sorted { lhs, rhs in
let lhsTitle = lhs.title.replacingOccurrences(of: ":", with: "\t")
let rhsTitle = rhs.title.replacingOccurrences(of: ":", with: "\t")
let comparison = lhsTitle.localizedCaseInsensitiveCompare(rhsTitle)
if comparison != .orderedSame {
return comparison == .orderedAscending
}
if let lhsKey = lhs.sortKey, let rhsKey = rhs.sortKey {
return lhsKey < rhsKey
}
return false
}
}
struct TerminalCommandPaletteView: View {
/// The surface that this command palette represents.
let surfaceView: Ghostty.SurfaceView
@@ -64,19 +79,7 @@ struct TerminalCommandPaletteView: View {
// Sort the rest. We replace ":" with a character that sorts before space
// so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker
// for stable ordering when titles are equal.
options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in
let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t")
let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t")
let comparison = aNormalized.localizedCaseInsensitiveCompare(bNormalized)
if comparison != .orderedSame {
return comparison == .orderedAscending
}
// Tie-breaker: use sortKey if both have one
if let aSortKey = a.sortKey, let bSortKey = b.sortKey {
return aSortKey < bSortKey
}
return false
})
options.append(contentsOf: sortedTerminalPaletteOptions(jumpOptions + terminalOptions))
return options
}
@@ -168,7 +171,7 @@ struct TerminalCommandPaletteView: View {
subtitle: subtitle,
leadingIcon: "rectangle.on.rectangle",
leadingColor: displayColor?.displayColor.map { Color($0) },
sortKey: AnySortKey(ObjectIdentifier(surface))
sortKey: ObjectIdentifier(surface)
) {
NotificationCenter.default.post(
name: Ghostty.Notification.ghosttyPresentTerminal,

View File

@@ -159,8 +159,6 @@ class QuickTerminalController: BaseTerminalController {
// applies if we can be seen.
guard visible else { return }
terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: true)
// Re-hide the dock if we were hiding it before.
hiddenDock?.hide()
}
@@ -174,8 +172,6 @@ class QuickTerminalController: BaseTerminalController {
// ensures we don't run logic twice.
guard visible else { return }
terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: false)
// We don't animate out if there is a modal sheet being shown currently.
// This lets us show alerts without causing the window to disappear.
guard window?.attachedSheet == nil else { return }

View File

@@ -15,7 +15,7 @@ struct SecureInputOverlay: View {
Image(systemName: "lock.shield.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.scaledToFit()
.frame(width: 25, height: 25)
.foregroundColor(.primary)
.padding(5)

View File

@@ -59,7 +59,7 @@ class ServiceProvider: NSObject {
for url in directoryURLs {
var config = Ghostty.SurfaceConfiguration()
config.workingDirectory = url.path(percentEncoded: false)
config.workingDirectory = url.pathWithoutTrailingSlash
switch target {
case .window:

View File

@@ -1462,7 +1462,7 @@ class BaseTerminalController: NSWindowController,
}
@IBAction func findPrevious(_ sender: Any) {
focusedSurface?.findNext(sender)
focusedSurface?.findPrevious(sender)
}
@IBAction func findHide(_ sender: Any) {
@@ -1665,7 +1665,7 @@ extension BaseTerminalController {
target.pendingClipboardConfirmation = nil
}
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action) {
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, remember: Bool) {
// End our clipboard confirmation no matter what
guard let cc = self.clipboardConfirmation else { return }
dismissClipboardConfirmation(cc)
@@ -1674,7 +1674,7 @@ extension BaseTerminalController {
case .cancel:
cc.confirmation.cancel()
case .confirm:
cc.confirmation.complete()
cc.confirmation.complete(remember: remember)
}
// Clear only if this is still the surface's current request. Completing

View File

@@ -48,7 +48,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
/// The initial window presentation is deferred by one runloop turn in a few places so
/// AppKit can settle tab/window state first. Close actions must cancel it to avoid
/// re-showing a tab that was already closed.
/// re-showing a tab/window that was already closed.
private var pendingInitialPresentation: DispatchWorkItem?
/// This is set to false by init if the window managed by this controller should not be restorable.
@@ -283,17 +283,25 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
}
// We're dispatching this async because otherwise the lastCascadePoint doesn't
// take effect. Our best theory is there is some next-event-loop-tick logic
// that Cocoa is doing that we need to be after.
c.scheduleInitialPresentation {
c.showWindow(self)
// We're dispatching this async because in some cases AppKit will tab this window,
// although we have a check in `windowDidLoad` and it works in most cases, but not for AppIntent
//
// That weird tabbing behavior only happens in the following cases at the point of writing.
// - Creating a window via the Shortcuts app for now.
// - Creating a window via `New Ghostty Window Here` service.
c.showWindowSafely(self)
// Only cascade if we aren't fullscreen.
if let window = c.window {
if !window.styleMask.contains(.fullScreen) {
let hasFixedPos = c.derivedConfig.windowPositionX != nil && c.derivedConfig.windowPositionY != nil
Self.applyCascade(to: window, hasFixedPos: hasFixedPos)
// We're dispatching this async because otherwise the lastCascadePoint doesn't
// take effect after positioning in `showWindow`. Our best theory is there is
// some next-event-loop-tick logic that Cocoa is doing that we need to be after.
DispatchQueue.main.async {
Self.applyCascade(to: window, hasFixedPos: hasFixedPos)
}
}
}
@@ -353,8 +361,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
c.isBackgroundOpaque = inheritBackgroundOpacity
}
// Showing window in current event loop works so far with dragging surface into
// a new window, but remember to defer the cascade when you move it inside
// `scheduleInitialPresentation` to solve other issues in the future.
c.showWindowSafely(self)
c.scheduleInitialPresentation {
c.showWindow(self)
if let window = c.window {
// If we have a tree size, resize the window's content to match
if let treeSize, treeSize.width > 0, treeSize.height > 0 {
@@ -453,26 +464,44 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// If we don't allow tabs then we create a new window instead.
if window.tabbingMode != .disallowed {
let tabCreated: Bool
// Add the window to the tab group and show it.
switch ghostty.config.windowNewTabPosition {
case "end":
// If we already have a tab group and we want the new tab to open at the end,
// then we use the last window in the tab group as the parent.
if let last = parent.tabGroup?.windows.last {
last.addTabbedWindowSafely(window, ordered: .above)
tabCreated = last.addTabbedWindowSafely(window, ordered: .above)
} else {
fallthrough
}
case "current": fallthrough
default:
parent.addTabbedWindowSafely(window, ordered: .above)
tabCreated = parent.addTabbedWindowSafely(window, ordered: .above)
}
if tabCreated {
// We set the selectedWindow early here because we want the next window
// to become first responder as quickly as possible. Usually this is
// set while `-[NSWindowController showWindow:]` is called, but we're
// dispatching it to resolve other issues.
parent.tabGroup?.selectedWindow = window
}
}
// showWindow makes regular windows key and ordered front. AppKit can
// throw while selecting a tab if its fullscreen stack is inconsistent,
// so this must cross the Objective-C exception bridge.
// We don't need to dispatch this because `tabbingMode = .disallowed`
// for HiddenTitlebarTerminalWindow.
controller.showWindowSafely(self)
// Windows with `macos-titlebar-style = hidden` create new windows when the
// new tab binding is pressed, we should cascade those windows as well.
// We're dispatching this async because otherwise the lastCascadePoint doesn't
// take effect. Our best theory is there is some next-event-loop-tick logic
// that Cocoa is doing that we need to be after.
// take effect after position in `showWindow`. Our best theory is there is some
// next-event-loop-tick logic that Cocoa is doing that we need to be after.
controller.scheduleInitialPresentation {
// Only cascade if we aren't fullscreen and are alone in the tab group.
if !window.styleMask.contains(.fullScreen) &&
@@ -481,11 +510,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
Self.applyCascade(to: window, hasFixedPos: hasFixedPos)
}
// showWindow makes regular windows key and ordered front. AppKit can
// throw while selecting a tab if its fullscreen stack is inconsistent,
// so this must cross the Objective-C exception bridge.
controller.showWindowSafely(self)
// We also activate our app so that it becomes front. This may be
// necessary for the dock menu.
NSApp.activate(ignoringOtherApps: true)
@@ -1239,12 +1263,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
super.windowDidBecomeKey(notification)
self.relabelTabs()
self.fixTabBar()
terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: true)
}
override func windowDidResignKey(_ notification: Notification) {
super.windowDidResignKey(notification)
terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: false)
}
override func windowDidMove(_ notification: Notification) {
@@ -1384,21 +1402,58 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// if we're closing the window. If we don't have a tabgroup for any
// reason we check ourselves.
let windows: [NSWindow] = window.tabGroup?.windows ?? [window]
guard let confirmController = windows
let confirmControllers = windows
.compactMap({ $0.windowController as? TerminalController })
.first(where: { $0.surfaceTree.contains(where: { $0.needsConfirmQuit }) })
.filter({ $0.surfaceTree.contains(where: { $0.needsConfirmQuit }) })
guard
!confirmControllers.isEmpty
else {
closeWindowImmediately()
return
}
if confirmControllers.count == 1 {
// We call confirmClose on the proper controller so the alert is
// attached to the window that needs confirmation.
confirmControllers[0].confirmClose(
messageText: "Close Window?",
informativeText: "All terminal sessions in this window will be terminated.",
) {
self.closeWindowImmediately()
}
return
}
// We call confirmClose on the proper controller so the alert is
// attached to the window that needs confirmation.
confirmController.confirmClose(
messageText: "Close Window?",
informativeText: "All terminal sessions in this window will be terminated.",
) {
self.closeWindowImmediately()
Task {
let alert = NSAlert.reviewWindowsAlert(
messageText: "You have \(confirmControllers.count) windows with running processes. Do you want to review these windows before closing?",
terminateNowButtonTitle: "Close"
)
switch await alert.beginSheetModal(for: window) {
case .alertFirstButtonReturn:
await reviewWindows(confirmControllers, window: window)
case .alertSecondButtonReturn:
closeWindowImmediately()
default:
break
}
}
}
private func reviewWindows(_ controllers: [TerminalController], window: NSWindow) async {
for controller in controllers {
let response = await controller.confirmCloseAsync(
messageText: "Close Window?",
informativeText: "All terminal sessions in this window will be terminated.",
)
if [.OK, .alertFirstButtonReturn].contains(response) {
// Close this tab
controller.closeTabImmediately()
continue
} else {
// Cancel the review
return
}
}
}

View File

@@ -6,7 +6,7 @@ import SwiftUI
class TerminalViewContainer: NSView {
private let terminalView: NSView
/// Combined glass effect and inactive tint overlay view
/// Background color applied with glass effect
private(set) var glassEffectView: NSView?
private var derivedConfig: DerivedConfig?
@@ -78,7 +78,14 @@ class TerminalViewContainer: NSView {
let newValue = DerivedConfig(config: config, preferredBackgroundColor: preferredBackgroundColor, cornerRadius: windowCornerRadius)
guard newValue != derivedConfig else { return }
derivedConfig = newValue
DispatchQueue.main.async(execute: updateGlassEffectIfNeeded)
// Attach the glass effect synchronously if missing to prevent flicker when a new tab appears.
// Existing updates remain deferred, as they can occur during SwiftUI rendering.
if glassEffectView == nil {
updateGlassEffectIfNeeded()
} else {
DispatchQueue.main.async(execute: updateGlassEffectIfNeeded)
}
}
}
@@ -96,14 +103,41 @@ extension BaseTerminalController {
/// an inactive-window tint overlay.
#if compiler(>=6.2)
@available(macOS 26.0, *)
private class TerminalGlassView: NSView {
private let glassEffectView: NSGlassEffectView
private class TerminalGlassView: NSView, ObservableObject {
/// We use this to apply glass effect to background colors
///
struct GlassBackground: View {
@ObservedObject var model: GlassViewModel
var body: some View {
model.color
.glassEffect(
model.glass,
in: RoundedRectangle(cornerRadius: model.cornerRadius)
)
}
}
class GlassViewModel: ObservableObject {
@Published var backgroundColor: Color = .clear
@Published var backgroundOpacity: Double = 0
@Published var cornerRadius: CGFloat = 0
@Published var glass: Glass = .identity
/// backgroundColor applied with backgroundOpacity
var color: Color {
backgroundColor.opacity(backgroundOpacity)
}
}
private let glassEffectView: NSView
private var topConstraint: NSLayoutConstraint!
private let tintOverlay: NSView
private let glassViewModel: GlassViewModel
init(topOffset: CGFloat) {
self.glassEffectView = NSGlassEffectView()
self.tintOverlay = NSView()
let viewModel = GlassViewModel()
self.glassEffectView = NSHostingView(rootView: GlassBackground(model: viewModel))
self.glassViewModel = viewModel
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
@@ -121,19 +155,6 @@ private class TerminalGlassView: NSView {
glassEffectView.bottomAnchor.constraint(equalTo: bottomAnchor),
glassEffectView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
// Tint overlay sits above the glass effect.
tintOverlay.translatesAutoresizingMaskIntoConstraints = false
tintOverlay.wantsLayer = true
tintOverlay.alphaValue = 0
addSubview(tintOverlay, positioned: .above, relativeTo: glassEffectView)
NSLayoutConstraint.activate([
tintOverlay.topAnchor.constraint(equalTo: glassEffectView.topAnchor),
tintOverlay.leadingAnchor.constraint(equalTo: glassEffectView.leadingAnchor),
tintOverlay.bottomAnchor.constraint(equalTo: glassEffectView.bottomAnchor),
tintOverlay.trailingAnchor.constraint(equalTo: glassEffectView.trailingAnchor),
])
}
@available(*, unavailable)
@@ -141,19 +162,17 @@ private class TerminalGlassView: NSView {
fatalError("init(coder:) has not been implemented")
}
/// Configures the glass effect style, tint color, corner radius, and
/// updates the inactive tint overlay based on window key status.
/// Configures the glass, tint color, corner radius.
func configure(
style: NSGlassEffectView.Style,
glass: Glass,
backgroundColor: NSColor,
backgroundOpacity: Double,
cornerRadius: CGFloat?,
isKeyWindow: Bool
) {
glassEffectView.style = style
glassEffectView.tintColor = backgroundColor.withAlphaComponent(backgroundOpacity)
glassEffectView.cornerRadius = cornerRadius ?? 0
updateKeyStatus(isKeyWindow, backgroundColor: backgroundColor)
glassViewModel.backgroundColor = Color(backgroundColor)
glassViewModel.backgroundOpacity = backgroundOpacity
glassViewModel.cornerRadius = cornerRadius ?? 0
glassViewModel.glass = glass
}
/// Updates the top inset offset for both the glass effect and tint overlay.
@@ -161,21 +180,6 @@ private class TerminalGlassView: NSView {
func updateTopInset(_ offset: CGFloat) {
topConstraint.constant = offset
}
/// Updates the tint overlay visibility based on window key status.
func updateKeyStatus(_ isKeyWindow: Bool, backgroundColor: NSColor) {
let tint = tintProperties(for: backgroundColor)
tintOverlay.layer?.backgroundColor = tint.color.cgColor
tintOverlay.alphaValue = isKeyWindow ? 0 : tint.opacity
}
/// Computes a saturation-boosted tint color and opacity for the inactive overlay.
private func tintProperties(for color: NSColor) -> (color: NSColor, opacity: CGFloat) {
let isLight = color.isLightColor
let vibrant = color.adjustingSaturation(by: 1.2)
let overlayOpacity: CGFloat = isLight ? 0.35 : 0.85
return (vibrant, overlayOpacity)
}
}
#endif // compiler(>=6.2)
@@ -215,11 +219,10 @@ extension TerminalViewContainer {
}
effectView.configure(
style: derivedConfig.style.official,
glass: derivedConfig.glass.official,
backgroundColor: derivedConfig.backgroundColor,
backgroundOpacity: derivedConfig.backgroundOpacity,
cornerRadius: derivedConfig.cornerRadius,
isKeyWindow: window?.isKeyWindow ?? true
)
#endif // compiler(>=6.2)
}
@@ -237,21 +240,8 @@ extension TerminalViewContainer {
#endif // compiler(>=6.2)
}
func updateGlassTintOverlay(isKeyWindow: Bool) {
#if compiler(>=6.2)
guard
#available(macOS 26.0, *),
let effectView = glassEffectView as? TerminalGlassView,
let derivedConfig
else {
return
}
effectView.updateKeyStatus(isKeyWindow, backgroundColor: derivedConfig.backgroundColor)
#endif // compiler(>=6.2)
}
struct DerivedConfig: Equatable {
let style: BackportNSGlassStyle
let glass: BackportGlass
let backgroundColor: NSColor
let backgroundOpacity: Double
let cornerRadius: CGFloat?
@@ -259,9 +249,9 @@ extension TerminalViewContainer {
init?(config: Ghostty.Config, preferredBackgroundColor: NSColor?, cornerRadius: CGFloat?) {
switch config.backgroundBlur {
case .macosGlassRegular:
style = .regular
glass = .regular
case .macosGlassClear:
style = .clear
glass = .clear
default:
return nil
}

View File

@@ -157,7 +157,7 @@ class TerminalWindow: NSWindow {
// Setup the accessory view for tabs that shows our keyboard shortcuts,
// zoomed state, etc. Note I tried to use SwiftUI here but ran into issues
// where buttons were not clickable.
// where buttons were not clickable on macOS 15.
tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor)
let stackView = NSStackView()

View File

@@ -57,7 +57,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
super.becomeKey()
updateNewTabButtonOpacity()
resetZoomToolbarButton.contentTintColor = .controlAccentColor
tab.attributedTitle = attributedTitle
}
@@ -65,7 +64,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
super.resignKey()
updateNewTabButtonOpacity()
resetZoomToolbarButton.contentTintColor = .tertiaryLabelColor
tab.attributedTitle = attributedTitle
}
@@ -236,42 +234,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
}
}
// MARK: - Split Zoom Button
private lazy var resetZoomToolbarButton: NSButton = generateResetZoomButton()
private func generateResetZoomButton() -> NSButton {
let button = NSButton()
button.target = nil
button.action = #selector(TerminalController.splitZoom(_:))
button.isBordered = false
button.allowsExpansionToolTips = true
button.toolTip = "Reset Zoom"
button.contentTintColor = .controlAccentColor
button.state = .on
button.image = NSImage(named: "ResetZoom")
button.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
button.translatesAutoresizingMaskIntoConstraints = false
button.widthAnchor.constraint(equalToConstant: 20).isActive = true
button.heightAnchor.constraint(equalToConstant: 20).isActive = true
return button
}
@objc private func selectTabAndZoom(_ sender: NSButton) {
guard let tabGroup else { return }
guard let associatedWindow = tabGroup.windows.first(where: {
guard let accessoryView = $0.tab.accessoryView else { return false }
return accessoryView.subviews.contains(sender)
}),
let windowController = associatedWindow.windowController as? TerminalController
else { return }
tabGroup.selectedWindow = associatedWindow
windowController.splitZoom(self)
}
// MARK: - Titlebar Font
// Used to set the titlebar font.
@@ -321,12 +283,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
toolbar = terminalToolbar
toolbarStyle = .unifiedCompact
if let resetZoomItem = terminalToolbar.items.first(where: { $0.itemIdentifier == .resetZoom }) {
resetZoomItem.view = resetZoomToolbarButton
resetZoomItem.view!.removeConstraints(resetZoomItem.view!.constraints)
resetZoomItem.view!.widthAnchor.constraint(equalToConstant: 22).isActive = true
resetZoomItem.view!.heightAnchor.constraint(equalToConstant: 20).isActive = true
}
}
// For titlebar tabs, we want to hide the separator view so that we get rid
@@ -648,8 +604,6 @@ private class TerminalToolbar: NSToolbar, NSToolbarDelegate {
])
item.isEnabled = true
case .resetZoom:
item = NSToolbarItem(itemIdentifier: .resetZoom)
default:
item = NSToolbarItem(itemIdentifier: itemIdentifier)
}
@@ -658,7 +612,7 @@ private class TerminalToolbar: NSToolbar, NSToolbarDelegate {
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return [.titleText, .flexibleSpace, .space, .resetZoom]
return [.titleText, .flexibleSpace]
}
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
@@ -713,6 +667,5 @@ private class CenteredDynamicLabel: NSTextField {
}
extension NSToolbarItem.Identifier {
static let resetZoom = NSToolbarItem.Identifier("ResetZoom")
static let titleText = NSToolbarItem.Identifier("TitleText")
}

View File

@@ -60,8 +60,20 @@ extension Ghostty {
supports_selection_clipboard: true,
wakeup_cb: { userdata in App.wakeup(userdata) },
action_cb: { app, target, action in App.action(app!, target: target, action: action) },
read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) },
confirm_read_clipboard_cb: { userdata, str, state, request in App.confirmReadClipboard(userdata, string: str, state: state, request: request ) },
read_clipboard_cb: { userdata, loc, state, mimes, mimesLen, list in
App.readClipboard(
userdata,
location: loc,
state: state,
mimes: mimes,
mimesLen: mimesLen,
list: list) },
confirm_read_clipboard_cb: { userdata, confirm, state, request in
App.confirmReadClipboard(
userdata,
confirm: confirm,
state: state,
request: request) },
write_clipboard_cb: { userdata, loc, content, len, confirm in
App.writeClipboard(userdata, location: loc, content: content, len: len, confirm: confirm) },
close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) }
@@ -286,60 +298,186 @@ extension Ghostty {
static func readClipboard(
_ userdata: UnsafeMutableRawPointer?,
location: ghostty_clipboard_e,
state: UnsafeMutableRawPointer?
) -> Bool {
state: UnsafeMutableRawPointer?,
mimes: UnsafePointer<UnsafePointer<CChar>?>?,
mimesLen: Int,
list: Bool
) -> ghostty_clipboard_read_result_e {
let surfaceView = self.surfaceUserdata(from: userdata)
guard let surface = surfaceView.surface else { return false }
guard let surface = surfaceView.surface else {
return GHOSTTY_CLIPBOARD_READ_UNSUPPORTED
}
// Get our pasteboard
guard let pasteboard = NSPasteboard.ghostty(location) else { return false }
guard let pasteboard = NSPasteboard.ghostty(location) else {
return GHOSTTY_CLIPBOARD_READ_UNSUPPORTED
}
// Return false if there is no text-like clipboard content so
// performable paste bindings can pass through to the terminal.
guard let str = pasteboard.getOpinionatedStringContents() else { return false }
// Gather the representation for each requested MIME type that
// the pasteboard can serve. We only ever read the requested
// representations so unrelated (potentially large) clipboard
// contents are never loaded.
var contents: [Ghostty.ClipboardContent] = []
var seen = Set<String>()
if let mimes {
for i in 0..<mimesLen {
guard let ptr = mimes[i] else { continue }
let mime = String(cString: ptr)
guard !seen.contains(mime) else { continue }
seen.insert(mime)
guard let data = pasteboard.ghosttyData(forMime: mime) else { continue }
contents.append(.init(mime: mime, data: data))
}
}
completeClipboardRequest(surface, data: str, state: state)
return true
// The listing of available types, only gathered when requested.
let available: [String] = list ? pasteboard.ghosttyAvailableMimes() : []
// With nothing to serve and no listing requested there is
// nothing to complete the read with.
if contents.isEmpty && !list {
return GHOSTTY_CLIPBOARD_READ_UNAVAILABLE
}
completeClipboardRequest(
surface,
contents: contents,
available: available,
state: state)
return GHOSTTY_CLIPBOARD_READ_STARTED
}
static func confirmReadClipboard(
_ userdata: UnsafeMutableRawPointer?,
string: UnsafePointer<CChar>?,
confirm: UnsafePointer<ghostty_clipboard_confirm_s>?,
state: UnsafeMutableRawPointer?,
request: ghostty_clipboard_request_e
) {
let surfaceView = self.surfaceUserdata(from: userdata)
guard surfaceView.surface != nil,
let string,
let valueStr = String(cString: string, encoding: .utf8),
let kind = Ghostty.ClipboardRequest.from(request: request) else { return }
guard let surface = surfaceView.surface else { return }
guard let confirm,
let kind = Ghostty.ClipboardRequest.from(request: request) else {
ghostty_surface_deny_clipboard_request(surface, state)
return
}
let c = confirm.pointee
// Copy the borrowed C representations: the confirmation is
// asynchronous and completes with exactly what the user
// approved, so the clipboard is never re-read.
var reps: [Ghostty.ClipboardContent] = []
if let contents = c.contents {
for i in 0..<c.contents_len {
let content = contents[i]
let data: Data = if content.len > 0 {
Data(bytes: content.data, count: content.len)
} else {
Data()
}
reps.append(.init(mime: String(cString: content.mime), data: data))
}
}
var avail: [String] = []
if let available = c.available {
for i in 0..<c.available_len {
guard let ptr = available[i] else { continue }
avail.append(String(cString: ptr))
}
}
// The dialog can only display text: show the text
// representation when there is one and summarize the rest.
let display = reps.first(where: { $0.mime == "text/plain" })
.flatMap { String(data: $0.data, encoding: .utf8) }
?? reps.map { "\($0.mime) (\($0.data.count) bytes)" }.joined(separator: "\n")
// Decode an image representation so the dialog can preview
// exactly what would be disclosed rather than a byte count.
let previewImage: NSImage? = reps.lazy
.filter { $0.mime.hasPrefix("image/") }
.compactMap { NSImage(data: $0.data) }
.first
// libghostty reaches this callback only when the request attempted
// by readClipboard requires confirmation. Reads allowed by policy
// complete immediately and never become pending Swift state.
let request = Ghostty.ClipboardConfirmationRequest(
surface: surfaceView,
contents: valueStr,
kind: kind
) { surfaceView, contents in
contents: display,
kind: kind,
programName: c.name.map { String(cString: $0) },
canRemember: c.can_remember,
previewImage: previewImage
) { surfaceView, confirmed, remember in
guard let surface = surfaceView.surface else { return }
completeClipboardRequest(
surface,
data: contents ?? "",
state: state,
confirmed: true)
if confirmed {
completeClipboardRequest(
surface,
contents: reps,
available: avail,
state: state,
confirmed: true,
remember: remember)
} else {
ghostty_surface_deny_clipboard_request(surface, state)
}
}
surfaceView.pendingClipboardConfirmation = request
}
private static func completeClipboardRequest(
_ surface: ghostty_surface_t,
data: String,
contents: [Ghostty.ClipboardContent],
available: [String],
state: UnsafeMutableRawPointer?,
confirmed: Bool = false
confirmed: Bool = false,
remember: Bool = false
) {
data.withCString { ptr in
ghostty_surface_complete_clipboard_request(surface, ptr, state, confirmed)
// Copy everything into C memory for the duration of the call.
var cStrings: [UnsafeMutablePointer<CChar>] = []
var cDatas: [UnsafeMutableRawPointer] = []
defer {
cStrings.forEach { free($0) }
cDatas.forEach { $0.deallocate() }
}
var cContents: [ghostty_clipboard_content_s] = []
for entry in contents {
guard let mime = strdup(entry.mime) else { continue }
cStrings.append(mime)
let buf = UnsafeMutableRawPointer.allocate(
byteCount: max(entry.data.count, 1),
alignment: 1)
cDatas.append(buf)
entry.data.withUnsafeBytes { src in
if let base = src.baseAddress {
buf.copyMemory(from: base, byteCount: src.count)
}
}
cContents.append(ghostty_clipboard_content_s(
mime: mime,
data: buf.assumingMemoryBound(to: CChar.self),
len: entry.data.count))
}
var cAvailable: [UnsafePointer<CChar>?] = []
for mime in available {
guard let str = strdup(mime) else { continue }
cStrings.append(str)
cAvailable.append(UnsafePointer(str))
}
cContents.withUnsafeBufferPointer { contentsBuf in
cAvailable.withUnsafeBufferPointer { availableBuf in
var complete = ghostty_clipboard_complete_s(
contents: contentsBuf.baseAddress,
contents_len: contentsBuf.count,
available: availableBuf.baseAddress,
available_len: availableBuf.count,
confirmed: confirmed,
remember: remember)
ghostty_surface_complete_clipboard_request(surface, &complete, state)
}
}
}
@@ -376,24 +514,25 @@ extension Ghostty {
// Set data for each type
for item in contentArray {
guard let type = NSPasteboard.PasteboardType(mimeType: item.mime) else { continue }
pasteboard.setString(item.data, forType: type)
pasteboard.setData(item.data, forType: type)
}
return
}
// For confirmation, use the text/plain content if it exists
guard let textPlainContent = contentArray.first(where: { $0.mime == "text/plain" }) else {
guard let textPlainContent = contentArray.first(where: { $0.mime == "text/plain" }),
let textPlainString = textPlainContent.string else {
return
}
let request = Ghostty.ClipboardConfirmationRequest(
surface: surfaceView,
contents: textPlainContent.data,
contents: textPlainString,
kind: .osc_52_write
) { _, contents in
guard let contents else { return }
) { _, confirmed, _ in
guard confirmed else { return }
pasteboard.declareTypes([.string], owner: nil)
pasteboard.setString(contents, forType: .string)
pasteboard.setString(textPlainString, forType: .string)
}
surfaceView.pendingClipboardConfirmation = request
}
@@ -1985,19 +2124,21 @@ extension Ghostty {
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
if v.active {
NotificationCenter.default.post(
name: Notification.didContinueKeySequence,
object: surfaceView,
userInfo: [
Notification.KeySequenceKey: keyboardShortcut(for: v.trigger) as Any
]
)
} else {
NotificationCenter.default.post(
name: Notification.didEndKeySequence,
object: surfaceView
)
DispatchQueue.main.async {
if v.active {
NotificationCenter.default.post(
name: Notification.didContinueKeySequence,
object: surfaceView,
userInfo: [
Notification.KeySequenceKey: keyboardShortcut(for: v.trigger) as Any
]
)
} else {
NotificationCenter.default.post(
name: Notification.didEndKeySequence,
object: surfaceView
)
}
}
default:

View File

@@ -1,3 +1,4 @@
import AppKit
import Foundation
import GhosttyKit
@@ -13,21 +14,32 @@ extension Ghostty {
/// An application is attempting to write to the clipboard using OSC 52.
case osc_52_write
/// The text to show in the clipboard confirmation prompt for this request.
func text() -> String {
/// An application is attempting to read from the clipboard using
/// the Kitty clipboard protocol (OSC 5522).
case kitty_read
/// An application is attempting to write to the clipboard using
/// the Kitty clipboard protocol (OSC 5522).
case kitty_write
/// The text to show in the clipboard confirmation prompt for this
/// request. The name is the requesting program's human friendly
/// name, when the protocol carries one.
func text(name: String? = nil) -> String {
let program = name.map { "\"\($0)\"" } ?? "An application"
switch self {
case .paste:
return """
Pasting this text to the terminal may be dangerous as it looks like some commands may be executed.
"""
case .osc_52_read:
case .osc_52_read, .kitty_read:
return """
An application is attempting to read from the clipboard.
\(program) is attempting to read from the clipboard.
The current clipboard contents are shown below.
"""
case .osc_52_write:
case .osc_52_write, .kitty_write:
return """
An application is attempting to write to the clipboard.
\(program) is attempting to write to the clipboard.
The content to write is shown below.
"""
}
@@ -41,6 +53,10 @@ extension Ghostty {
return .osc_52_read
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE:
return .osc_52_write
case GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ:
return .kitty_read
case GHOSTTY_CLIPBOARD_REQUEST_KITTY_WRITE:
return .kitty_write
default:
return nil
}
@@ -56,20 +72,46 @@ extension Ghostty {
/// occurs from inside the libghostty callback that created the request.
final class ClipboardConfirmationRequest {
private(set) weak var surface: SurfaceView?
/// The textual preview of the clipboard contents shown in the
/// confirmation dialog. The actual representations served on
/// confirmation are held by the completion.
let contents: String
let kind: ClipboardRequest
private var completion: ((SurfaceView, String?) -> Void)?
/// The human friendly name of the requesting program to show in
/// the prompt, when the protocol carries one.
let programName: String?
/// True when the user's decision may be remembered as a session
/// grant, showing a remember option in the prompt.
let canRemember: Bool
/// An image decoded from the request contents, previewed scaled
/// in the dialog when the request carries an image
/// representation.
let previewImage: NSImage?
/// Called exactly once with whether the user confirmed the
/// request and whether their decision should be remembered.
private var completion: ((SurfaceView, Bool, Bool) -> Void)?
init(
surface: SurfaceView,
contents: String,
kind: ClipboardRequest,
completion: @escaping (SurfaceView, String?) -> Void
programName: String? = nil,
canRemember: Bool = false,
previewImage: NSImage? = nil,
completion: @escaping (SurfaceView, Bool, Bool) -> Void
) {
self.surface = surface
self.contents = contents
self.kind = kind
self.programName = programName
self.canRemember = canRemember
self.previewImage = previewImage
self.completion = completion
}
@@ -77,29 +119,30 @@ extension Ghostty {
guard let surface, let completion else { return }
self.completion = nil
DispatchQueue.main.async {
completion(surface, nil)
completion(surface, false, false)
}
}
/// Complete the request using the displayed clipboard contents.
func complete() {
finish(contents)
/// Complete the request with the displayed clipboard contents.
func complete(remember: Bool = false) {
finish(true, remember: remember)
}
/// Cancel the request without using the displayed clipboard contents.
/// Cancel the request, denying access to the clipboard contents.
func cancel() {
finish(nil)
finish(false)
}
/// Cancel using the owning surface explicitly. SurfaceView uses this
/// for replacement and teardown because its weak reference is already
/// nil during the owner's deinitialization.
func cancel(from surface: SurfaceView) {
finish(nil, on: surface)
finish(false, on: surface)
}
private func finish(
_ contents: String?,
_ confirmed: Bool,
remember: Bool = false,
on explicitSurface: SurfaceView? = nil
) {
guard let surface = explicitSurface ?? self.surface,
@@ -108,7 +151,7 @@ extension Ghostty {
return
}
self.completion = nil
completion(surface, contents)
completion(surface, confirmed, remember)
}
}
}

View File

@@ -111,13 +111,16 @@ extension Ghostty {
/// configuration would be "quit" action.
///
/// Returns nil if there is no key equivalent for the given action.
func keyboardShortcut(for action: String) -> KeyboardShortcut? {
guard let cfg = self.config else { return nil }
let trigger = ghostty_config_trigger(cfg, action, UInt(action.lengthOfBytes(using: .utf8)))
@MainActor func keyboardShortcut(for action: String) -> KeyboardShortcut? {
guard let trigger = keybindTrigger(for: action) else { return nil }
return Ghostty.keyboardShortcut(for: trigger)
}
func keybindTrigger(for action: String) -> ghostty_input_trigger_s? {
guard let config else { return nil }
return ghostty_config_trigger(config, action, UInt(action.lengthOfBytes(using: .utf8)))
}
// MARK: - Configuration Values
/// For all of the configuration values below, see the associated Ghostty documentation for
@@ -125,10 +128,10 @@ extension Ghostty {
/// due to the embedded library and Swift.
var bellFeatures: BellFeatures {
guard let config = self.config else { return .init() }
guard let config = self.config else { return .defaultValue }
var v: CUnsignedInt = 0
let key = "bell-features"
guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .init() }
guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .defaultValue }
return .init(rawValue: v)
}
@@ -807,6 +810,8 @@ extension Ghostty.Config {
static let attention = BellFeatures(rawValue: 1 << 2)
static let title = BellFeatures(rawValue: 1 << 3)
static let border = BellFeatures(rawValue: 1 << 4)
static let defaultValue = BellFeatures([.attention, .title])
}
struct SplitPreserveZoom: OptionSet {

View File

@@ -16,16 +16,28 @@ extension Ghostty {
/// (F1, F2, ...) with a KeyboardShortcut. This doesn't represent a practical issue because input
/// handling for Ghostty is handled at a lower level (usually). This function should generally only
/// be used for things like NSMenu that only support keyboard shortcuts anyways.
static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? {
@MainActor static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? {
let modifierFlags = Self.eventModifierFlags(mods: trigger.mods)
let key: KeyEquivalent
switch trigger.tag {
case GHOSTTY_TRIGGER_PHYSICAL:
// Only functional keys can be converted to a KeyboardShortcut. Other physical
// mappings cannot because KeyboardShortcut in Swift is inherently layout-dependent.
if let equiv = Self.keyToEquivalent[trigger.key.physical] {
key = equiv
let physical = trigger.key.physical
if let equivalent = Self.keyToEquivalent[physical] {
key = equivalent
} else {
return nil
guard
Self.writingSystemKeyRange.contains(physical.rawValue),
let inputKey = Input.Key(cKey: physical),
let keyCode = inputKey.keyCode,
// Command can select a distinct layout table. Other modifiers remain
// separate in the menu's modifier mask and must not affect this character.
let character = KeyboardLayout.character(
for: keyCode,
modifiers: modifierFlags.intersection(.command))
else { return nil }
// Printable physical keys must be translated through the current layout.
key = KeyEquivalent(character)
}
case GHOSTTY_TRIGGER_UNICODE:
@@ -45,7 +57,7 @@ extension Ghostty {
return KeyboardShortcut(
key,
modifiers: EventModifiers(nsFlags: Ghostty.eventModifierFlags(mods: trigger.mods)))
modifiers: EventModifiers(nsFlags: modifierFlags))
}
// MARK: Mods
@@ -101,6 +113,10 @@ extension Ghostty {
GHOSTTY_KEY_BACKSPACE: .delete,
GHOSTTY_KEY_SPACE: .space,
]
/// The contiguous W3C "Writing System Keys" § 3.1.1 key range.
private static let writingSystemKeyRange =
GHOSTTY_KEY_BACKQUOTE.rawValue...GHOSTTY_KEY_SLASH.rawValue
}
// MARK: Ghostty.Input.BindingFlags
@@ -224,6 +240,66 @@ extension Ghostty.Input {
}
}
extension Ghostty.Input.KeyEvent {
/// Create a translated key event for programmatic input (e.g. AppleScript).
///
/// - Parameters:
/// - key: The key being pressed or released.
/// - action: The key action.
/// - mods: The full set of modifiers for the event.
/// - translationMods: The subset of `mods` that participates in text
/// translation. Use `Surface.keyTranslationMods(_:)` so that
/// configuration such as `macos-option-as-alt` is honored.
///
/// - Note: Translation is a single stateless pass through the keyboard layout.
/// A key that starts a dead-key sequence (e.g. option+E on a US layout)
/// produces its standalone character or nothing.
@MainActor
init(
synthesizing key: Ghostty.Input.Key,
action: Ghostty.Input.Action,
mods: Ghostty.Input.Mods,
translationMods: Ghostty.Input.Mods
) {
let keyCode = key.keyCode
// Control never contributes to the translation of text,
// matching `NSEvent.ghosttyCharacters`.
let text: String?
if action == .release {
// We don't need to attach text to a release key event,
// as real NSEvents don't carry them in most cases.
text = nil
} else {
text = keyCode
.flatMap {
KeyboardLayout.character(
for: $0,
modifiers: translationMods.nsFlags.subtracting(.control))
}
.flatMap { String($0).keyEventText }
}
// The unshifted codepoint ignores all modifiers. Control characters are
// reported as no codepoint (0) so that Ghostty encodes such keys from
// the key enum instead.
let unshiftedCodepoint = keyCode
.flatMap { KeyboardLayout.character(for: $0, modifiers: []) }
.flatMap { String($0).keyEventText }?
.unicodeScalars.first?.value ?? 0
self.init(
key: key,
action: action,
text: text,
mods: mods,
// Same as `NSEvent.ghosttyKeyEvent`
consumedMods: translationMods.subtracting([.ctrl, .super]),
unshiftedCodepoint: unshiftedCodepoint
)
}
}
// MARK: Ghostty.Input.Action
extension Ghostty.Input {
@@ -771,14 +847,15 @@ extension Ghostty.Input {
case cut
case paste
init?(cKey: ghostty_input_key_e) {
guard let key = Key.allCases.first(where: { $0.cKey == cKey }) else { return nil }
self = key
}
/// Get a key from a keycode
init?(keyCode: UInt16) {
if let key = Key.allCases.first(where: { $0.keyCode == keyCode }) {
self = key
return
}
return nil
guard let key = Key.allCases.first(where: { $0.keyCode == keyCode }) else { return nil }
self = key
}
var cKey: ghostty_input_key_e {

View File

@@ -1,5 +1,6 @@
import AppKit
import SwiftUI
import GhosttyKit
extension Ghostty {
/// The manager that's responsible for updating shortcuts of Ghostty's app menu
@@ -26,6 +27,8 @@ extension Ghostty {
if !updateMenuShortcut(config, action: action, menuItem: menu) {
menu.keyEquivalent = ""
menu.keyEquivalentModifierMask = []
menu.allowsAutomaticKeyEquivalentLocalization = true
menu.allowsAutomaticKeyEquivalentMirroring = true
}
}
@@ -34,16 +37,23 @@ extension Ghostty {
/// bindings through the menu so they flash but also lets our surface override macOS built-ins
/// like Cmd+H.
func performGhosttyBindingMenuKeyEquivalent(with event: NSEvent) -> Bool {
// Convert this event into the same normalized lookup key we use when
// syncing menu shortcuts from configuration.
guard let key = MenuShortcutKey(event: event) else {
return false
// Physical bindings take precedence over Unicode bindings in the core.
let physicalKey = MenuShortcutKey(
physicalKeyCode: event.keyCode,
modifiers: event.modifierFlags)
if let result = performMenuItem(for: physicalKey) {
return result
}
guard let key = MenuShortcutKey(event: event) else { return false }
return performMenuItem(for: key) ?? false
}
private func performMenuItem(for key: MenuShortcutKey) -> Bool? {
// If we don't have an entry for this key combo, no Ghostty-owned
// menu shortcut exists for this event.
guard let weakItem = menuItemsByShortcut[key] else {
return false
return nil
}
// Weak references can be nil if a menu item was deallocated after sync.
@@ -81,16 +91,23 @@ private extension Ghostty.MenuShortcutManager {
func updateMenuShortcut(_ config: Ghostty.Config, action: String?, menuItem menu: NSMenuItem) -> Bool {
guard
let action,
let shortcut = config.keyboardShortcut(for: action),
// Build a direct lookup for key-equivalent dispatch so we don't need to
// linearly walk the full menu hierarchy at event time.
let key = MenuShortcutKey(shortcut)
else {
let trigger = config.keybindTrigger(for: action),
let shortcut = Ghostty.keyboardShortcut(for: trigger)
else { return false }
let isPhysical = trigger.tag == GHOSTTY_TRIGGER_PHYSICAL
let physicalKeyCode = isPhysical ? Ghostty.Input.Key(cKey: trigger.key.physical)?.keyCode : nil
// Build a direct lookup for key-equivalent dispatch so we don't need to
// linearly walk the full menu hierarchy at event time.
guard let key = MenuShortcutKey(shortcut, physicalKeyCode: physicalKeyCode) else {
return false
}
menu.keyEquivalent = key.keyEquivalent
menu.keyEquivalent = shortcut.key.character.description
menu.keyEquivalentModifierMask = key.modifierFlags
// The key equivalent was already localized from the physical keycode.
menu.allowsAutomaticKeyEquivalentLocalization = !isPhysical
menu.allowsAutomaticKeyEquivalentMirroring = !isPhysical
// Later registrations intentionally override earlier ones for the same key.
menuItemsByShortcut[key] = .init(menu)
@@ -101,10 +118,14 @@ private extension Ghostty.MenuShortcutManager {
extension Ghostty.MenuShortcutManager {
/// Hashable key for a menu shortcut match, normalized for quick lookup.
struct MenuShortcutKey: Hashable {
private enum Identity: Hashable {
case keyEquivalent(String)
case physicalKeyCode(UInt16)
}
private static let shortcutModifiers: NSEvent.ModifierFlags = [.shift, .control, .option, .command]
let keyEquivalent: String
// Make it Hashable
private let identity: Identity
private let modifiersRawValue: UInt
var modifierFlags: NSEvent.ModifierFlags {
@@ -122,40 +143,30 @@ extension Ghostty.MenuShortcutManager {
// it's originally uppercased, then we need to add `shift` to the modifiers
mods.insert(.shift)
}
self.keyEquivalent = normalized
self.identity = .keyEquivalent(normalized)
self.modifiersRawValue = mods.rawValue
}
init(physicalKeyCode: UInt16, modifiers: NSEvent.ModifierFlags) {
self.identity = .physicalKeyCode(physicalKeyCode)
self.modifiersRawValue = modifiers.intersection(Self.shortcutModifiers).rawValue
}
init?(event: NSEvent) {
guard let keyEquivalent = event.charactersIgnoringModifiers else { return nil }
self.init(keyEquivalent: keyEquivalent, modifiers: event.modifierFlags)
}
/// Create from a `NSMenuItem`
///
/// - Important: This will check whether the `keyEquivalent` is uppercased by `.shift` modifier.
init?(_ menuItem: NSMenuItem) {
self.init(
keyEquivalent: menuItem.keyEquivalent,
modifiers: menuItem.keyEquivalentModifierMask,
)
}
/// Create from a swiftUI `KeyboardShortcut`
init?(_ shortcut: KeyboardShortcut) {
// Ghostty configured shortcuts are already normalized
// in `Ghostty.keyboardShortcut(for:)`, see also gh-#12039
let keyEquivalent = shortcut.key.character.description
let modifierMask = NSEvent.ModifierFlags(swiftUIFlags: shortcut.modifiers)
self.init(keyEquivalent: keyEquivalent, modifiers: modifierMask)
}
var swiftUIShortcut: KeyboardShortcut? {
guard let character = keyEquivalent.first else { return nil }
return KeyboardShortcut(
KeyEquivalent(character),
modifiers: .init(nsFlags: modifierFlags)
)
/// Create from a SwiftUI `KeyboardShortcut`.
init?(_ shortcut: KeyboardShortcut, physicalKeyCode: UInt16? = nil) {
let modifiers = NSEvent.ModifierFlags(swiftUIFlags: shortcut.modifiers)
if let physicalKeyCode {
self.init(physicalKeyCode: physicalKeyCode, modifiers: modifiers)
} else {
self.init(
keyEquivalent: shortcut.key.character.description,
modifiers: modifiers)
}
}
}
}

View File

@@ -44,8 +44,9 @@ extension Ghostty {
}
}
/// Send text to the terminal as if it was typed. This doesn't send the key events so keyboard
/// shortcuts and other encodings do not take effect.
/// Send text to the terminal using paste semantics. This doesn't send key events, so keyboard
/// shortcuts and other encodings do not take effect. Bracketed paste framing is applied when
/// the terminal has enabled it.
@MainActor
func sendText(_ text: String) {
let len = text.utf8CString.count
@@ -57,6 +58,17 @@ extension Ghostty {
}
}
/// Returns the modifiers that participate in text translation for key
/// events on this surface. This honors configuration such as
/// `macos-option-as-alt`, which may exclude option from translation.
///
/// - Parameter mods: The full set of modifiers for the key event.
/// - Returns: The subset of `mods` to use for keyboard layout translation.
@MainActor
func keyTranslationMods(_ mods: Input.Mods) -> Input.Mods {
Input.Mods(cMods: ghostty_surface_key_translation_mods(surface, mods.cMods))
}
/// Send a key event to the terminal.
///
/// This sends the full key event including modifiers, action type, and text to the terminal.

View File

@@ -237,9 +237,14 @@ extension Ghostty.SplitFocusDirection {
}
extension Ghostty {
/// One representation of clipboard contents. The data is binary-safe;
/// textual consumers use `string`.
struct ClipboardContent {
let mime: String
let data: String
let data: Data
/// The data as text, if it is valid UTF-8.
var string: String? { String(data: data, encoding: .utf8) }
static func from(content: ghostty_clipboard_content_s) -> ClipboardContent? {
guard let mimePtr = content.mime,
@@ -247,9 +252,15 @@ extension Ghostty {
return nil
}
let data: Data = if content.len > 0 {
Data(bytes: dataPtr, count: content.len)
} else {
Data()
}
return ClipboardContent(
mime: String(cString: mimePtr),
data: String(cString: dataPtr)
data: data
)
}
}

View File

@@ -1212,7 +1212,7 @@ extension Ghostty {
continue
}
_ = committedPreeditTextAction(action, text: text)
_ = committedTextAction(action, text: text)
}
if shouldReplayCommittedPreeditKey(translationEvent) {
@@ -1484,11 +1484,7 @@ extension Ghostty {
var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags)
key_ev.composing = composing
// For text, we only encode UTF8 if we don't have a single control
// character. Control characters are encoded by Ghostty itself.
// Without this, `ctrl+enter` does the wrong thing.
if let text, text.count > 0,
let codepoint = text.utf8.first, codepoint >= 0x20 {
if let text = text?.keyEventText {
return text.withCString { ptr in
key_ev.text = ptr
return ghostty_surface_key(surface, key_ev)
@@ -1512,7 +1508,7 @@ extension Ghostty {
}
}
private func committedPreeditTextAction(
private func committedTextAction(
_ action: ghostty_input_action_e,
text: String
) -> Bool {
@@ -1787,20 +1783,31 @@ extension Ghostty {
// Note the callback may be executed on a background thread as documented
// so we need @MainActor since we're reading/writing view state.
Task { @MainActor in
// We use [weak self] here because we don't want to extend the surface's
// lifetime when a notification is triggered right before the surface closes.
Task { @MainActor [weak self] in
do {
try await UNUserNotificationCenter.current().add(request)
guard let focused = self?.focused else {
// We remove the notification if the surface is deallocated.
UNUserNotificationCenter.current()
.removeDeliveredNotifications(withIdentifiers: [uuid])
return
}
// We need to keep track of this notification so we can remove it
// under certain circumstances
notificationIdentifiers.insert(uuid)
self?.notificationIdentifiers.insert(uuid)
// If we're focused then we schedule to remove the notification
// after a few seconds. If we gain focus we automatically remove it
// in focusDidChange.
if self.focused {
try await Task.sleep(for: .seconds(3))
notificationIdentifiers.remove(uuid)
if focused {
// If the suspension is failed, we remove the notification anyway.
try? await Task.sleep(for: .seconds(3))
self?.notificationIdentifiers.remove(uuid)
// We remove the notification if the surface is deallocated while we wait.
UNUserNotificationCenter.current()
.removeDeliveredNotifications(withIdentifiers: [uuid])
}
@@ -2063,7 +2070,6 @@ extension Ghostty.SurfaceView: NSTextInputClient {
func insertText(_ string: Any, replacementRange: NSRange) {
// We must have an associated event
guard NSApp.currentEvent != nil else { return }
guard let surfaceModel else { return }
// We want the string view of the any value
var chars = ""
@@ -2089,8 +2095,6 @@ extension Ghostty.SurfaceView: NSTextInputClient {
return
}
let hadMarkedText = hasMarkedText()
// If insertText is called, our preedit must be over.
unmarkText()
@@ -2102,14 +2106,11 @@ extension Ghostty.SurfaceView: NSTextInputClient {
return
}
if hadMarkedText, !chars.isEmpty {
// Send preedit commits as key events instead of raw text for
// keybind interpretation by programs.
_ = committedPreeditTextAction(GHOSTTY_ACTION_PRESS, text: chars)
return
// All committed text (IME, dictation, etc.) must be sent as key
// events so programs treat it as typed input, never as a paste.
if !chars.isEmpty {
_ = committedTextAction(GHOSTTY_ACTION_PRESS, text: chars)
}
surfaceModel.sendText(chars)
}
/// This function needs to exist for two reasons:
@@ -2299,10 +2300,7 @@ extension Ghostty.SurfaceView {
if let content {
DispatchQueue.main.async {
self.insertText(
content,
replacementRange: NSRange(location: 0, length: 0)
)
self.surfaceModel?.sendText(content)
}
return true
}

View File

@@ -1,25 +0,0 @@
import Foundation
/// Type-erased wrapper for any Comparable type to use as a sort key.
struct AnySortKey: Comparable {
private let value: Any
private let comparator: (Any, Any) -> ComparisonResult
init<T: Comparable>(_ value: T) {
self.value = value
self.comparator = { lhs, rhs in
guard let l = lhs as? T, let r = rhs as? T else { return .orderedSame }
if l < r { return .orderedAscending }
if l > r { return .orderedDescending }
return .orderedSame
}
}
static func < (lhs: AnySortKey, rhs: AnySortKey) -> Bool {
lhs.comparator(lhs.value, rhs.value) == .orderedAscending
}
static func == (lhs: AnySortKey, rhs: AnySortKey) -> Bool {
lhs.comparator(lhs.value, rhs.value) == .orderedSame
}
}

View File

@@ -104,11 +104,11 @@ enum BackportPointerStyle {
}
}
enum BackportNSGlassStyle {
enum BackportGlass {
case regular, clear
@available(macOS 26, *)
var official: NSGlassEffectView.Style {
var official: Glass {
switch self {
case .regular: return .regular
case .clear: return .clear

View File

@@ -0,0 +1,19 @@
import AppKit
extension NSAlert {
static func reviewWindowsAlert(
messageText: String,
informativeText: String = "If you don't review your windows, any running processes will be terminated",
terminateNowButtonTitle: String = "Terminate Processes"
) -> NSAlert {
let alert = NSAlert()
alert.messageText = messageText
alert.informativeText = informativeText
alert.addButton(withTitle: "Review Windows...")
alert.addButton(withTitle: terminateNowButtonTitle)
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
return alert
}
}

View File

@@ -24,14 +24,6 @@ extension NSColor {
appleColorList?.allKeys.map { $0.lowercased() } ?? []
}
/// Returns a new color with its saturation multiplied by the given factor, clamped to [0, 1].
func adjustingSaturation(by factor: CGFloat) -> NSColor {
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
let hsbColor = self.usingColorSpace(.sRGB) ?? self
hsbColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
return NSColor(hue: h, saturation: min(max(s * factor, 0), 1), brightness: b, alpha: a)
}
/// Calculates the perceptual distance to another color in RGB space.
func distance(to other: NSColor) -> Double {
guard let a = self.usingColorSpace(.sRGB),

View File

@@ -7,10 +7,5 @@ extension NSMenuItem {
if #available(macOS 26, *) {
image = NSImage(systemSymbolName: symbol, accessibilityDescription: title)
}
#if compiler(>=6.4)
if #available(macOS 27.0, *) {
preferredImageVisibility = .automatic
}
#endif
}
}

View File

@@ -54,7 +54,86 @@ extension NSPasteboard {
return strings.joined(separator: " ")
}
/// The pasteboard for the Ghostty enum type.
/// The file URLs on the pasteboard, e.g. files copied in Finder.
private var ghosttyFileURLs: [URL] {
(pasteboardItems ?? []).compactMap { item in
guard let plist = item.propertyList(forType: .fileURL),
let url = NSURL(pasteboardPropertyList: plist, ofType: .fileURL) as URL?,
url.isFileURL else { return nil }
return url
}
}
/// The data for the given MIME type, if the pasteboard can serve it.
///
/// The canonical "text/plain" type uses the opinionated string
/// contents so that e.g. copying a file yields its escaped path;
/// this matches what pasting into the terminal produces. Copied
/// files are additionally served as "text/uri-list" (RFC 2483, the
/// type X11/Wayland clipboards carry file copies under). All other
/// types are mapped through UTType.
func ghosttyData(forMime mime: String) -> Data? {
switch mime {
case "text/plain":
guard let str = getOpinionatedStringContents() else { return nil }
return Data(str.utf8)
case "text/uri-list":
let urls = ghosttyFileURLs
guard !urls.isEmpty else { return nil }
return Data(urls.map { $0.absoluteString + "\r\n" }.joined().utf8)
default:
guard let type = NSPasteboard.PasteboardType(mimeType: mime) else { return nil }
return data(forType: type)
}
}
/// The MIME types available on the pasteboard, best-effort mapped
/// from the pasteboard types. Types without a MIME mapping are not
/// reported. This only inspects declared types and never reads data,
/// since mode 5522 paste events must remain metadata-only.
func ghosttyAvailableMimes() -> [String] {
var result: [String] = []
var seen = Set<String>()
let availableTypes = types ?? []
let mimeType: (NSPasteboard.PasteboardType) -> String? = { type in
guard let mime = UTType(type.rawValue)?.preferredMIMEType else { return nil }
return mime == "text/plain;charset=utf-8" ? "text/plain" : mime
}
// Plain text and copied files can both be served as the canonical
// text representation. Infer this from declared types so lazy
// pasteboard providers are not asked for their contents.
let hasFileURL = availableTypes.contains(.fileURL)
let hasPlainText = hasFileURL || availableTypes.contains { type in
mimeType(type) == "text/plain"
}
if hasPlainText {
result.append("text/plain")
seen.insert("text/plain")
}
// Copied files are additionally served as a URI list. The
// generic mapping below never reports this since file URL
// pasteboard types have no MIME type.
if hasFileURL {
result.append("text/uri-list")
seen.insert("text/uri-list")
}
for type in availableTypes {
guard let mime = mimeType(type),
!seen.contains(mime) else { continue }
seen.insert(mime)
result.append(mime)
}
return result
}
/// The pasteboard for the Ghostty enum type. Returns nil for locations
/// macOS can't serve; callers report those as unsupported.
static func ghostty(_ clipboard: ghostty_clipboard_e) -> NSPasteboard? {
switch clipboard {
case GHOSTTY_CLIPBOARD_STANDARD:
@@ -63,6 +142,10 @@ extension NSPasteboard {
case GHOSTTY_CLIPBOARD_SELECTION:
return Self.ghosttySelection
case GHOSTTY_CLIPBOARD_PRIMARY:
// macOS has no primary selection.
return nil
default:
return nil
}

View File

@@ -1,4 +1,21 @@
extension String {
/// True when the first scalar is an ASCII control character (C0 or DEL).
var startsWithASCIIControlCharacter: Bool {
guard let scalar = unicodeScalars.first else { return false }
return scalar.value < 0x20 || scalar.value == 0x7F
}
/// The string as the text of a terminal key event, or nil when it is empty
/// or begins with an ASCII control character.
///
/// - Note: Control characters are encoded by Ghostty itself so that the
/// physical key and its modifiers remain available to protocols
/// such as the Kitty keyboard protocol.
var keyEventText: String? {
guard !isEmpty, !startsWithASCIIControlCharacter else { return nil }
return self
}
func truncate(length: Int, trailing: String = "") -> String {
let maxLength = length - trailing.count
guard maxLength > 0, !self.isEmpty, self.count > length else {

View File

@@ -0,0 +1,9 @@
import Foundation
import System
extension URL {
/// The decoded path with trailing separators removed, except for the root path.
var pathWithoutTrailingSlash: String {
FilePath(path(percentEncoded: false)).string
}
}

View File

@@ -1,14 +0,0 @@
import SwiftUI
struct HostingWindowKey: EnvironmentKey {
typealias Value = () -> NSWindow? // needed for weak link
static let defaultValue: Self.Value = { nil }
}
extension EnvironmentValues {
/// This can be used to set the hosting NSWindow to a NSHostingView
var hostingWindow: HostingWindowKey.Value {
get { return self[HostingWindowKey.self] }
set { self[HostingWindowKey.self] = newValue }
}
}

View File

@@ -1,3 +1,4 @@
import AppKit
import Carbon
class KeyboardLayout {
@@ -11,4 +12,32 @@ class KeyboardLayout {
return nil
}
/// Translate a physical keycode for use as a menu key equivalent.
///
/// AppKit retranslates against the current input source without changing its dead key state.
///
/// - Important: Must be called on the main thread because underlying Text Input Sources APIs are not thread-safe.
@MainActor static func character(
for keyCode: UInt16,
modifiers: NSEvent.ModifierFlags
) -> Character? {
guard
let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [],
timestamp: 0,
windowNumber: 0,
context: nil,
characters: "",
charactersIgnoringModifiers: "",
isARepeat: false,
keyCode: keyCode),
let result = event.characters(byApplyingModifiers: modifiers),
result.count == 1
else { return nil }
return result.first
}
}

View File

@@ -14,13 +14,15 @@ struct CommandPaletteFilterTests {
title: String,
subtitle: String? = nil,
description: String? = nil,
leadingColor: Color? = nil
leadingColor: Color? = nil,
sortKey: ObjectIdentifier? = nil
) -> CommandOption {
CommandOption(
title: title,
subtitle: subtitle,
description: description,
leadingColor: leadingColor
leadingColor: leadingColor,
sortKey: sortKey
) {}
}
@@ -46,4 +48,24 @@ struct CommandPaletteFilterTests {
#expect([first, second].filteredAndSorted(query: "new") == [first, second])
#expect([second, first].filteredAndSorted(query: "new") == [second, first])
}
/// Equal titles use their sort keys independent of input order.
@Test func equalTitlesUseSortKey() {
let firstKey = NSObject()
let secondKey = NSObject()
let first = option(
title: "Focus: Shell",
subtitle: "/tmp",
sortKey: ObjectIdentifier(firstKey)
)
let second = option(
title: "Focus: Shell",
subtitle: "/tmp",
sortKey: ObjectIdentifier(secondKey)
)
let forward = sortedTerminalPaletteOptions([first, second])
let reverse = sortedTerminalPaletteOptions([second, first])
#expect(forward == reverse)
}
}

View File

@@ -0,0 +1,187 @@
import AppKit
import Testing
@testable import Ghostty
/// Tests for `ScriptKeyEventCommand.parse`, which turns the scripting
/// arguments of the `send key` AppleScript command into the
/// `Ghostty.Input.KeyEvent` delivered to the surface.
///
/// Printable keys must carry the text and codepoints a real NSEvent-based key
/// press would, derived from the current keyboard layout; otherwise the key
/// produces no terminal output. Expected characters are computed through
/// `KeyboardLayout` rather than hardcoded so the tests hold on any layout.
@MainActor
struct ScriptKeyEventCommandTests {
private let keyCodeA: UInt16 = 0x00 // W3C KeyA
/// Parse a `send key` command the way the scripting runtime delivers it.
private func parse(
_ keyName: Any?,
modifiers: String? = nil,
action: UInt32? = nil,
translationMods: (Ghostty.Input.Mods) -> Ghostty.Input.Mods = { $0 },
) throws -> Ghostty.Input.KeyEvent {
var arguments: [String: Any] = [:]
if let modifiers { arguments["modifiers"] = modifiers }
if let action { arguments["action"] = action }
return try ScriptKeyEventCommand.parse(
directParameter: keyName,
evaluatedArguments: arguments.isEmpty ? nil : arguments,
translationMods: translationMods,
)
}
// MARK: Argument parsing
@Test func defaultsToPressWithNoModifiers() throws {
let event = try parse("a")
#expect(event.key == .a)
#expect(event.action == .press)
#expect(event.mods == [])
}
@Test func parsesActionCodes() throws {
try #expect(
parse("enter", action: "GIpr".fourCharCode).action == .press
)
try #expect(
parse("enter", action: "GIrl".fourCharCode).action == .release
)
}
@Test func unknownActionCodeFallsBackToPress() throws {
try #expect(parse("a", action: 0).action == .press)
}
@Test(
arguments: [
("shift", Ghostty.Input.Mods.shift),
("control", .ctrl),
("option", .alt),
("command", .super),
("shift, command", [.shift, .super]),
("SHIFT,Option", [.shift, .alt]),
(" control , shift ", [.ctrl, .shift]),
("", []),
] as [(String, Ghostty.Input.Mods)]
)
func parsesModifiers(string: String, expected: Ghostty.Input.Mods) throws {
try #expect(parse("a", modifiers: string).mods == expected)
}
@Test(arguments: [nil, 42, NSNull()] as [Any?])
func missingOrNonStringKeyThrows(directParameter: Any?) {
#expect(throws: ScriptKeyEventCommand.ArgumentError.missingKey) {
try parse(directParameter)
}
}
@Test func unknownKeyNameThrows() {
#expect(
throws: ScriptKeyEventCommand.ArgumentError.unknownKey("banana")
) {
try parse("banana")
}
}
@Test func unknownModifierThrows() {
#expect(
throws: ScriptKeyEventCommand.ArgumentError.unknownModifiers(
"shift, hyper"
)
) {
try parse("a", modifiers: "shift, hyper")
}
}
/// The sdef's documented key name examples must all resolve.
@Test(arguments: ["enter", "a", "space"])
func documentedKeyNamesResolve(name: String) throws {
_ = try parse(name)
}
// MARK: Produced key event
// These record known issues until `send key` derives text and codepoints
// from the keyboard layout the way real NSEvent-based input does.
// Uncomment the expectations and remove the Issue.record calls once the
// fix lands.
@Test func pressCarriesLayoutText() throws {
let event = try parse("a")
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == String(expected))
#expect(event.unshiftedCodepoint == expected.unicodeScalars.first?.value)
#expect(event.consumedMods == [])
}
@Test func shiftShiftsTextAndIsConsumed() throws {
let event = try parse("a", modifiers: "shift")
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .shift))
let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == String(expected))
#expect(event.consumedMods == .shift)
#expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value)
}
/// The original bug scenario: `send key "c" with modifiers "control"`
/// must produce the base character as text ("c", not 0x03) with control
/// unconsumed, so core can encode the control sequence itself.
@Test func controlKeepsBaseTextAndIsNotConsumed() throws {
let event = try parse("c", modifiers: "control")
let expected = try #require(KeyboardLayout.character(
for: 0x08, // W3C KeyC
modifiers: []))
#expect(event.text == String(expected))
#expect(event.mods == .ctrl)
#expect(event.consumedMods == [])
}
@Test func optionIncludedInTranslationIsConsumed() throws {
// macos-option-as-alt=false: option participates in translation.
let event = try parse("a", modifiers: "option")
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .option))
#expect(event.text == String(expected))
#expect(event.consumedMods == .alt)
}
@Test func optionExcludedFromTranslationIsNotConsumed() throws {
// macos-option-as-alt=true: the surface's translation mods exclude
// option, so it stays unconsumed and core can encode it (e.g. ESC
// prefix).
let event = try parse("a", modifiers: "option") { $0.subtracting(.alt) }
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == String(expected))
#expect(event.consumedMods == [])
}
@Test func releaseCarriesNoText() throws {
let event = try parse(
"a",
modifiers: "shift",
action: "GIrl".fourCharCode
)
let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == nil)
#expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value)
}
/// Keys whose layout translation is a control character (or a PUA
/// function-key character) must carry no text and no unshifted codepoint;
/// core encodes them from the key enum.
@Test(arguments: [
Ghostty.Input.Key.enter, .escape, .tab, .backspace, .arrowUp, .f1,
.home, .delete,
])
func functionalKeysCarryNoTextOrCodepoint(key: Ghostty.Input.Key) throws {
let event = try parse(key.rawValue)
#expect(event.text == nil)
#expect(event.unshiftedCodepoint == 0)
}
@Test func modsPassThroughUnchanged() throws {
let event = try parse("a", modifiers: "control, shift")
#expect(event.mods == [.ctrl, .shift])
}
}

View File

@@ -223,8 +223,8 @@ struct ConfigTests {
// MARK: - Keybind
@Test
func uppercasedLetterShouldBeNormalized() async throws {
@MainActor @Test
func uppercasedLetterShouldBeNormalized() throws {
let config = try TemporaryConfig("""
keybind=cmd+L=goto_split:left
""")
@@ -238,8 +238,8 @@ struct ConfigTests {
#expect(shortcut2 == .init("ä", modifiers: [.command]))
}
@Test
func emptyConfigShouldBeHaveDefaultShortcut() async throws {
@MainActor @Test
func emptyConfigShouldBeHaveDefaultShortcut() throws {
let config = try TemporaryConfig("")
let newWindow = try #require(config.keyboardShortcut(for: "new_window"))
#expect(newWindow == .init("n", modifiers: [.command]))

View File

@@ -0,0 +1,115 @@
import AppKit
import SwiftUI
import Testing
@testable import Ghostty
import GhosttyKit
/// Tests for `Ghostty.Input.KeyEvent.init(synthesizing:...)`, the derivation
/// used for programmatic key input such as AppleScript's `send key`.
///
/// Expected characters are computed through `KeyboardLayout` rather than
/// hardcoded so the tests hold on any keyboard layout; what's under test is
/// the text/consumed/unshifted derivation, not the layout itself.
@MainActor
struct KeyEventSynthesizeTests {
private let keyCodeA: UInt16 = 0x00 // W3C KeyA
@Test func pressHasLayoutText() throws {
let event = Ghostty.Input.KeyEvent(
synthesizing: .a, action: .press, mods: [], translationMods: [])
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == String(expected))
#expect(event.unshiftedCodepoint == expected.unicodeScalars.first?.value)
#expect(event.consumedMods == [])
}
@Test func shiftAppliesToTextAndIsConsumed() throws {
let event = Ghostty.Input.KeyEvent(
synthesizing: .a, action: .press, mods: .shift, translationMods: .shift)
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .shift))
let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == String(expected))
#expect(event.consumedMods == .shift)
#expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value)
}
@Test func controlIsNeverConsumedAndDoesNotAffectText() throws {
// Core passes ctrl through translation mods; the event must still
// produce the base character ("a", not 0x01) and not consume ctrl.
let event = Ghostty.Input.KeyEvent(
synthesizing: .a, action: .press, mods: .ctrl, translationMods: .ctrl)
let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == String(expected))
#expect(event.consumedMods == [])
}
@Test func optionFollowsTranslationMods() throws {
// macos-option-as-alt=false: option participates in translation and
// is consumed.
let translated = Ghostty.Input.KeyEvent(
synthesizing: .a, action: .press, mods: .alt, translationMods: .alt)
let optionChar = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .option))
#expect(translated.text == String(optionChar))
#expect(translated.consumedMods == .alt)
// macos-option-as-alt=true: option is excluded from translation and
// remains unconsumed, so core can encode it (e.g. ESC prefix).
let asAlt = Ghostty.Input.KeyEvent(
synthesizing: .a, action: .press, mods: .alt, translationMods: [])
let baseChar = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(asAlt.text == String(baseChar))
#expect(asAlt.consumedMods == [])
}
@Test func releaseHasNoText() throws {
let event = Ghostty.Input.KeyEvent(
synthesizing: .a, action: .release, mods: .shift, translationMods: .shift)
let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
#expect(event.text == nil)
#expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value)
#expect(event.consumedMods == .shift)
}
/// Every functional key with a Mac keycode. Their layout translations are
/// control characters (or nothing), which must never be attached as text
/// or reported as an unshifted codepoint; core encodes them from the key
/// enum. This also guards against a translation leaking through in some
/// other form, such as a PUA function-key character.
@Test(arguments: [
Ghostty.Input.Key.enter, .numpadEnter, .escape, .tab, .backspace,
.delete, .insert, .home, .end, .pageUp, .pageDown,
.arrowUp, .arrowDown, .arrowLeft, .arrowRight,
.contextMenu, .numLock,
.f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10,
.f11, .f12, .f13, .f14, .f15, .f16, .f17, .f18, .f19, .f20,
])
func controlKeyTranslationsProduceNoTextOrCodepoint(key: Ghostty.Input.Key) {
let event = Ghostty.Input.KeyEvent(
synthesizing: key, action: .press, mods: [], translationMods: [])
#expect(event.text == nil)
#expect(event.unshiftedCodepoint == 0)
}
}
/// The menu-shortcut path must translate the key equivalent with only the
/// command modifier applied: command can select a distinct layout table, while
/// the other modifiers live in the shortcut's modifier mask.
@MainActor
struct KeyboardShortcutTranslationTests {
@Test func keyEquivalentIgnoresNonCommandModifiers() throws {
var trigger = ghostty_input_trigger_s()
trigger.tag = GHOSTTY_TRIGGER_PHYSICAL
trigger.key.physical = GHOSTTY_KEY_BACKQUOTE
trigger.mods = ghostty_input_mods_e(
GHOSTTY_MODS_SUPER.rawValue | GHOSTTY_MODS_SHIFT.rawValue | GHOSTTY_MODS_ALT.rawValue)
let shortcut = try #require(Ghostty.keyboardShortcut(for: trigger))
let expected = try #require(KeyboardLayout.character(
for: 0x32, // W3C Backquote
modifiers: .command))
#expect(shortcut.key.character == expected)
#expect(shortcut.modifiers.contains(.shift))
#expect(shortcut.modifiers.contains(.option))
#expect(shortcut.modifiers.contains(.command))
}
}

View File

@@ -26,6 +26,21 @@ struct MenuShortcutManagerTests {
#expect(item.keyEquivalentModifierMask == .command)
}
@MainActor @Test func physicalBackquoteUsesCurrentKeyboardLayout() throws {
let config = try TemporaryConfig("keybind=super+backquote=toggle_quick_terminal")
let expected = try #require(KeyboardLayout.character(for: 0x32, modifiers: .command))
let item = NSMenuItem(title: "Quick Terminal", action: nil, keyEquivalent: "")
let manager = Ghostty.MenuShortcutManager()
manager.reset()
manager.syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: item)
#expect(item.keyEquivalent == String(expected))
#expect(item.keyEquivalentModifierMask == .command)
#expect(!item.allowsAutomaticKeyEquivalentLocalization)
#expect(!item.allowsAutomaticKeyEquivalentMirroring)
}
@Test(.bug("https://github.com/ghostty-org/ghostty/issues/11396", id: 11396))
func overrideDefault() async throws {
let config = try TemporaryConfig("keybind=super+h=goto_split:left")

View File

@@ -13,11 +13,6 @@ struct NormalizedMenuShortcutKeyTests {
#expect(key == nil)
}
@Test func lowercasesKeyEquivalent() {
let key = Key(keyEquivalent: "A", modifiers: .command)
#expect(key?.keyEquivalent == "a")
}
@Test func stripsNonShortcutModifiers() {
// .capsLock and .function should be stripped
let key = Key(keyEquivalent: "c", modifiers: [.command, .capsLock, .function])
@@ -74,6 +69,17 @@ struct NormalizedMenuShortcutKeyTests {
#expect(a != b)
}
@Test func physicalKeysUseKeyCodeIdentity() {
let physical = Key(physicalKeyCode: 0x32, modifiers: .command)
let same = Key(physicalKeyCode: 0x32, modifiers: .command)
let different = Key(physicalKeyCode: 0x31, modifiers: .command)
let unicode = Key(keyEquivalent: "`", modifiers: .command)
#expect(physical == same)
#expect(physical != different)
#expect(physical != unicode)
}
@Test func differentModifiersAreNotEqual() {
let a = Key(keyEquivalent: "c", modifiers: .command)
let b = Key(keyEquivalent: "c", modifiers: .option)

View File

@@ -0,0 +1,27 @@
import AppKit
import Testing
@testable import Ghostty
@MainActor
struct KeyboardLayoutTests {
@Test(arguments: [
0x00, // W3C KeyA
0x12, // W3C Digit1
0x32, // W3C Backquote
])
func characterHandlesKeyCode(keyCode: UInt16) {
#expect(KeyboardLayout.character(for: keyCode, modifiers: []) != nil)
}
@Test func characterRejectsInvalidKeyCode() {
#expect(KeyboardLayout.character(for: UInt16.max, modifiers: []) == nil)
}
@Test func characterAppliesModifiers() throws {
let keyCode: UInt16 = 0x00 // W3C KeyA
let unmodified = try #require(KeyboardLayout.character(for: keyCode, modifiers: []))
let shifted = try #require(KeyboardLayout.character(for: keyCode, modifiers: .shift))
#expect(shifted != unmodified)
#expect(String(shifted).lowercased() == String(unmodified).lowercased())
}
}

View File

@@ -60,44 +60,4 @@ struct TerminalViewContainerTests {
#expect(view.glassEffectView == nil)
}
}
#if compiler(>=6.2)
@Test func configChangeUpdatesGlass() async throws {
guard #available(macOS 26.0, *) else { return }
let view = await MockTerminalViewContainer {
EmptyView()
}
let config1 = MockConfig(backgroundBlur: .macosGlassRegular, backgroundColor: .clear, backgroundOpacity: 1)
await view.ghosttyConfigDidChange(config1, preferredBackgroundColor: nil)
let glassEffectView = await view.descendants(withClassName: "NSGlassEffectView").first as? NSGlassEffectView
let effectView = try #require(glassEffectView)
try await Task.sleep(nanoseconds: UInt64(1e8)) // wait for the view to be setup if needed
#expect(effectView.tintColor?.hexString == NSColor.clear.hexString)
// Test with same config but with different preferredBackgroundColor
await view.ghosttyConfigDidChange(config1, preferredBackgroundColor: .red)
#expect(effectView.tintColor?.hexString == NSColor.red.hexString)
// MARK: - Corner Radius
#expect(effectView.cornerRadius == 0)
await MainActor.run { view._windowCornerRadius = 10 }
// This won't change, unless ghosttyConfigDidChange is called
#expect(effectView.cornerRadius == 0)
await view.ghosttyConfigDidChange(config1, preferredBackgroundColor: .red)
#expect(effectView.cornerRadius == 10)
// MARK: - Glass Style
#expect(effectView.style == .regular)
let config2 = MockConfig(backgroundBlur: .macosGlassClear, backgroundColor: .clear, backgroundOpacity: 1)
await view.ghosttyConfigDidChange(config2, preferredBackgroundColor: .red)
#expect(effectView.style == .clear)
}
#endif // compiler(>=6.2)
}

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