Merge branch 'ghostty-org:main' into fix-tabbing-from-tab-overview

This commit is contained in:
Noah Gregory
2026-02-24 20:24:18 -05:00
committed by GitHub
92 changed files with 2824 additions and 679 deletions

12
.github/VOUCHED.td vendored
View File

@@ -19,11 +19,13 @@
# discussion by the author. Maintainers can denounce users by commenting
# "!denounce" or "!denounce [username]" on a discussion.
00-kat
aalhendi
abudvytis
aindriu80
alanmoyano
alexfeijoo44
andrejdaskalov
atomk
balazs-szucs
bennettp123
benodiwal
@@ -36,6 +38,7 @@ brentschroeter
charliie-dev
chernetskyi
craziestowl
curtismoncoq
d-dudas
daiimus
damyanbogoev
@@ -60,6 +63,7 @@ jacobsandlund
jake-stewart
jcollie
johnslavik
josephmart
jparise
juniqlim
kawarimidoll
@@ -68,8 +72,10 @@ khipp
kirwiisp
kjvdven
kloneets
koranir
kristina8888
kristofersoler
laxystem
liby
lonsagisawa
mahnokropotkinvich
@@ -81,7 +87,10 @@ mikailmm
misairuzame
mitchellh
miupa
mrmage
mtak
natesmyth
neo773
nicosuave
nwehg
oshdubh
@@ -94,9 +103,9 @@ piedrahitac
pluiedev
pouwerkerk
priyans-hu
prsweet
qwerasd205
reo101
rgehan
rmengelbrecht
rmunn
rockorager
@@ -104,6 +113,7 @@ rpfaeffle
secrus
silveirapf
slsrepo
sunshine-syz
tdslot
ticclick
tnagatomi

View File

@@ -130,10 +130,20 @@ jobs:
GHOSTTY_VERSION: ${{ needs.setup.outputs.version }}
GHOSTTY_BUILD: ${{ needs.setup.outputs.build }}
GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }}
ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2
with:
cache: |
xcode
path: |
/Users/runner/zig
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
@@ -174,7 +184,9 @@ jobs:
- name: Build Ghostty.app
run: |
cd macos
xcodebuild -target Ghostty -configuration Release
xcodebuild -target Ghostty -configuration Release \
COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \
COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES
# Add all our metadata to Info.plist so we can reference it later.
- name: Update Info.plist
@@ -219,6 +231,7 @@ jobs:
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate"
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app"
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework"
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/PlugIns/DockTilePlugin.plugin"
# Codesign the app bundle
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app

View File

@@ -37,6 +37,11 @@ jobs:
with:
# Important so that build number generation works
fetch-depth: 0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2
with:
path: |
/nix
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
with:
nix_path: nixpkgs=channel:nixos-unstable
@@ -219,6 +224,8 @@ jobs:
GHOSTTY_BUILD: ${{ needs.setup.outputs.build }}
GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }}
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -226,6 +233,14 @@ jobs:
# Important so that build number generation works
fetch-depth: 0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2
with:
cache: |
xcode
path: |
/Users/runner/zig
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
@@ -263,7 +278,9 @@ jobs:
- name: Build Ghostty.app
run: |
cd macos
xcodebuild -target Ghostty -configuration Release
xcodebuild -target Ghostty -configuration Release \
COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \
COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES
# We inject the "build number" as simply the number of commits since HEAD.
# This will be a monotonically always increasing build number that we use.
@@ -309,6 +326,7 @@ jobs:
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate"
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app"
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework"
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/PlugIns/DockTilePlugin.plugin"
# Codesign the app bundle
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app
@@ -462,6 +480,8 @@ jobs:
GHOSTTY_BUILD: ${{ needs.setup.outputs.build }}
GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }}
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -469,6 +489,14 @@ jobs:
# Important so that build number generation works
fetch-depth: 0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2
with:
cache: |
xcode
path: |
/Users/runner/zig
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
@@ -506,7 +534,9 @@ jobs:
- name: Build Ghostty.app
run: |
cd macos
xcodebuild -target Ghostty -configuration Release
xcodebuild -target Ghostty -configuration Release \
COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \
COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES
# We inject the "build number" as simply the number of commits since HEAD.
# This will be a monotonically always increasing build number that we use.
@@ -552,6 +582,7 @@ jobs:
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate"
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app"
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework"
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/PlugIns/DockTilePlugin.plugin"
# Codesign the app bundle
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app
@@ -646,6 +677,8 @@ jobs:
GHOSTTY_BUILD: ${{ needs.setup.outputs.build }}
GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }}
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -653,6 +686,14 @@ jobs:
# Important so that build number generation works
fetch-depth: 0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2
with:
cache: |
xcode
path: |
/Users/runner/zig
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
@@ -690,7 +731,9 @@ jobs:
- name: Build Ghostty.app
run: |
cd macos
xcodebuild -target Ghostty -configuration Release
xcodebuild -target Ghostty -configuration Release \
COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \
COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES
# We inject the "build number" as simply the number of commits since HEAD.
# This will be a monotonically always increasing build number that we use.
@@ -736,6 +779,7 @@ jobs:
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate"
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app"
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework"
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/PlugIns/DockTilePlugin.plugin"
# Codesign the app bundle
/usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app

View File

@@ -22,22 +22,29 @@ jobs:
# signaling that all other jobs can be skipped entirely.
skip: ${{ steps.determine.outputs.skip }}
# Path-based filters to gate specific linter/formatter jobs.
actions_pins: ${{ steps.filter.outputs.actions_pins }}
blueprints: ${{ steps.filter.outputs.blueprints }}
macos: ${{ steps.filter.outputs.macos }}
nix: ${{ steps.filter.outputs.nix }}
shell: ${{ steps.filter.outputs.shell }}
zig: ${{ steps.filter.outputs.zig }}
actions_pins: ${{ steps.filter_any.outputs.actions_pins }}
blueprints: ${{ steps.filter_any.outputs.blueprints }}
macos: ${{ steps.filter_any.outputs.macos }}
nix: ${{ steps.filter_any.outputs.nix }}
shell: ${{ steps.filter_any.outputs.shell }}
zig: ${{ steps.filter_any.outputs.zig }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter
id: filter_every
with:
token: ${{ secrets.GITHUB_TOKEN }}
predicate-quantifier: "every"
filters: |
code:
- '**'
- '!.github/VOUCHED.td'
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter_any
with:
token: ${{ secrets.GITHUB_TOKEN }}
filters: |
macos:
- '.swiftlint.yml'
- 'macos/**'
@@ -64,7 +71,7 @@ jobs:
- id: determine
name: Determine skip
run: |
if [ "${{ steps.filter.outputs.code }}" = "false" ]; then
if [ "${{ steps.filter_every.outputs.code }}" = "false" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
@@ -81,6 +88,7 @@ jobs:
- build-examples
- build-flatpak
- build-libghostty-vt
- build-libghostty-vt-android
- build-libghostty-vt-macos
- build-linux
- build-linux-libghostty
@@ -322,10 +330,21 @@ jobs:
target: [aarch64-macos, x86_64-macos, aarch64-ios]
runs-on: namespace-profile-ghostty-macos-tahoe
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2
with:
cache: |
xcode
path: |
/Users/runner/zig
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
@@ -343,6 +362,54 @@ jobs:
nix develop -c zig build lib-vt \
-Dtarget=${{ matrix.target }}
# lib-vt requires the Android NDK for Android builds
build-libghostty-vt-android:
strategy:
matrix:
target:
[aarch64-linux-android, x86_64-linux-android, arm-linux-androideabi]
runs-on: namespace-profile-ghostty-sm
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
ANDROID_NDK_VERSION: r29
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Setup Android NDK
uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1.6.0
id: setup-ndk
with:
ndk-version: r29
add-to-path: false
link-to-sdk: false
local-cache: true
- name: Build
run: |
nix develop -c zig build lib-vt \
-Dtarget=${{ matrix.target }}
env:
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
build-linux:
strategy:
fail-fast: false
@@ -534,10 +601,21 @@ jobs:
build-macos:
runs-on: namespace-profile-ghostty-macos-tahoe
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2
with:
cache: |
xcode
path: |
/Users/runner/zig
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
@@ -566,21 +644,38 @@ jobs:
# codesigning. IMPORTANT: this must NOT run in a Nix environment.
# Nix breaks xcodebuild so this has to be run outside.
- name: Build Ghostty.app
run: cd macos && xcodebuild -target Ghostty
run: |
cd macos
xcodebuild -target Ghostty \
COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \
COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES
# Build the iOS target without code signing just to verify it works.
- name: Build Ghostty iOS
run: |
cd macos
xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO"
xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" \
COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \
COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES
build-macos-freetype:
runs-on: namespace-profile-ghostty-macos-tahoe
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2
with:
cache: |
xcode
path: |
/Users/runner/zig
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
@@ -843,10 +938,21 @@ jobs:
test-macos:
runs-on: namespace-profile-ghostty-macos-tahoe
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2
with:
cache: |
xcode
path: |
/Users/runner/zig
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
@@ -1001,6 +1107,14 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2
with:
cache: |
xcode
path: |
/Users/runner/zig
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
@@ -1204,7 +1318,7 @@ jobs:
needs: [test, build-dist]
steps:
- name: Install and configure Namespace CLI
uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10
uses: namespacelabs/nscloud-setup@f378676225212387f1283f4da878712af2c4cd60 # v0.0.11
- name: Configure Namespace powered Buildx
uses: namespacelabs/nscloud-setup-buildx-action@f5814dcf37a16cce0624d5bec2ab879654294aa0 # v0.0.22

View File

@@ -14,7 +14,7 @@ jobs:
app-id: ${{ secrets.VOUCH_APP_ID }}
private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }}
- uses: mitchellh/vouch/action/check-issue@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0
- uses: mitchellh/vouch/action/check-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
with:
issue-number: ${{ github.event.issue.number }}
auto-close: true

View File

@@ -14,7 +14,7 @@ jobs:
app-id: ${{ secrets.VOUCH_APP_ID }}
private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }}
- uses: mitchellh/vouch/action/check-pr@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0
- uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
with:
pr-number: ${{ github.event.pull_request.number }}
auto-close: true

View File

@@ -22,7 +22,7 @@ jobs:
with:
token: ${{ steps.app-token.outputs.token }}
- uses: mitchellh/vouch/action/manage-by-discussion@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0
- uses: mitchellh/vouch/action/manage-by-discussion@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
with:
discussion-number: ${{ github.event.discussion.number }}
comment-node-id: ${{ github.event.comment.node_id }}

View File

@@ -22,7 +22,7 @@ jobs:
with:
token: ${{ steps.app-token.outputs.token }}
- uses: mitchellh/vouch/action/manage-by-issue@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0
- uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
with:
repo: ${{ github.repository }}
issue-id: ${{ github.event.issue.number }}

View File

@@ -23,7 +23,7 @@ jobs:
with:
token: ${{ steps.app-token.outputs.token }}
- uses: mitchellh/vouch/action/sync-codeowners@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0
- uses: mitchellh/vouch/action/sync-codeowners@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
with:
repo: ${{ github.repository }}
pull-request: "true"

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@
zig-cache/
.zig-cache/
zig-out/
/build.zig.zon.bak
/result*
/.nixos-test-history
example/*.wasm

View File

@@ -21,7 +21,7 @@
},
.z2d = .{
// vancluever/z2d
.url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz",
.url = "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz",
.hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
.lazy = true,
},
@@ -39,7 +39,7 @@
},
.uucode = .{
// jacobsandlund/uucode
.url = "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz",
.url = "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz",
.hash = "uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9",
},
.zig_wayland = .{
@@ -115,9 +115,10 @@
// Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.android_ndk = .{ .path = "./pkg/android-ndk" },
.iterm2_themes = .{
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz",
.hash = "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z",
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz",
.hash = "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF",
.lazy = true,
},
},

View File

@@ -1,124 +0,0 @@
.{
.name = .ghostty,
.version = "1.3.0-dev",
.paths = .{""},
.fingerprint = 0x64407a2a0b4147e5,
.minimum_zig_version = "0.15.2",
.dependencies = .{
// Zig libs
.libxev = .{
// mitchellh/libxev
.url = "https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz",
.hash = "libxev-0.0.0-86vtc4IcEwCqEYxEYoN_3KXmc6A9VLcm22aVImfvecYs",
.lazy = true,
},
.vaxis = .{
// rockorager/libvaxis
.url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
.hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS",
.lazy = true,
},
.z2d = .{
// vancluever/z2d
.url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz",
.hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T",
.lazy = true,
},
.zig_objc = .{
// mitchellh/zig-objc
.url = "https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz",
.hash = "zig_objc-0.0.0-Ir_Sp5gTAQCvxxR7oVIrPXxXwsfKgVP7_wqoOQrZjFeK",
.lazy = true,
},
.zig_js = .{
// mitchellh/zig-js
.url = "https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz",
.hash = "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi",
.lazy = true,
},
.uucode = .{
// jacobsandlund/uucode
.url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
.hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E",
},
.zig_wayland = .{
// codeberg ifreund/zig-wayland
.url = "https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz",
.hash = "wayland-0.5.0-dev-lQa1khrMAQDJDwYFKpdH3HizherB7sHo5dKMECfvxQHe",
.lazy = true,
},
.zf = .{
// natecraddock/zf
.url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
.hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh",
.lazy = true,
},
.gobject = .{
// https://github.com/ghostty-org/zig-gobject based on zig_gobject
// Temporary until we generate them at build time automatically.
.url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst",
.hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-",
.lazy = true,
},
// C libs
.cimgui = .{ .path = "./pkg/cimgui", .lazy = true },
.fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true },
.freetype = .{ .path = "./pkg/freetype", .lazy = true },
.gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true },
.harfbuzz = .{ .path = "./pkg/harfbuzz", .lazy = true },
.highway = .{ .path = "./pkg/highway", .lazy = true },
.libintl = .{ .path = "./pkg/libintl", .lazy = true },
.libpng = .{ .path = "./pkg/libpng", .lazy = true },
.macos = .{ .path = "./pkg/macos", .lazy = true },
.oniguruma = .{ .path = "./pkg/oniguruma", .lazy = true },
.opengl = .{ .path = "./pkg/opengl", .lazy = true },
.sentry = .{ .path = "./pkg/sentry", .lazy = true },
.simdutf = .{ .path = "./pkg/simdutf", .lazy = true },
.utfcpp = .{ .path = "./pkg/utfcpp", .lazy = true },
.wuffs = .{ .path = "./pkg/wuffs", .lazy = true },
.zlib = .{ .path = "./pkg/zlib", .lazy = true },
// Shader translation
.glslang = .{ .path = "./pkg/glslang", .lazy = true },
.spirv_cross = .{ .path = "./pkg/spirv-cross", .lazy = true },
// Wayland
.wayland = .{
.url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz",
.hash = "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t",
.lazy = true,
},
.wayland_protocols = .{
.url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz",
.hash = "N-V-__8AAKw-DAAaV8bOAAGqA0-oD7o-HNIlPFYKRXSPT03S",
.lazy = true,
},
.plasma_wayland_protocols = .{
.url = "https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566.tar.gz",
.hash = "N-V-__8AAKYZBAB-CFHBKs3u4JkeiT4BMvyHu3Y5aaWF3Bbs",
.lazy = true,
},
// Fonts
.jetbrains_mono = .{
.url = "https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz",
.hash = "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x",
.lazy = true,
},
.nerd_fonts_symbols_only = .{
.url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz",
.hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s",
.lazy = true,
},
// Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
.hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-",
.lazy = true,
},
},
}

10
build.zig.zon.json generated
View File

@@ -54,10 +54,10 @@
"url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
"hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs="
},
"N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z": {
"N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF": {
"name": "iterm2_themes",
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz",
"hash": "sha256-xN+3iQaN3uIJ/BzkgFxLojgHqeuz1htNcVjcjWR7Qjg="
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz",
"hash": "sha256-FCALuGoMgUq2lgnVALKAs5a20uuDXt8Gdt5KeJwKqP0="
},
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
"name": "jetbrains_mono",
@@ -121,7 +121,7 @@
},
"uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9": {
"name": "uucode",
"url": "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz",
"url": "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz",
"hash": "sha256-0KvuD0+L1urjwFF3fhbnxC2JZKqqAVWRxOVlcD9GX5U="
},
"vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": {
@@ -146,7 +146,7 @@
},
"z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ": {
"name": "z2d",
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz",
"url": "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz",
"hash": "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c="
},
"zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": {

10
build.zig.zon.nix generated
View File

@@ -171,11 +171,11 @@ in
};
}
{
name = "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z";
name = "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF";
path = fetchZigArtifact {
name = "iterm2_themes";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz";
hash = "sha256-xN+3iQaN3uIJ/BzkgFxLojgHqeuz1htNcVjcjWR7Qjg=";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz";
hash = "sha256-FCALuGoMgUq2lgnVALKAs5a20uuDXt8Gdt5KeJwKqP0=";
};
}
{
@@ -278,7 +278,7 @@ in
name = "uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9";
path = fetchZigArtifact {
name = "uucode";
url = "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz";
url = "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz";
hash = "sha256-0KvuD0+L1urjwFF3fhbnxC2JZKqqAVWRxOVlcD9GX5U=";
};
}
@@ -318,7 +318,7 @@ in
name = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ";
path = fetchZigArtifact {
name = "z2d";
url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz";
url = "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz";
hash = "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c=";
};
}

6
build.zig.zon.txt generated
View File

@@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
https://deps.files.ghostty.org/gettext-0.24.tar.gz
https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz
https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
@@ -21,16 +21,16 @@ https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e
https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e.tar.gz
https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz
https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz
https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz
https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz
https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz
https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz
https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz
https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz
https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz
https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz
https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz
https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz
https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz
https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz
https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz
https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz

View File

@@ -80,6 +80,7 @@
packageOverrides = pyfinal: pyprev: {
blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {};
ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {};
wcwidth = pyfinal.callPackage ./nix/pkgs/wcwidth.nix {};
};
};
};

View File

@@ -67,9 +67,9 @@
},
{
"type": "archive",
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz",
"dest": "vendor/p/N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z",
"sha256": "c4dfb789068ddee209fc1ce4805c4ba23807a9ebb3d61b4d7158dc8d647b4238"
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz",
"dest": "vendor/p/N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF",
"sha256": "14200bb86a0c814ab69609d500b280b396b6d2eb835edf0676de4a789c0aa8fd"
},
{
"type": "archive",
@@ -145,7 +145,7 @@
},
{
"type": "archive",
"url": "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz",
"url": "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz",
"dest": "vendor/p/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9",
"sha256": "d0abee0f4f8bd6eae3c051777e16e7c42d8964aaaa015591c4e565703f465f95"
},
@@ -175,7 +175,7 @@
},
{
"type": "archive",
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz",
"url": "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz",
"dest": "vendor/p/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
"sha256": "69f21da2efd5ee0937fe55c4d09e48afc4fb2f91a01ef167c8c275ae046797f7"
},

View File

@@ -11,18 +11,12 @@ disabled_rules:
- function_body_length
- line_length
- nesting
- no_fallthrough_only
- todo
- trailing_comma
- trailing_newline
- type_body_length
# TODO
- for_where
- force_cast
- multiple_closures_with_trailing_closure
- no_fallthrough_only
- switch_case_alignment
identifier_name:
min_length: 1
allowed_symbols: ["_"]

3
macos/AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# macOS Ghostty Application
- Use `swiftlint` for formatting and linting Swift code.

View File

@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSDockTilePlugIn</key>
<string>DockTilePlugin.plugin</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>

View File

@@ -10,6 +10,8 @@
29C15B1D2CDC3B2900520DD4 /* bat in Resources */ = {isa = PBXBuildFile; fileRef = 29C15B1C2CDC3B2000520DD4 /* bat */; };
55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; };
552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; };
819324582F24E78800A9ED8F /* DockTilePlugin.plugin in Copy DockTilePlugin */ = {isa = PBXBuildFile; fileRef = 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
819324642F24FF2100A9ED8F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
A51BFC272B30F1B800E92F16 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A51BFC262B30F1B800E92F16 /* Sparkle */; };
A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
@@ -35,6 +37,13 @@
remoteGlobalIDString = A5B30530299BEAAA0047F10C;
remoteInfo = Ghostty;
};
819324672F2502FB00A9ED8F /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A5B30529299BEAAA0047F10C /* Project object */;
proxyType = 1;
remoteGlobalIDString = 8193244C2F24E6C000A9ED8F;
remoteInfo = DockTilePlugin;
};
A54F45F72E1F047A0046BD5C /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A5B30529299BEAAA0047F10C /* Project object */;
@@ -44,12 +53,27 @@
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
819324572F24E74E00A9ED8F /* Copy DockTilePlugin */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
819324582F24E78800A9ED8F /* DockTilePlugin.plugin in Copy DockTilePlugin */,
);
name = "Copy DockTilePlugin";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
29C15B1C2CDC3B2000520DD4 /* bat */ = {isa = PBXFileReference; lastKnownFileType = folder; name = bat; path = "../zig-out/share/bat"; sourceTree = "<group>"; };
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = "<group>"; };
55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = "<group>"; };
552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; };
810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DockTilePlugin.plugin; sourceTree = BUILT_PRODUCTS_DIR; };
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = "<group>"; };
A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = "<group>"; };
@@ -70,6 +94,22 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
"Features/Custom App Icon/AppIcon.swift",
"Features/Custom App Icon/ColorizedGhosttyIcon.swift",
"Features/Custom App Icon/DockTilePlugin.swift",
"Features/Custom App Icon/Extensions/Notification+AppIcon.swift",
"Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift",
Ghostty/Ghostty.ConfigTypes.swift,
Ghostty/GhosttyPackageMeta.swift,
Helpers/CrossKit.swift,
"Helpers/Extensions/NSImage+Extension.swift",
"Helpers/Extensions/OSColor+Extension.swift",
);
target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */;
};
81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@@ -80,6 +120,7 @@
Features/About/About.xib,
Features/About/AboutController.swift,
Features/About/AboutView.swift,
Features/About/AboutViewModel.swift,
Features/About/CyclingIconView.swift,
"Features/App Intents/CloseTerminalIntent.swift",
"Features/App Intents/CommandPaletteIntent.swift",
@@ -96,11 +137,15 @@
Features/ClipboardConfirmation/ClipboardConfirmation.xib,
Features/ClipboardConfirmation/ClipboardConfirmationController.swift,
Features/ClipboardConfirmation/ClipboardConfirmationView.swift,
"Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift",
"Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift",
"Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift",
"Features/Command Palette/CommandPalette.swift",
"Features/Command Palette/TerminalCommandPalette.swift",
"Features/Custom App Icon/AppIcon.swift",
"Features/Custom App Icon/ColorizedGhosttyIcon.swift",
"Features/Custom App Icon/ColorizedGhosttyIconImage.swift",
"Features/Custom App Icon/ColorizedGhosttyIconView.swift",
"Features/Custom App Icon/DockTilePlugin.swift",
"Features/Custom App Icon/Extensions/Notification+AppIcon.swift",
"Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift",
"Features/Global Keybinds/GlobalEventTap.swift",
Features/QuickTerminal/QuickTerminal.xib,
Features/QuickTerminal/QuickTerminalController.swift,
@@ -190,6 +235,7 @@
Helpers/Private/CGS.swift,
Helpers/Private/Dock.swift,
Helpers/TabGroupCloseCoordinator.swift,
Helpers/TabTitleEditor.swift,
Helpers/VibrantLayer.m,
Helpers/Weak.swift,
);
@@ -199,6 +245,7 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
App/iOS/iOSApp.swift,
"Features/Custom App Icon/DockTilePlugin.swift",
"Ghostty/Surface View/SurfaceView_UIKit.swift",
);
target = A5B30530299BEAAA0047F10C /* Ghostty */;
@@ -207,7 +254,7 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
810ACCA02E9D3302004F8F92 /* GhosttyUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyUITests; sourceTree = "<group>"; };
81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = "<group>"; };
81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = "<group>"; };
A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
@@ -219,6 +266,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
8193244A2F24E6C000A9ED8F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A54F45F02E1F047A0046BD5C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -289,6 +343,7 @@
A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */,
A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */,
810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */,
8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */,
);
name = Products;
sourceTree = "<group>";
@@ -328,6 +383,25 @@
productReference = 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
8193244C2F24E6C000A9ED8F /* DockTilePlugin */ = {
isa = PBXNativeTarget;
buildConfigurationList = 819324512F24E6C000A9ED8F /* Build configuration list for PBXNativeTarget "DockTilePlugin" */;
buildPhases = (
819324492F24E6C000A9ED8F /* Sources */,
8193244A2F24E6C000A9ED8F /* Frameworks */,
8193244B2F24E6C000A9ED8F /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = DockTilePlugin;
packageProductDependencies = (
);
productName = DockTilePlugin;
productReference = 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */;
productType = "com.apple.product-type.bundle";
};
A54F45F22E1F047A0046BD5C /* GhosttyTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */;
@@ -359,10 +433,12 @@
A5B3052D299BEAAA0047F10C /* Sources */,
A5B3052E299BEAAA0047F10C /* Frameworks */,
A5B3052F299BEAAA0047F10C /* Resources */,
819324572F24E74E00A9ED8F /* Copy DockTilePlugin */,
);
buildRules = (
);
dependencies = (
819324682F2502FB00A9ED8F /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
81F82BC72E82815D001EDFA7 /* Sources */,
@@ -409,6 +485,9 @@
CreatedOnToolsVersion = 26.1;
TestTargetID = A5B30530299BEAAA0047F10C;
};
8193244C2F24E6C000A9ED8F = {
CreatedOnToolsVersion = 26.2;
};
A54F45F22E1F047A0046BD5C = {
CreatedOnToolsVersion = 26.0;
TestTargetID = A5B30530299BEAAA0047F10C;
@@ -440,6 +519,7 @@
targets = (
A5B30530299BEAAA0047F10C /* Ghostty */,
A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */,
8193244C2F24E6C000A9ED8F /* DockTilePlugin */,
A54F45F22E1F047A0046BD5C /* GhosttyTests */,
810ACC9E2E9D3301004F8F92 /* GhosttyUITests */,
);
@@ -454,6 +534,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
8193244B2F24E6C000A9ED8F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
819324642F24FF2100A9ED8F /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
A54F45F12E1F047A0046BD5C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -522,6 +610,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
819324492F24E6C000A9ED8F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A54F45EF2E1F047A0046BD5C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -551,6 +646,11 @@
target = A5B30530299BEAAA0047F10C /* Ghostty */;
targetProxy = 810ACCA52E9D3302004F8F92 /* PBXContainerItemProxy */;
};
819324682F2502FB00A9ED8F /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */;
targetProxy = 819324672F2502FB00A9ED8F /* PBXContainerItemProxy */;
};
A54F45F82E1F047A0046BD5C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A5B30530299BEAAA0047F10C /* Ghostty */;
@@ -683,7 +783,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
@@ -706,6 +806,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
@@ -728,6 +829,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
@@ -736,6 +838,93 @@
};
name = ReleaseLocal;
};
8193244E2F24E6C000A9ED8F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSPrincipalClass = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DOCK_TILE_PLUGIN DEBUG";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
WRAPPER_EXTENSION = plugin;
};
name = Debug;
};
8193244F2F24E6C000A9ED8F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSPrincipalClass = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DOCK_TILE_PLUGIN;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
WRAPPER_EXTENSION = plugin;
};
name = Release;
};
819324502F24E6C000A9ED8F /* ReleaseLocal */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSPrincipalClass = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DOCK_TILE_PLUGIN;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
WRAPPER_EXTENSION = plugin;
};
name = ReleaseLocal;
};
A54F45F92E1F047A0046BD5C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -1162,6 +1351,16 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = ReleaseLocal;
};
819324512F24E6C000A9ED8F /* Build configuration list for PBXNativeTarget "DockTilePlugin" */ = {
isa = XCConfigurationList;
buildConfigurations = (
8193244E2F24E6C000A9ED8F /* Debug */,
8193244F2F24E6C000A9ED8F /* Release */,
819324502F24E6C000A9ED8F /* ReleaseLocal */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = ReleaseLocal;
};
A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@@ -11,10 +11,8 @@ extension AppDelegate: Ghostty.Delegate {
continue
}
for surface in controller.surfaceTree {
if surface.id == id {
return surface
}
for surface in controller.surfaceTree where surface.id == id {
return surface
}
}

View File

@@ -380,13 +380,7 @@ class AppDelegate: NSObject,
if let why = event.attributeDescriptor(forKeyword: keyword) {
switch why.typeCodeValue {
case kAEShutDown:
fallthrough
case kAERestart:
fallthrough
case kAEReallyLogOut:
case kAEShutDown, kAERestart, kAEReallyLogOut:
return .terminateNow
default:
@@ -934,9 +928,8 @@ class AppDelegate: NSObject,
} else {
GlobalEventTap.shared.disable()
}
Task {
await updateAppIcon(from: config)
}
updateAppIcon(from: config)
}
/// Sync the appearance of our app with the theme specified in the config.
@@ -944,81 +937,15 @@ class AppDelegate: NSObject,
NSApplication.shared.appearance = .init(ghosttyConfig: config)
}
// Using AppIconActor to ensure this work
// happens synchronously in the background
@AppIconActor
private func updateAppIcon(from config: Ghostty.Config) async {
var appIcon: NSImage?
var appIconName: String? = config.macosIcon.rawValue
switch config.macosIcon {
case let icon where icon.assetName != nil:
appIcon = NSImage(named: icon.assetName!)!
case .custom:
if let userIcon = NSImage(contentsOfFile: config.macosCustomIcon) {
appIcon = userIcon
appIconName = config.macosCustomIcon
} else {
appIcon = nil // Revert back to official icon if invalid location
appIconName = nil // Discard saved icon name
}
case .customStyle:
// Discard saved icon name
// if no valid colours were found
appIconName = nil
guard let ghostColor = config.macosIconGhostColor else { break }
guard let screenColors = config.macosIconScreenColor else { break }
guard let icon = ColorizedGhosttyIcon(
screenColors: screenColors,
ghostColor: ghostColor,
frame: config.macosIconFrame
).makeImage() else { break }
appIcon = icon
let colorStrings = ([ghostColor] + screenColors).compactMap(\.hexString)
appIconName = (colorStrings + [config.macosIconFrame.rawValue])
.joined(separator: "_")
default:
// Discard saved icon name
appIconName = nil
private func updateAppIcon(from config: Ghostty.Config) {
// Since this is called after `DockTilePlugin` has been running,
// clean it up here to trigger a correct update of the current config.
UserDefaults.standard.removeObject(forKey: "CustomGhosttyIcon")
DispatchQueue.global().async {
UserDefaults.standard.appIcon = AppIcon(config: config)
DistributedNotificationCenter.default()
.postNotificationName(.ghosttyIconDidChange, object: nil, userInfo: nil, deliverImmediately: true)
}
// Only change the icon if it has actually changed from the current one,
// or if the app build has changed (e.g. after an update that reset the icon)
let cachedIconName = UserDefaults.standard.string(forKey: "CustomGhosttyIcon")
let cachedIconBuild = UserDefaults.standard.string(forKey: "CustomGhosttyIconBuild")
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
let buildChanged = cachedIconBuild != currentBuild
guard cachedIconName != appIconName || buildChanged else {
#if DEBUG
if appIcon == nil {
await MainActor.run {
// Changing the app bundle's icon will corrupt code signing.
// We only use the default blueprint icon for the dock,
// so developers don't need to clean and re-build every time.
NSApplication.shared.applicationIconImage = NSImage(named: "BlueprintImage")
}
}
#endif
return
}
// make it immutable, so Swift 6 won't complain
let newIcon = appIcon
let appPath = Bundle.main.bundlePath
guard NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: []) else { return }
NSWorkspace.shared.noteFileSystemChanged(appPath)
await MainActor.run {
self.appIcon = newIcon
NSApplication.shared.applicationIconImage = newIcon
}
UserDefaults.standard.set(appIconName, forKey: "CustomGhosttyIcon")
UserDefaults.standard.set(currentBuild, forKey: "CustomGhosttyIconBuild")
}
// MARK: - Restorable State
@@ -1082,10 +1009,8 @@ class AppDelegate: NSObject,
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? {
for c in TerminalController.all {
for view in c.surfaceTree {
if view.id == uuid {
return view
}
for view in c.surfaceTree where view.id == uuid {
return view
}
}
@@ -1357,8 +1282,3 @@ private enum QuickTerminalState {
/// Controller has been initialized.
case initialized(QuickTerminalController)
}
@globalActor
private actor AppIconActor: GlobalActor {
static let shared = AppIconActor()
}

View File

@@ -5,19 +5,21 @@ import SwiftUI
class AboutController: NSWindowController, NSWindowDelegate {
static let shared: AboutController = AboutController()
private let viewModel = AboutViewModel()
override var windowNibName: NSNib.Name? { "About" }
override func windowDidLoad() {
guard let window = window else { return }
window.center()
window.isMovableByWindowBackground = true
window.contentView = NSHostingView(rootView: AboutView())
window.contentView = NSHostingView(rootView: AboutView().environmentObject(viewModel))
}
// MARK: - Functions
func show() {
window?.makeKeyAndOrderFront(nil)
viewModel.startCyclingIcons()
}
func hide() {
@@ -38,4 +40,8 @@ class AboutController: NSWindowController, NSWindowDelegate {
@objc func cancel(_ sender: Any?) {
close()
}
func windowWillClose(_ notification: Notification) {
viewModel.stopCyclingIcons()
}
}

View File

@@ -0,0 +1,40 @@
import Combine
class AboutViewModel: ObservableObject {
@Published var currentIcon: Ghostty.MacOSIcon?
@Published var isHovering: Bool = false
private var timerCancellable: AnyCancellable?
private let icons: [Ghostty.MacOSIcon] = [
.official,
.blueprint,
.chalkboard,
.microchip,
.glass,
.holographic,
.paper,
.retro,
.xray,
]
func startCyclingIcons() {
timerCancellable = Timer.publish(every: 3, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
guard let self, !isHovering else { return }
advanceToNextIcon()
}
}
func stopCyclingIcons() {
timerCancellable = nil
currentIcon = nil
}
func advanceToNextIcon() {
let currentIndex = currentIcon.flatMap(icons.firstIndex(of:)) ?? 0
let nextIndex = icons.indexWrapping(after: currentIndex)
currentIcon = icons[nextIndex]
}
}

View File

@@ -1,50 +1,38 @@
import SwiftUI
import GhosttyKit
import Combine
/// A view that cycles through Ghostty's official icon variants.
struct CyclingIconView: View {
@State private var currentIcon: Ghostty.MacOSIcon = .official
@State private var isHovering: Bool = false
private let icons: [Ghostty.MacOSIcon] = [
.official,
.blueprint,
.chalkboard,
.microchip,
.glass,
.holographic,
.paper,
.retro,
.xray,
]
private let timerPublisher = Timer.publish(every: 3, on: .main, in: .common)
@EnvironmentObject var viewModel: AboutViewModel
var body: some View {
ZStack {
iconView(for: currentIcon)
.id(currentIcon)
iconView(for: viewModel.currentIcon)
.id(viewModel.currentIcon)
}
.animation(.easeInOut(duration: 0.5), value: currentIcon)
.animation(.easeInOut(duration: 0.5), value: viewModel.currentIcon)
.frame(height: 128)
.onReceive(timerPublisher.autoconnect()) { _ in
if !isHovering {
advanceToNextIcon()
}
}
.onHover { hovering in
isHovering = hovering
viewModel.isHovering = hovering
}
.onTapGesture {
advanceToNextIcon()
viewModel.advanceToNextIcon()
}
.contextMenu {
if let currentIcon = viewModel.currentIcon {
Button("Copy Icon Config") {
NSPasteboard.general.setString("macos-icon = \(currentIcon.rawValue)", forType: .string)
}
}
}
.help("macos-icon = \(currentIcon.rawValue)")
.accessibilityLabel("Ghostty Application Icon")
.accessibilityHint("Click to cycle through icon variants")
}
@ViewBuilder
private func iconView(for icon: Ghostty.MacOSIcon) -> some View {
let iconImage: Image = switch icon.assetName {
private func iconView(for icon: Ghostty.MacOSIcon?) -> some View {
let iconImage: Image = switch icon?.assetName {
case let assetName?: Image(assetName)
case nil: ghosttyIconImage()
}
@@ -53,10 +41,4 @@ struct CyclingIconView: View {
.resizable()
.aspectRatio(contentMode: .fit)
}
private func advanceToNextIcon() {
let currentIndex = icons.firstIndex(of: currentIcon) ?? 0
let nextIndex = icons.indexWrapping(after: currentIndex)
currentIcon = icons[nextIndex]
}
}

View File

@@ -1,55 +0,0 @@
import Cocoa
struct ColorizedGhosttyIcon {
/// The colors that make up the gradient of the screen.
let screenColors: [NSColor]
/// The color of the ghost.
let ghostColor: NSColor
/// The frame type to use
let frame: Ghostty.MacOSIconFrame
/// Make a custom colorized ghostty icon.
func makeImage() -> NSImage? {
// All of our layers (not in order)
guard let screen = NSImage(named: "CustomIconScreen") else { return nil }
guard let screenMask = NSImage(named: "CustomIconScreenMask") else { return nil }
guard let ghost = NSImage(named: "CustomIconGhost") else { return nil }
guard let crt = NSImage(named: "CustomIconCRT") else { return nil }
guard let gloss = NSImage(named: "CustomIconGloss") else { return nil }
let baseName = switch frame {
case .aluminum: "CustomIconBaseAluminum"
case .beige: "CustomIconBaseBeige"
case .chrome: "CustomIconBaseChrome"
case .plastic: "CustomIconBasePlastic"
}
guard let base = NSImage(named: baseName) else { return nil }
// Apply our color in various ways to our layers.
// NOTE: These functions are not built-in, they're implemented as an extension
// to NSImage in NSImage+Extension.swift.
guard let screenGradient = screenMask.gradient(colors: screenColors) else { return nil }
guard let tintedGhost = ghost.tint(color: ghostColor) else { return nil }
// Combine our layers using the proper blending modes
return.combine(images: [
base,
screen,
screenGradient,
ghost,
tintedGhost,
crt,
gloss,
], blendingModes: [
.normal,
.normal,
.color,
.normal,
.color,
.overlay,
.normal,
])
}
}

View File

@@ -0,0 +1,86 @@
import AppKit
import System
/// The icon style for the Ghostty App.
enum AppIcon: Equatable, Codable {
case official
case blueprint
case chalkboard
case glass
case holographic
case microchip
case paper
case retro
case xray
/// Save full image data to avoid sandboxing issues
case custom(_ iconFile: Data)
case customStyle(_ icon: ColorizedGhosttyIcon)
#if !DOCK_TILE_PLUGIN
init?(config: Ghostty.Config) {
switch config.macosIcon {
case .official:
return nil
case .blueprint:
self = .blueprint
case .chalkboard:
self = .chalkboard
case .glass:
self = .glass
case .holographic:
self = .holographic
case .microchip:
self = .microchip
case .paper:
self = .paper
case .retro:
self = .retro
case .xray:
self = .xray
case .custom:
if let data = try? Data(contentsOf: URL(filePath: config.macosCustomIcon, relativeTo: nil)) {
self = .custom(data)
} else {
return nil
}
case .customStyle:
// Discard saved icon name
// if no valid colours were found
guard
let ghostColor = config.macosIconGhostColor,
let screenColors = config.macosIconScreenColor
else {
return nil
}
self = .customStyle(ColorizedGhosttyIcon(screenColors: screenColors, ghostColor: ghostColor, frame: config.macosIconFrame))
}
}
#endif
func image(in bundle: Bundle) -> NSImage? {
switch self {
case .official:
return nil
case .blueprint:
return bundle.image(forResource: "BlueprintImage")!
case .chalkboard:
return bundle.image(forResource: "ChalkboardImage")!
case .glass:
return bundle.image(forResource: "GlassImage")!
case .holographic:
return bundle.image(forResource: "HolographicImage")!
case .microchip:
return bundle.image(forResource: "MicrochipImage")!
case .paper:
return bundle.image(forResource: "PaperImage")!
case .retro:
return bundle.image(forResource: "RetroImage")!
case .xray:
return bundle.image(forResource: "XrayImage")!
case let .custom(file):
return NSImage(data: file)
case let .customStyle(customIcon):
return customIcon.makeImage(in: bundle)
}
}
}

View File

@@ -0,0 +1,115 @@
import Cocoa
struct ColorizedGhosttyIcon {
/// The colors that make up the gradient of the screen.
let screenColors: [NSColor]
/// The color of the ghost.
let ghostColor: NSColor
/// The frame type to use
let frame: Ghostty.MacOSIconFrame
/// Make a custom colorized ghostty icon.
func makeImage(in bundle: Bundle) -> NSImage? {
// All of our layers (not in order)
guard let screen = bundle.image(forResource: "CustomIconScreen") else { return nil }
guard let screenMask = bundle.image(forResource: "CustomIconScreenMask") else { return nil }
guard let ghost = bundle.image(forResource: "CustomIconGhost") else { return nil }
guard let crt = bundle.image(forResource: "CustomIconCRT") else { return nil }
guard let gloss = bundle.image(forResource: "CustomIconGloss") else { return nil }
let baseName = switch frame {
case .aluminum: "CustomIconBaseAluminum"
case .beige: "CustomIconBaseBeige"
case .chrome: "CustomIconBaseChrome"
case .plastic: "CustomIconBasePlastic"
}
guard let base = bundle.image(forResource: baseName) else { return nil }
// Apply our color in various ways to our layers.
// NOTE: These functions are not built-in, they're implemented as an extension
// to NSImage in NSImage+Extension.swift.
guard let screenGradient = screenMask.gradient(colors: screenColors) else { return nil }
guard let tintedGhost = ghost.tint(color: ghostColor) else { return nil }
// Combine our layers using the proper blending modes
return.combine(images: [
base,
screen,
screenGradient,
ghost,
tintedGhost,
crt,
gloss,
], blendingModes: [
.normal,
.normal,
.color,
.normal,
.color,
.overlay,
.normal,
])
}
}
// MARK: Codable
extension ColorizedGhosttyIcon: Codable {
private enum CodingKeys: String, CodingKey {
case version
case screenColors
case ghostColor
case frame
static let currentVersion: Int = 1
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// If no version exists then this is the legacy v0 format.
let version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 0
guard version == 0 || version == CodingKeys.currentVersion else {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unsupported ColorizedGhosttyIcon version: \(version)"
)
)
}
let screenColorHexes = try container.decode([String].self, forKey: .screenColors)
let screenColors = screenColorHexes.compactMap(NSColor.init(hex:))
let ghostColorHex = try container.decode(String.self, forKey: .ghostColor)
guard let ghostColor = NSColor(hex: ghostColorHex) else {
throw DecodingError.dataCorruptedError(
forKey: .ghostColor,
in: container,
debugDescription: "Failed to decode ghost color from \(ghostColorHex)"
)
}
let frame = try container.decode(Ghostty.MacOSIconFrame.self, forKey: .frame)
self.init(screenColors: screenColors, ghostColor: ghostColor, frame: frame)
}
func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(CodingKeys.currentVersion, forKey: .version)
try container.encode(screenColors.compactMap(\.hexString), forKey: .screenColors)
try container.encode(ghostColor.hexString, forKey: .ghostColor)
try container.encode(frame, forKey: .frame)
}
}
// MARK: Equatable
extension ColorizedGhosttyIcon: Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.frame == rhs.frame &&
lhs.screenColors.compactMap(\.hexString) == rhs.screenColors.compactMap(\.hexString) &&
lhs.ghostColor.hexString == rhs.ghostColor.hexString
}
}

View File

@@ -8,6 +8,6 @@ struct ColorizedGhosttyIconView: View {
screenColors: [.purple, .blue],
ghostColor: .yellow,
frame: .aluminum
).makeImage()!)
).makeImage(in: .main)!)
}
}

View File

@@ -0,0 +1,118 @@
import AppKit
class DockTilePlugin: NSObject, NSDockTilePlugIn {
// WARNING: An instance of this class is alive as long as Ghostty's icon is
// in the doc (running or not!), so keep any state and processing to a
// minimum to respect resource usage.
private let pluginBundle = Bundle(for: DockTilePlugin.self)
// Separate defaults based on debug vs release builds so we can test icons
// without messing up releases.
#if DEBUG
private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty.debug")
#else
private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty")
#endif
private var iconChangeObserver: Any?
/// The path to the Ghostty.app, determined based on the bundle path of this plugin.
var ghosttyAppPath: String {
var url = pluginBundle.bundleURL
// Remove "/Contents/PlugIns/DockTilePlugIn.bundle" from the bundle URL to reach Ghostty.app.
while url.lastPathComponent != "Ghostty.app", !url.lastPathComponent.isEmpty {
url.deleteLastPathComponent()
}
return url.path
}
/// The primary NSDockTilePlugin function.
func setDockTile(_ dockTile: NSDockTile?) {
// If no dock tile or no access to Ghostty defaults, we can't do anything.
guard let dockTile, let ghosttyUserDefaults else {
iconChangeObserver = nil
return
}
// Try to restore the previous icon on launch.
iconDidChange(ghosttyUserDefaults.appIcon, dockTile: dockTile)
// Setup a new observer for when the icon changes so we can update. This message
// is sent by the primary Ghostty app.
iconChangeObserver = DistributedNotificationCenter
.default()
.publisher(for: .ghosttyIconDidChange)
.map { [weak self] _ in self?.ghosttyUserDefaults?.appIcon }
.receive(on: DispatchQueue.global())
.sink { [weak self] newIcon in self?.iconDidChange(newIcon, dockTile: dockTile) }
}
private func iconDidChange(_ newIcon: AppIcon?, dockTile: NSDockTile) {
guard let appIcon = newIcon?.image(in: pluginBundle) else {
resetIcon(dockTile: dockTile)
return
}
let appBundlePath = self.ghosttyAppPath
NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath)
NSWorkspace.shared.noteFileSystemChanged(appBundlePath)
dockTile.setIcon(appIcon)
}
/// Reset the application icon and dock tile icon to the default.
private func resetIcon(dockTile: NSDockTile) {
let appBundlePath = self.ghosttyAppPath
let appIcon: NSImage
if #available(macOS 26.0, *) {
// Reset to the default (glassy) icon.
NSWorkspace.shared.setIcon(nil, forFile: appBundlePath)
#if DEBUG
// Use the `Blueprint` icon to distinguish Debug from Release builds.
appIcon = pluginBundle.image(forResource: "BlueprintImage")!
#else
// Get the composed icon from the app bundle.
if let iconRep = NSWorkspace.shared.icon(forFile: appBundlePath)
.bestRepresentation(
for: CGRect(origin: .zero, size: dockTile.size),
context: nil,
hints: nil
) {
appIcon = NSImage(size: dockTile.size)
appIcon.addRepresentation(iconRep)
} else {
// If something unexpected happens on macOS 26,
// fall back to a bundled icon.
appIcon = pluginBundle.image(forResource: "AppIconImage")!
}
#endif
} else {
// Use the bundled icon to keep the corner radius consistent with pre-Tahoe apps.
appIcon = pluginBundle.image(forResource: "AppIconImage")!
NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath)
}
// Notify Finder/Dock so icon caches refresh immediately.
NSWorkspace.shared.noteFileSystemChanged(appBundlePath)
dockTile.setIcon(appIcon)
}
}
private extension NSDockTile {
func setIcon(_ newIcon: NSImage) {
// Update the Dock tile on the main thread.
DispatchQueue.main.async {
let iconView = NSImageView(frame: CGRect(origin: .zero, size: self.size))
iconView.wantsLayer = true
iconView.image = newIcon
self.contentView = iconView
self.display()
}
}
}
// This is required because of the DispatchQueue call above. This doesn't
// feel right but I don't know a better way to solve this.
extension NSDockTile: @unchecked @retroactive Sendable {}

View File

@@ -0,0 +1,5 @@
import AppKit
extension Notification.Name {
static let ghosttyIconDidChange = Notification.Name("com.mitchellh.ghostty.iconDidChange")
}

View File

@@ -0,0 +1,29 @@
import AppKit
extension UserDefaults {
private static let customIconKeyOld = "CustomGhosttyIcon"
private static let customIconKeyNew = "CustomGhosttyIcon2"
var appIcon: AppIcon? {
get {
// Always remove our old pre-docktileplugin values.
defer {
removeObject(forKey: Self.customIconKeyOld)
}
// Check if we have the new key for our dock tile plugin format.
guard let data = data(forKey: Self.customIconKeyNew) else {
return nil
}
return try? JSONDecoder().decode(AppIcon.self, from: data)
}
set {
guard let newData = try? JSONEncoder().encode(newValue) else {
return
}
set(newData, forKey: Self.customIconKeyNew)
}
}
}

View File

@@ -7,14 +7,14 @@ enum QuickTerminalSpaceBehavior {
init?(fromGhosttyConfig string: String) {
switch string {
case "move":
self = .move
case "move":
self = .move
case "remain":
self = .remain
case "remain":
self = .remain
default:
return nil
default:
return nil
}
}
@@ -25,12 +25,12 @@ enum QuickTerminalSpaceBehavior {
]
switch self {
case .move:
// We want this to move the window to the active space.
return NSWindow.CollectionBehavior([.canJoinAllSpaces] + commonBehavior)
case .remain:
// We want this to remain the window in the current space.
return NSWindow.CollectionBehavior([.moveToActiveSpace] + commonBehavior)
case .move:
// We want this to move the window to the active space.
return NSWindow.CollectionBehavior([.canJoinAllSpaces] + commonBehavior)
case .remain:
// We want this to remain the window in the current space.
return NSWindow.CollectionBehavior([.moveToActiveSpace] + commonBehavior)
}
}
}

View File

@@ -2,8 +2,8 @@ import SwiftUI
struct SecureInputOverlay: View {
// Animations
@State private var shadowAngle: Angle = .degrees(0)
@State private var shadowWidth: CGFloat = 6
@State private var gradientAngle: Angle = .degrees(0)
@State private var gradientOpacity: CGFloat = 0.5
// Popover explainer text
@State private var isPopover = false
@@ -20,18 +20,32 @@ struct SecureInputOverlay: View {
.foregroundColor(.primary)
.padding(5)
.background(
RoundedRectangle(cornerRadius: 12)
Rectangle()
.fill(.background)
.innerShadow(
using: RoundedRectangle(cornerRadius: 12),
stroke: AngularGradient(
gradient: Gradient(colors: [.cyan, .blue, .yellow, .blue, .cyan]),
center: .center,
angle: shadowAngle
),
width: shadowWidth
.overlay(
Rectangle()
.fill(
AngularGradient(
gradient: Gradient(
colors: [.cyan, .blue, .yellow, .blue, .cyan]
),
center: .center,
angle: gradientAngle
)
)
.blur(radius: 4, opaque: true)
.mask(
RadialGradient(
colors: [.clear, .black],
center: .center,
startRadius: 0,
endRadius: 25
)
)
.opacity(gradientOpacity)
)
)
.mask(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.gray, lineWidth: 1)
@@ -57,11 +71,11 @@ struct SecureInputOverlay: View {
}
.onAppear {
withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: false)) {
shadowAngle = .degrees(360)
gradientAngle = .degrees(360)
}
withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: true)) {
shadowWidth = 12
gradientOpacity = 1
}
}
}

View File

@@ -1265,6 +1265,17 @@ class BaseTerminalController: NSWindowController,
}
@IBAction func changeTabTitle(_ sender: Any) {
if let targetWindow = window {
let inlineHostWindow =
targetWindow.tabbedWindows?
.first(where: { $0.tabBarView != nil }) as? TerminalWindow
?? (targetWindow as? TerminalWindow)
if let inlineHostWindow, inlineHostWindow.beginInlineTabTitleEdit(for: targetWindow) {
return
}
}
promptTabTitle()
}

View File

@@ -131,11 +131,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
// Find the focused surface in surfaceTree
if let focusedStr = state.focusedSurface {
var foundView: Ghostty.SurfaceView?
for view in c.surfaceTree {
if view.id.uuidString == focusedStr {
foundView = view
break
}
for view in c.surfaceTree where view.id.uuidString == focusedStr {
foundView = view
break
}
if let view = foundView {

View File

@@ -6,9 +6,8 @@ import SwiftUI
class TerminalViewContainer<ViewModel: TerminalViewModel>: NSView {
private let terminalView: NSView
/// Glass effect view for liquid glass background when transparency is enabled
/// Combined glass effect and inactive tint overlay view
private var glassEffectView: NSView?
private var glassTopConstraint: NSLayoutConstraint?
private var derivedConfig: DerivedConfig
init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) {
@@ -27,6 +26,10 @@ class TerminalViewContainer<ViewModel: TerminalViewModel>: NSView {
fatalError("init(coder:) has not been implemented")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
/// To make ``TerminalController/DefaultSize/contentIntrinsicSize``
/// work in ``TerminalController/windowDidLoad()``,
/// we override this to provide the correct size.
@@ -50,6 +53,20 @@ class TerminalViewContainer<ViewModel: TerminalViewModel>: NSView {
name: .ghosttyConfigDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidBecomeKey(_:)),
name: NSWindow.didBecomeKeyNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidResignKey(_:)),
name: NSWindow.didResignKeyNotification,
object: nil
)
}
override func viewDidMoveToWindow() {
@@ -72,36 +89,139 @@ class TerminalViewContainer<ViewModel: TerminalViewModel>: NSView {
derivedConfig = newValue
DispatchQueue.main.async(execute: updateGlassEffectIfNeeded)
}
@objc private func windowDidBecomeKey(_ notification: Notification) {
guard let window = notification.object as? NSWindow,
window == self.window else { return }
updateGlassTintOverlay(isKeyWindow: true)
}
@objc private func windowDidResignKey(_ notification: Notification) {
guard let window = notification.object as? NSWindow,
window == self.window else { return }
updateGlassTintOverlay(isKeyWindow: false)
}
}
// MARK: Glass
/// An `NSView` that contains a liquid glass background effect and
/// an inactive-window tint overlay.
#if compiler(>=6.2)
@available(macOS 26.0, *)
private class TerminalGlassView: NSView {
private let glassEffectView: NSGlassEffectView
private var glassTopConstraint: NSLayoutConstraint?
private let tintOverlay: NSView
private var tintTopConstraint: NSLayoutConstraint?
init(topOffset: CGFloat) {
self.glassEffectView = NSGlassEffectView()
self.tintOverlay = NSView()
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
// Glass effect view fills this view.
glassEffectView.translatesAutoresizingMaskIntoConstraints = false
addSubview(glassEffectView)
glassTopConstraint = glassEffectView.topAnchor.constraint(
equalTo: topAnchor,
constant: topOffset
)
if let glassTopConstraint {
NSLayoutConstraint.activate([
glassTopConstraint,
glassEffectView.leadingAnchor.constraint(equalTo: leadingAnchor),
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)
tintTopConstraint = tintOverlay.topAnchor.constraint(
equalTo: topAnchor,
constant: topOffset
)
if let tintTopConstraint {
NSLayoutConstraint.activate([
tintTopConstraint,
tintOverlay.leadingAnchor.constraint(equalTo: leadingAnchor),
tintOverlay.bottomAnchor.constraint(equalTo: bottomAnchor),
tintOverlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
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.
func configure(
style: NSGlassEffectView.Style,
backgroundColor: NSColor,
backgroundOpacity: Double,
cornerRadius: CGFloat?,
isKeyWindow: Bool
) {
glassEffectView.style = style
glassEffectView.tintColor = backgroundColor.withAlphaComponent(backgroundOpacity)
if let cornerRadius {
glassEffectView.cornerRadius = cornerRadius
}
updateKeyStatus(isKeyWindow, backgroundColor: backgroundColor)
}
/// Updates the top inset offset for both the glass effect and tint overlay.
/// Call this when the safe area insets change (e.g., during layout).
func updateTopInset(_ offset: CGFloat) {
glassTopConstraint?.constant = offset
tintTopConstraint?.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)
private extension TerminalViewContainer {
#if compiler(>=6.2)
@available(macOS 26.0, *)
func addGlassEffectViewIfNeeded() -> NSGlassEffectView? {
if let existed = glassEffectView as? NSGlassEffectView {
func addGlassEffectViewIfNeeded() -> TerminalGlassView? {
if let existed = glassEffectView as? TerminalGlassView {
updateGlassEffectTopInsetIfNeeded()
return existed
}
guard let themeFrameView = window?.contentView?.superview else {
return nil
}
let effectView = NSGlassEffectView()
let effectView = TerminalGlassView(topOffset: -themeFrameView.safeAreaInsets.top)
addSubview(effectView, positioned: .below, relativeTo: terminalView)
effectView.translatesAutoresizingMaskIntoConstraints = false
glassTopConstraint = effectView.topAnchor.constraint(
equalTo: topAnchor,
constant: -themeFrameView.safeAreaInsets.top
)
if let glassTopConstraint {
NSLayoutConstraint.activate([
glassTopConstraint,
effectView.leadingAnchor.constraint(equalTo: leadingAnchor),
effectView.bottomAnchor.constraint(equalTo: bottomAnchor),
effectView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
}
NSLayoutConstraint.activate([
effectView.topAnchor.constraint(equalTo: topAnchor),
effectView.leadingAnchor.constraint(equalTo: leadingAnchor),
effectView.bottomAnchor.constraint(equalTo: bottomAnchor),
effectView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
glassEffectView = effectView
return effectView
}
@@ -112,26 +232,35 @@ private extension TerminalViewContainer {
guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else {
glassEffectView?.removeFromSuperview()
glassEffectView = nil
glassTopConstraint = nil
return
}
guard let effectView = addGlassEffectViewIfNeeded() else {
return
}
let style: NSGlassEffectView.Style
switch derivedConfig.backgroundBlur {
case .macosGlassRegular:
effectView.style = NSGlassEffectView.Style.regular
style = NSGlassEffectView.Style.regular
case .macosGlassClear:
effectView.style = NSGlassEffectView.Style.clear
style = NSGlassEffectView.Style.clear
default:
break
style = NSGlassEffectView.Style.regular
}
let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor)
effectView.tintColor = backgroundColor
.withAlphaComponent(derivedConfig.backgroundOpacity)
if let window, window.responds(to: Selector(("_cornerRadius"))), let cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat {
effectView.cornerRadius = cornerRadius
var cornerRadius: CGFloat?
if let window, window.responds(to: Selector(("_cornerRadius"))) {
cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat
}
effectView.configure(
style: style,
backgroundColor: backgroundColor,
backgroundOpacity: derivedConfig.backgroundOpacity,
cornerRadius: cornerRadius,
isKeyWindow: window?.isKeyWindow ?? true
)
#endif // compiler(>=6.2)
}
@@ -142,7 +271,16 @@ private extension TerminalViewContainer {
}
guard glassEffectView != nil else { return }
guard let themeFrameView = window?.contentView?.superview else { return }
glassTopConstraint?.constant = -themeFrameView.safeAreaInsets.top
(glassEffectView as? TerminalGlassView)?.updateTopInset(-themeFrameView.safeAreaInsets.top)
#endif // compiler(>=6.2)
}
func updateGlassTintOverlay(isKeyWindow: Bool) {
#if compiler(>=6.2)
guard #available(macOS 26.0, *) else { return }
guard glassEffectView != nil else { return }
let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor)
(glassEffectView as? TerminalGlassView)?.updateKeyStatus(isKeyWindow, backgroundColor: backgroundColor)
#endif // compiler(>=6.2)
}

View File

@@ -37,6 +37,12 @@ class TerminalWindow: NSWindow {
/// Sets up our tab context menu
private var tabMenuObserver: NSObjectProtocol?
/// Handles inline tab title editing for this host window.
private lazy var tabTitleEditor = TabTitleEditor(
hostWindow: self,
delegate: self
)
/// Whether this window supports the update accessory. If this is false, then views within this
/// window should determine how to show update notifications.
var supportsUpdateAccessory: Bool {
@@ -174,7 +180,16 @@ class TerminalWindow: NSWindow {
override var canBecomeKey: Bool { return true }
override var canBecomeMain: Bool { return true }
override func sendEvent(_ event: NSEvent) {
if tabTitleEditor.handleDoubleClick(event) {
return
}
super.sendEvent(event)
}
override func close() {
tabTitleEditor.finishEditing(commit: true)
NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self)
super.close()
}
@@ -207,6 +222,21 @@ class TerminalWindow: NSWindow {
viewModel.isMainWindow = false
}
@discardableResult
func beginInlineTabTitleEdit(for targetWindow: NSWindow) -> Bool {
tabTitleEditor.beginEditing(for: targetWindow)
}
@objc private func renameTabFromContextMenu(_ sender: NSMenuItem) {
let targetWindow = sender.representedObject as? NSWindow ?? self
if beginInlineTabTitleEdit(for: targetWindow) {
return
}
guard let targetController = targetWindow.windowController as? BaseTerminalController else { return }
targetController.promptTabTitle()
}
override func mergeAllWindows(_ sender: Any?) {
super.mergeAllWindows(sender)
@@ -731,10 +761,11 @@ extension TerminalWindow {
separator.identifier = Self.tabColorSeparatorIdentifier
menu.addItem(separator)
// Change Title...
let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "")
// Rename Tab...
let changeTitleItem = NSMenuItem(title: "Rename Tab...", action: #selector(TerminalWindow.renameTabFromContextMenu(_:)), keyEquivalent: "")
changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier
changeTitleItem.target = target
changeTitleItem.target = self
changeTitleItem.representedObject = target?.window
changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line")
menu.addItem(changeTitleItem)
@@ -760,3 +791,42 @@ private func makeTabColorPaletteView(
hostingView.frame.size = hostingView.intrinsicContentSize
return hostingView
}
// MARK: - Inline Tab Title Editing
extension TerminalWindow: TabTitleEditorDelegate {
func tabTitleEditor(
_ editor: TabTitleEditor,
canRenameTabFor targetWindow: NSWindow
) -> Bool {
targetWindow.windowController is BaseTerminalController
}
func tabTitleEditor(
_ editor: TabTitleEditor,
titleFor targetWindow: NSWindow
) -> String {
guard let targetController = targetWindow.windowController as? BaseTerminalController else {
return targetWindow.title
}
return targetController.titleOverride ?? targetWindow.title
}
func tabTitleEditor(
_ editor: TabTitleEditor,
didCommitTitle editedTitle: String,
for targetWindow: NSWindow
) {
guard let targetController = targetWindow.windowController as? BaseTerminalController else { return }
targetController.titleOverride = editedTitle.isEmpty ? nil : editedTitle
}
func tabTitleEditor(
_ editor: TabTitleEditor,
performFallbackRenameFor targetWindow: NSWindow
) {
guard let targetController = targetWindow.windowController as? BaseTerminalController else { return }
targetController.promptTabTitle()
}
}

View File

@@ -47,7 +47,7 @@ struct UpdatePill: View {
} else {
showPopover.toggle()
}
}) {
}, label: {
HStack(spacing: 6) {
UpdateBadge(model: model)
.frame(width: 14, height: 14)
@@ -66,7 +66,7 @@ struct UpdatePill: View {
)
.foregroundColor(model.foregroundColor)
.contentShape(Capsule())
}
})
.buttonStyle(.plain)
.help(model.text)
.accessibilityLabel(model.text)

View File

@@ -694,7 +694,10 @@ extension Ghostty {
if let candidate = URL(string: action.url), candidate.scheme != nil {
url = candidate
} else {
url = URL(filePath: action.url)
// Expand ~ to the user's home directory so that file paths
// like ~/Documents/file.txt resolve correctly.
let expandedPath = NSString(string: action.url).standardizingPath
url = URL(filePath: expandedPath)
}
switch action.kind {

View File

@@ -0,0 +1,43 @@
// This file contains the configuration types for Ghostty so that alternate targets
// can get typed information without depending on all the dependencies of GhosttyKit.
extension Ghostty {
/// macos-icon
enum MacOSIcon: String, Sendable {
case official
case blueprint
case chalkboard
case glass
case holographic
case microchip
case paper
case retro
case xray
case custom
case customStyle = "custom-style"
/// Bundled asset name for built-in icons
var assetName: String? {
switch self {
case .official: return nil
case .blueprint: return "BlueprintImage"
case .chalkboard: return "ChalkboardImage"
case .microchip: return "MicrochipImage"
case .glass: return "GlassImage"
case .holographic: return "HolographicImage"
case .paper: return "PaperImage"
case .retro: return "RetroImage"
case .xray: return "XrayImage"
case .custom, .customStyle: return nil
}
}
}
/// macos-icon-frame
enum MacOSIconFrame: String, Codable {
case aluminum
case beige
case plastic
case chrome
}
}

View File

@@ -2,23 +2,6 @@ import os
import SwiftUI
import GhosttyKit
struct Ghostty {
// The primary logger used by the GhosttyKit libraries.
static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: "ghostty"
)
// All the notifications that will be emitted will be put here.
struct Notification {}
// The user notification category identifier
static let userNotificationCategory = "com.mitchellh.ghostty.userNotification"
// The user notification "Show" action
static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show"
}
// MARK: C Extensions
/// A command is fully self-contained so it is Sendable.
@@ -28,6 +11,14 @@ extension ghostty_command_s: @unchecked @retroactive Sendable {}
/// may be unsafe but the value itself is safe to send across threads.
extension ghostty_surface_t: @unchecked @retroactive Sendable {}
extension Ghostty {
// The user notification category identifier
static let userNotificationCategory = "com.mitchellh.ghostty.userNotification"
// The user notification "Show" action
static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show"
}
// MARK: Build Info
extension Ghostty {
@@ -317,45 +308,6 @@ extension Ghostty {
}
}
/// macos-icon
enum MacOSIcon: String, Sendable {
case official
case blueprint
case chalkboard
case glass
case holographic
case microchip
case paper
case retro
case xray
case custom
case customStyle = "custom-style"
/// Bundled asset name for built-in icons
var assetName: String? {
switch self {
case .official: return nil
case .blueprint: return "BlueprintImage"
case .chalkboard: return "ChalkboardImage"
case .microchip: return "MicrochipImage"
case .glass: return "GlassImage"
case .holographic: return "HolographicImage"
case .paper: return "PaperImage"
case .retro: return "RetroImage"
case .xray: return "XrayImage"
case .custom, .customStyle: return nil
}
}
}
/// macos-icon-frame
enum MacOSIconFrame: String {
case aluminum
case beige
case plastic
case chrome
}
/// Enum for the macos-window-buttons config option
enum MacOSWindowButtons: String {
case visible

View File

@@ -0,0 +1,16 @@
import Foundation
import os
// This defines the minimal information required so all other files can do
// `extension Ghostty` to add more to it. This purposely has minimal
// dependencies so things like our dock tile plugin can use it.
enum Ghostty {
// The primary logger used by the GhosttyKit libraries.
static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: "ghostty"
)
// All the notifications that will be emitted will be put here.
struct Notification {}
}

View File

@@ -1,41 +1,37 @@
import AppKit
import SwiftUI
extension Ghostty {
/// A grab handle overlay at the top of the surface for dragging the window.
/// Only appears when hovering in the top region of the surface.
struct SurfaceGrabHandle: View {
private let handleHeight: CGFloat = 10
let surfaceView: SurfaceView
@ObservedObject var surfaceView: SurfaceView
@State private var isHovering: Bool = false
@State private var isDragging: Bool = false
var body: some View {
VStack(spacing: 0) {
Rectangle()
.fill(Color.primary.opacity(isHovering || isDragging ? 0.15 : 0))
.frame(height: handleHeight)
.overlay(alignment: .center) {
if isHovering || isDragging {
Image(systemName: "ellipsis")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.primary.opacity(0.5))
}
}
.contentShape(Rectangle())
.overlay {
SurfaceDragSource(
surfaceView: surfaceView,
isDragging: $isDragging,
isHovering: $isHovering
)
}
private var ellipsisVisible: Bool {
surfaceView.mouseOverSurface && surfaceView.cursorVisible
}
Spacer()
var body: some View {
ZStack {
SurfaceDragSource(
surfaceView: surfaceView,
isDragging: $isDragging,
isHovering: $isHovering
)
.frame(width: 80, height: 12)
.contentShape(Rectangle())
if ellipsisVisible {
Image(systemName: "ellipsis")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.primary.opacity(isHovering ? 0.8 : 0.3))
.offset(y: -3)
.allowsHitTesting(false)
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
}

View File

@@ -454,18 +454,18 @@ extension Ghostty {
guard let surface = surfaceView.surface else { return }
let action = "navigate_search:next"
ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))
}) {
}, label: {
Image(systemName: "chevron.up")
}
})
.buttonStyle(SearchButtonStyle())
Button(action: {
guard let surface = surfaceView.surface else { return }
let action = "navigate_search:previous"
ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))
}) {
}, label: {
Image(systemName: "chevron.down")
}
})
.buttonStyle(SearchButtonStyle())
Button(action: onClose) {

View File

@@ -116,6 +116,12 @@ extension Ghostty {
// Whether the pointer should be visible or not
@Published private(set) var pointerStyle: CursorStyle = .horizontalText
// Whether the mouse is currently over this surface
@Published private(set) var mouseOverSurface: Bool = false
// Whether the cursor is currently visible (not hidden by typing, etc.)
@Published private(set) var cursorVisible: Bool = true
/// The configuration derived from the Ghostty config so we don't need to rely on references.
@Published private(set) var derivedConfig: DerivedConfig
@@ -533,6 +539,7 @@ extension Ghostty {
}
func setCursorVisibility(_ visible: Bool) {
cursorVisible = visible
// Technically this action could be called anytime we want to
// change the mouse visibility but at the time of writing this
// mouse-hide-while-typing is the only use case so this is the
@@ -910,6 +917,7 @@ extension Ghostty {
}
override func mouseEntered(with event: NSEvent) {
mouseOverSurface = true
super.mouseEntered(with: event)
guard let surfaceModel else { return }
@@ -928,6 +936,7 @@ extension Ghostty {
}
override func mouseExited(with event: NSEvent) {
mouseOverSurface = false
guard let surfaceModel else { return }
// If the mouse is being dragged then we don't have to emit

View File

@@ -24,6 +24,14 @@ 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

@@ -52,10 +52,8 @@ extension NSView {
return true
}
for subview in subviews {
if subview.contains(view) {
return true
}
for subview in subviews where subview.contains(view) {
return true
}
return false
@@ -67,10 +65,8 @@ extension NSView {
return true
}
for subview in subviews {
if subview.contains(className: name) {
return true
}
for subview in subviews where subview.contains(className: name) {
return true
}
return false

View File

@@ -76,25 +76,33 @@ extension NSWindow {
titlebarView?.firstDescendant(withClassName: "NSTabBar")
}
/// Returns the index of the tab button at the given screen point, if any.
func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? {
/// Returns tab button views in visual order from left to right.
func tabButtonsInVisualOrder() -> [NSView] {
guard let tabBarView else { return [] }
return tabBarView
.descendants(withClassName: "NSTabButton")
.sorted { $0.frame.minX < $1.frame.minX }
}
/// Returns the visual tab index and matching tab button at the given screen point.
func tabButtonHit(atScreenPoint screenPoint: NSPoint) -> (index: Int, tabButton: NSView)? {
guard let tabBarView else { return nil }
let locationInWindow = convertPoint(fromScreen: screenPoint)
let locationInTabBar = tabBarView.convert(locationInWindow, from: nil)
guard tabBarView.bounds.contains(locationInTabBar) else { return nil }
// Find all tab buttons and sort by x position to get visual order.
// The view hierarchy order doesn't match the visual tab order.
let tabItemViews = tabBarView.descendants(withClassName: "NSTabButton")
.sorted { $0.frame.origin.x < $1.frame.origin.x }
for (index, tabItemView) in tabItemViews.enumerated() {
let locationInTab = tabItemView.convert(locationInWindow, from: nil)
if tabItemView.bounds.contains(locationInTab) {
return index
for (index, tabButton) in tabButtonsInVisualOrder().enumerated() {
let locationInTabButton = tabButton.convert(locationInWindow, from: nil)
if tabButton.bounds.contains(locationInTabButton) {
return (index, tabButton)
}
}
return nil
}
/// Returns the index of the tab button at the given screen point, if any.
func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? {
tabButtonHit(atScreenPoint: screenPoint)?.index
}
}

View File

@@ -1,5 +1,7 @@
import Foundation
#if !DOCK_TILE_PLUGIN
import GhosttyKit
#endif
extension OSColor {
var isLightColor: Bool {
@@ -92,7 +94,7 @@ extension OSColor {
}
// MARK: Ghostty Types
#if !DOCK_TILE_PLUGIN
extension OSColor {
/// Create a color from a Ghostty color.
convenience init(ghostty: ghostty_config_color_s) {
@@ -102,3 +104,4 @@ extension OSColor {
self.init(red: red, green: green, blue: blue, alpha: 1)
}
}
#endif

View File

@@ -0,0 +1,336 @@
import AppKit
/// Delegate used by ``TabTitleEditor`` to resolve tab-specific behavior.
protocol TabTitleEditorDelegate: AnyObject {
/// Returns whether inline rename should be allowed for the given tab window.
func tabTitleEditor(
_ editor: TabTitleEditor,
canRenameTabFor targetWindow: NSWindow
) -> Bool
/// Returns the current title value to seed into the inline editor.
func tabTitleEditor(
_ editor: TabTitleEditor,
titleFor targetWindow: NSWindow
) -> String
/// Called when inline editing commits a title for a target tab window.
func tabTitleEditor(
_ editor: TabTitleEditor,
didCommitTitle editedTitle: String,
for targetWindow: NSWindow
)
/// Called when inline editing could not start and the host should show a fallback flow.
func tabTitleEditor(
_ editor: TabTitleEditor,
performFallbackRenameFor targetWindow: NSWindow
)
}
/// Handles inline tab title editing for native AppKit window tabs.
final class TabTitleEditor: NSObject, NSTextFieldDelegate {
/// Host window containing the tab bar where editing occurs.
private weak var hostWindow: NSWindow?
/// Delegate that provides and commits title data for target tab windows.
private weak var delegate: TabTitleEditorDelegate?
/// Active inline editor view, if editing is in progress.
private weak var inlineTitleEditor: NSTextField?
/// Tab window currently being edited.
private weak var inlineTitleTargetWindow: NSWindow?
/// Original hidden state for title labels that are temporarily hidden while editing.
private var hiddenLabels: [(label: NSTextField, wasHidden: Bool)] = []
/// Original button title state restored once editing finishes.
private var buttonState: (button: NSButton, title: String, attributedTitle: NSAttributedString?)?
/// Deferred begin-editing work used to avoid visual flicker on double-click.
private var pendingEditWorkItem: DispatchWorkItem?
/// Creates a coordinator bound to a host window and rename delegate.
init(hostWindow: NSWindow, delegate: TabTitleEditorDelegate) {
self.hostWindow = hostWindow
self.delegate = delegate
}
/// Handles double-click events from the host window and begins inline edit if possible. If this
/// returns true then the double click was handled by the coordinator.
func handleDoubleClick(_ event: NSEvent) -> Bool {
// We only want double-clicks
guard event.type == .leftMouseDown, event.clickCount == 2 else { return false }
// If we don't have a host window to look up the click, we do nothing.
guard let hostWindow else { return false }
// Find the tab window that is being clicked.
let locationInScreen = hostWindow.convertPoint(toScreen: event.locationInWindow)
guard let tabIndex = hostWindow.tabIndex(atScreenPoint: locationInScreen),
let targetWindow = hostWindow.tabbedWindows?[safe: tabIndex],
delegate?.tabTitleEditor(self, canRenameTabFor: targetWindow) == true
else { return false }
// We need to start editing in a separate event loop tick, so set that up.
pendingEditWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self, weak targetWindow] in
guard let self, let targetWindow else { return }
if self.beginEditing(for: targetWindow) {
return
}
// Inline editing failed, so trigger fallback rename whatever it is.
self.delegate?.tabTitleEditor(self, performFallbackRenameFor: targetWindow)
}
pendingEditWorkItem = workItem
DispatchQueue.main.async(execute: workItem)
return true
}
/// Begins editing the given target tab window title. Returns true if we're able to start the
/// inline edit.
@discardableResult
func beginEditing(for targetWindow: NSWindow) -> Bool {
// Resolve the visual tab button for the target tab window. We rely on visual order
// since native tab view hierarchy order does not necessarily match what is on screen.
guard let hostWindow,
let tabbedWindows = hostWindow.tabbedWindows,
let tabIndex = tabbedWindows.firstIndex(of: targetWindow),
let tabButton = hostWindow.tabButtonsInVisualOrder()[safe: tabIndex],
delegate?.tabTitleEditor(self, canRenameTabFor: targetWindow) == true
else { return false }
// If we have a pending edit, we need to cancel it because we got
// called to start edit explicitly.
pendingEditWorkItem?.cancel()
pendingEditWorkItem = nil
finishEditing(commit: true)
// Build the editor using title text and style derived from the tab's existing label.
let titleLabels = tabButton
.descendants(withClassName: "NSTextField")
.compactMap { $0 as? NSTextField }
let editedTitle = delegate?.tabTitleEditor(self, titleFor: targetWindow) ?? targetWindow.title
let sourceLabel = sourceTabTitleLabel(from: titleLabels, matching: editedTitle)
let editorFrame = tabTitleEditorFrame(for: tabButton, sourceLabel: sourceLabel)
guard editorFrame.width >= 20, editorFrame.height >= 14 else { return false }
let editor = NSTextField(frame: editorFrame)
editor.delegate = self
editor.stringValue = editedTitle
editor.alignment = sourceLabel?.alignment ?? .center
editor.isBordered = false
editor.isBezeled = false
editor.drawsBackground = false
editor.focusRingType = .none
editor.lineBreakMode = .byClipping
if let editorCell = editor.cell as? NSTextFieldCell {
editorCell.wraps = false
editorCell.usesSingleLineMode = true
editorCell.isScrollable = true
}
if let sourceLabel {
applyTextStyle(to: editor, from: sourceLabel, title: editedTitle)
}
// Hide it until the tab button has finished layout so we can avoid flicker.
editor.isHidden = true
inlineTitleEditor = editor
inlineTitleTargetWindow = targetWindow
// Temporarily hide native title label views while editing so only the text field is visible.
CATransaction.begin()
CATransaction.setDisableActions(true)
hiddenLabels = titleLabels.map { ($0, $0.isHidden) }
for label in titleLabels {
label.isHidden = true
}
if let tabButton = tabButton as? NSButton {
buttonState = (tabButton, tabButton.title, tabButton.attributedTitle)
tabButton.title = ""
tabButton.attributedTitle = NSAttributedString(string: "")
} else {
buttonState = nil
}
tabButton.layoutSubtreeIfNeeded()
tabButton.displayIfNeeded()
tabButton.addSubview(editor)
CATransaction.commit()
// Focus after insertion so AppKit has created the field editor for this text field.
DispatchQueue.main.async { [weak hostWindow, weak editor] in
guard let hostWindow, let editor else { return }
editor.isHidden = false
hostWindow.makeFirstResponder(editor)
if let fieldEditor = editor.currentEditor() as? NSTextView,
let editorFont = editor.font {
fieldEditor.font = editorFont
var typingAttributes = fieldEditor.typingAttributes
typingAttributes[.font] = editorFont
fieldEditor.typingAttributes = typingAttributes
}
editor.currentEditor()?.selectAll(nil)
}
return true
}
/// Finishes any in-flight inline edit and optionally commits the edited title.
func finishEditing(commit: Bool) {
// If we're pending starting a new edit, cancel it.
pendingEditWorkItem?.cancel()
pendingEditWorkItem = nil
// To finish editing we need a current editor.
guard let editor = inlineTitleEditor else { return }
let editedTitle = editor.stringValue
let targetWindow = inlineTitleTargetWindow
// Clear coordinator references first so re-entrant paths don't see stale state.
editor.delegate = nil
inlineTitleEditor = nil
inlineTitleTargetWindow = nil
// Make sure the window grabs focus again
if let hostWindow {
if let currentEditor = editor.currentEditor(), hostWindow.firstResponder === currentEditor {
hostWindow.makeFirstResponder(nil)
} else if hostWindow.firstResponder === editor {
hostWindow.makeFirstResponder(nil)
}
}
editor.removeFromSuperview()
// Restore original tab title presentation.
for (label, wasHidden) in hiddenLabels {
label.isHidden = wasHidden
}
hiddenLabels.removeAll()
if let buttonState {
buttonState.button.title = buttonState.title
buttonState.button.attributedTitle = buttonState.attributedTitle ?? NSAttributedString(string: buttonState.title)
}
self.buttonState = nil
// Delegate owns title persistence semantics (including empty-title handling).
guard commit, let targetWindow else { return }
delegate?.tabTitleEditor(self, didCommitTitle: editedTitle, for: targetWindow)
}
/// Chooses an editor frame that aligns with the tab title within the tab button.
private func tabTitleEditorFrame(for tabButton: NSView, sourceLabel: NSTextField?) -> NSRect {
let bounds = tabButton.bounds
let horizontalInset: CGFloat = 6
var frame = bounds.insetBy(dx: horizontalInset, dy: 0)
if let sourceLabel {
let labelFrame = tabButton.convert(sourceLabel.bounds, from: sourceLabel)
frame.origin.y = labelFrame.minY
frame.size.height = labelFrame.height
}
return frame.integral
}
/// Selects the best title label candidate from private tab button subviews.
private func sourceTabTitleLabel(from labels: [NSTextField], matching title: String) -> NSTextField? {
let expected = title.trimmingCharacters(in: .whitespacesAndNewlines)
if !expected.isEmpty {
// Prefer a visible exact title match when we can find one.
if let exactVisible = labels.first(where: {
!$0.isHidden &&
$0.alphaValue > 0.01 &&
$0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected
}) {
return exactVisible
}
// Fall back to any exact match, including hidden labels.
if let exactAny = labels.first(where: {
$0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected
}) {
return exactAny
}
}
// Otherwise heuristically choose the largest visible, centered label first.
let visibleNonEmpty = labels.filter {
!$0.isHidden &&
$0.alphaValue > 0.01 &&
!$0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
if let centeredVisible = visibleNonEmpty
.filter({ $0.alignment == .center })
.max(by: { $0.bounds.width < $1.bounds.width }) {
return centeredVisible
}
if let visible = visibleNonEmpty.max(by: { $0.bounds.width < $1.bounds.width }) {
return visible
}
return labels.max(by: { $0.bounds.width < $1.bounds.width })
}
/// Copies text styling from the source tab label onto the inline editor.
private func applyTextStyle(to editor: NSTextField, from label: NSTextField, title: String) {
var attributes: [NSAttributedString.Key: Any] = [:]
if label.attributedStringValue.length > 0 {
attributes = label.attributedStringValue.attributes(at: 0, effectiveRange: nil)
}
if attributes[.font] == nil, let font = label.font {
attributes[.font] = font
}
if attributes[.foregroundColor] == nil {
attributes[.foregroundColor] = label.textColor
}
if let font = attributes[.font] as? NSFont {
editor.font = font
}
if let textColor = attributes[.foregroundColor] as? NSColor {
editor.textColor = textColor
}
if !attributes.isEmpty {
editor.attributedStringValue = NSAttributedString(string: title, attributes: attributes)
} else {
editor.stringValue = title
}
}
// MARK: NSTextFieldDelegate
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
guard control === inlineTitleEditor else { return false }
// Enter commits and exits inline edit.
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
finishEditing(commit: true)
return true
}
// Escape cancels and restores the previous tab title.
if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
finishEditing(commit: false)
return true
}
return false
}
func controlTextDidEndEditing(_ obj: Notification) {
guard let inlineTitleEditor,
let finishedEditor = obj.object as? NSTextField,
finishedEditor === inlineTitleEditor
else { return }
// Blur/end-edit commits, matching standard NSTextField behavior.
finishEditing(commit: true)
}
}

View File

@@ -0,0 +1,144 @@
import AppKit
import Foundation
import Testing
@testable import Ghostty
struct ColorizedGhosttyIconTests {
private func makeIcon(
screenColors: [NSColor] = [
NSColor(hex: "#112233")!,
NSColor(hex: "#AABBCC")!,
],
ghostColor: NSColor = NSColor(hex: "#445566")!,
frame: Ghostty.MacOSIconFrame = .aluminum
) -> ColorizedGhosttyIcon {
.init(screenColors: screenColors, ghostColor: ghostColor, frame: frame)
}
// MARK: - Codable
@Test func codableRoundTripPreservesIcon() throws {
let icon = makeIcon(frame: .chrome)
let data = try JSONEncoder().encode(icon)
let decoded = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data)
#expect(decoded == icon)
#expect(decoded.screenColors.compactMap(\.hexString) == ["#112233", "#AABBCC"])
#expect(decoded.ghostColor.hexString == "#445566")
#expect(decoded.frame == .chrome)
}
@Test func encodingWritesVersionAndHexColors() throws {
let icon = makeIcon(frame: .plastic)
let data = try JSONEncoder().encode(icon)
let payload = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(payload["version"] as? Int == 1)
#expect(payload["screenColors"] as? [String] == ["#112233", "#AABBCC"])
#expect(payload["ghostColor"] as? String == "#445566")
#expect(payload["frame"] as? String == "plastic")
}
@Test func decodesLegacyV0PayloadWithoutVersion() throws {
let data = Data("""
{
"screenColors": ["#112233", "#AABBCC"],
"ghostColor": "#445566",
"frame": "beige"
}
""".utf8)
let decoded = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data)
#expect(decoded.screenColors.compactMap(\.hexString) == ["#112233", "#AABBCC"])
#expect(decoded.ghostColor.hexString == "#445566")
#expect(decoded.frame == .beige)
}
@Test func decodingUnsupportedVersionThrowsDataCorrupted() {
let data = Data("""
{
"version": 99,
"screenColors": ["#112233", "#AABBCC"],
"ghostColor": "#445566",
"frame": "chrome"
}
""".utf8)
do {
_ = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data)
Issue.record("Expected decode to fail for unsupported version")
} catch let DecodingError.dataCorrupted(context) {
#expect(context.debugDescription.contains("Unsupported ColorizedGhosttyIcon version"))
} catch {
Issue.record("Expected DecodingError.dataCorrupted, got: \(error)")
}
}
@Test func decodingInvalidGhostColorThrows() {
let data = Data("""
{
"version": 1,
"screenColors": ["#112233", "#AABBCC"],
"ghostColor": "not-a-color",
"frame": "chrome"
}
""".utf8)
do {
_ = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data)
Issue.record("Expected decode to fail for invalid ghost color")
} catch let DecodingError.dataCorrupted(context) {
#expect(context.debugDescription.contains("Failed to decode ghost color"))
} catch {
Issue.record("Expected DecodingError.dataCorrupted, got: \(error)")
}
}
@Test func decodingInvalidScreenColorsDropsInvalidEntries() throws {
let data = Data("""
{
"version": 1,
"screenColors": ["#112233", "invalid", "#AABBCC"],
"ghostColor": "#445566",
"frame": "chrome"
}
""".utf8)
let decoded = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data)
#expect(decoded.screenColors.compactMap(\.hexString) == ["#112233", "#AABBCC"])
}
// MARK: - Equatable
@Test func equatableUsesHexColorAndFrameValues() {
let lhs = makeIcon(
screenColors: [
NSColor(red: 0x11 / 255.0, green: 0x22 / 255.0, blue: 0x33 / 255.0, alpha: 1.0),
NSColor(red: 0xAA / 255.0, green: 0xBB / 255.0, blue: 0xCC / 255.0, alpha: 1.0),
],
ghostColor: NSColor(red: 0x44 / 255.0, green: 0x55 / 255.0, blue: 0x66 / 255.0, alpha: 1.0),
frame: .chrome
)
let rhs = makeIcon(frame: .chrome)
#expect(lhs == rhs)
}
@Test func equatableReturnsFalseForDifferentFrame() {
let lhs = makeIcon(frame: .aluminum)
let rhs = makeIcon(frame: .chrome)
#expect(lhs != rhs)
}
@Test func equatableReturnsFalseForDifferentScreenColors() {
let lhs = makeIcon(screenColors: [NSColor(hex: "#112233")!, NSColor(hex: "#AABBCC")!])
let rhs = makeIcon(screenColors: [NSColor(hex: "#112233")!, NSColor(hex: "#CCBBAA")!])
#expect(lhs != rhs)
}
@Test func equatableReturnsFalseForDifferentGhostColor() {
let lhs = makeIcon(ghostColor: NSColor(hex: "#445566")!)
let rhs = makeIcon(ghostColor: NSColor(hex: "#665544")!)
#expect(lhs != rhs)
}
}

View File

@@ -1,22 +1,24 @@
{
lib,
buildPythonPackage,
fetchPypi,
fetchFromGitHub,
pythonOlder,
flit-core,
six,
wcwidth,
}:
buildPythonPackage rec {
buildPythonPackage {
pname = "blessed";
version = "1.23.0";
version = "unstable-2026-02-23";
pyproject = true;
disabled = pythonOlder "3.7";
disabled = pythonOlder "3.8";
src = fetchPypi {
inherit pname version;
hash = "sha256-VlkaMpZvcE9hMfFACvQVHZ6PX0FEEzpcoDQBl2Pe53s=";
src = fetchFromGitHub {
owner = "jquast";
repo = "blessed";
rev = "master";
hash = "sha256-ROd/O9pfqnF5DHXqoz+tkl1jQJSZad3Ta1h+oC3+gvY=";
};
build-system = [flit-core];
@@ -27,6 +29,7 @@ buildPythonPackage rec {
];
doCheck = false;
dontCheckRuntimeDeps = true;
meta = with lib; {
homepage = "https://github.com/jquast/blessed";

View File

@@ -1,36 +1,42 @@
{
lib,
buildPythonPackage,
fetchPypi,
fetchFromGitHub,
pythonOlder,
setuptools,
hatchling,
# Dependencies
blessed,
wcwidth,
pyyaml,
prettytable,
requests,
}:
buildPythonPackage rec {
buildPythonPackage {
pname = "ucs-detect";
version = "1.0.8";
version = "unstable-2026-02-23";
pyproject = true;
disabled = pythonOlder "3.8";
src = fetchPypi {
inherit version;
pname = "ucs_detect";
hash = "sha256-ihB+tZCd6ykdeXYxc6V1Q6xALQ+xdCW5yqSL7oppqJc=";
src = fetchFromGitHub {
owner = "jquast";
repo = "ucs-detect";
rev = "master";
hash = "sha256-x7BD14n1/mP9bzjM6DPqc5R1Fk/HLLycl4o41KV+xAE=";
};
dependencies = [
blessed
wcwidth
pyyaml
prettytable
requests
];
nativeBuildInputs = [setuptools];
nativeBuildInputs = [hatchling];
doCheck = false;
dontCheckRuntimeDeps = true;
meta = with lib; {
description = "Measures number of Terminal column cells of wide-character codes";

27
nix/pkgs/wcwidth.nix Normal file
View File

@@ -0,0 +1,27 @@
{
lib,
buildPythonPackage,
fetchPypi,
hatchling,
}:
buildPythonPackage rec {
pname = "wcwidth";
version = "0.6.0";
pyproject = true;
src = fetchPypi {
inherit pname version;
hash = "sha256-zcTkJi1u+aGlfgGDhMvrEgjYq7xkF2An4sJFXIExMVk=";
};
build-system = [hatchling];
doCheck = false;
meta = with lib; {
description = "Measures the displayed width of unicode strings in a terminal";
homepage = "https://github.com/jquast/wcwidth";
license = licenses.mit;
maintainers = [];
};
}

207
pkg/android-ndk/build.zig Normal file
View File

@@ -0,0 +1,207 @@
const std = @import("std");
const builtin = @import("builtin");
pub fn build(_: *std.Build) !void {}
// Configure the step to point to the Android NDK for libc and include
// paths. This requires the Android NDK installed in the system and
// setting the appropriate environment variables or installing the NDK
// in the default location.
//
// The environment variables can be set as follows:
// - `ANDROID_NDK_HOME`: Directly points to the NDK path, including the version.
// - `ANDROID_HOME` or `ANDROID_SDK_ROOT`: Points to the Android SDK path;
// latest available NDK will be automatically selected.
//
// NB: This is a workaround until zig natively supports bionic
// cross-compilation (ziglang/zig#23906).
pub fn addPaths(b: *std.Build, step: *std.Build.Step.Compile) !void {
const Cache = struct {
const Key = struct {
arch: std.Target.Cpu.Arch,
abi: std.Target.Abi,
api_level: u32,
};
var map: std.AutoHashMapUnmanaged(Key, ?struct {
libc: std.Build.LazyPath,
cpp_include: std.Build.LazyPath,
lib: std.Build.LazyPath,
}) = .empty;
};
const target = step.rootModuleTarget();
const gop = try Cache.map.getOrPut(b.allocator, .{
.arch = target.cpu.arch,
.abi = target.abi,
.api_level = target.os.version_range.linux.android,
});
if (!gop.found_existing) {
const ndk_path = findNDKPath(b) orelse return error.AndroidNDKNotFound;
const ndk_triple = ndkTriple(target) orelse {
gop.value_ptr.* = null;
return error.AndroidNDKUnsupportedTarget;
};
const host = hostTag() orelse {
gop.value_ptr.* = null;
return error.AndroidNDKUnsupportedHost;
};
const sysroot = b.pathJoin(&.{
ndk_path,
"toolchains",
"llvm",
"prebuilt",
host,
"sysroot",
});
const include_dir = b.pathJoin(&.{
sysroot,
"usr",
"include",
});
const sys_include_dir = b.pathJoin(&.{
sysroot,
"usr",
"include",
ndk_triple,
});
const c_runtime_dir = b.pathJoin(&.{
sysroot,
"usr",
"lib",
ndk_triple,
b.fmt("{d}", .{target.os.version_range.linux.android}),
});
const lib = b.pathJoin(&.{
sysroot,
"usr",
"lib",
ndk_triple,
});
const cpp_include = b.pathJoin(&.{
sysroot,
"usr",
"include",
"c++",
"v1",
});
const libc_txt = b.fmt(
\\include_dir={s}
\\sys_include_dir={s}
\\crt_dir={s}
\\msvc_lib_dir=
\\kernel32_lib_dir=
\\gcc_dir=
, .{ include_dir, sys_include_dir, c_runtime_dir });
const wf = b.addWriteFiles();
const libc_path = wf.add("libc.txt", libc_txt);
gop.value_ptr.* = .{
.libc = libc_path,
.cpp_include = .{ .cwd_relative = cpp_include },
.lib = .{ .cwd_relative = lib },
};
}
const value = gop.value_ptr.* orelse return error.AndroidNDKNotFound;
step.setLibCFile(value.libc);
step.root_module.addSystemIncludePath(value.cpp_include);
step.root_module.addLibraryPath(value.lib);
}
fn findNDKPath(b: *std.Build) ?[]const u8 {
// Check if user has set the environment variable for the NDK path.
if (std.process.getEnvVarOwned(b.allocator, "ANDROID_NDK_HOME") catch null) |value| {
if (value.len == 0) return null;
var dir = std.fs.openDirAbsolute(value, .{}) catch return null;
defer dir.close();
return value;
}
// Check the common environment variables for the Android SDK path and look for the NDK inside it.
inline for (.{ "ANDROID_HOME", "ANDROID_SDK_ROOT" }) |env| {
if (std.process.getEnvVarOwned(b.allocator, env) catch null) |sdk| {
if (sdk.len > 0) {
if (findLatestNDK(b, sdk)) |ndk| return ndk;
}
}
}
// As a fallback, we assume the most common/default SDK path based on the OS.
const home = std.process.getEnvVarOwned(
b.allocator,
if (builtin.os.tag == .windows) "LOCALAPPDATA" else "HOME",
) catch return null;
const default_sdk_path = b.pathJoin(
&.{
home,
switch (builtin.os.tag) {
.linux => "Android/sdk",
.macos => "Library/Android/Sdk",
.windows => "Android/Sdk",
else => return null,
},
},
);
return findLatestNDK(b, default_sdk_path);
}
fn findLatestNDK(b: *std.Build, sdk_path: []const u8) ?[]const u8 {
const ndk_dir = b.pathJoin(&.{ sdk_path, "ndk" });
var dir = std.fs.openDirAbsolute(ndk_dir, .{ .iterate = true }) catch return null;
defer dir.close();
var latest_: ?struct {
name: []const u8,
version: std.SemanticVersion,
} = null;
var iterator = dir.iterate();
while (iterator.next() catch null) |file| {
if (file.kind != .directory) continue;
const version = std.SemanticVersion.parse(file.name) catch continue;
if (latest_) |latest| {
if (version.order(latest.version) != .gt) continue;
}
latest_ = .{
.name = file.name,
.version = version,
};
}
const latest = latest_ orelse return null;
return b.pathJoin(&.{ sdk_path, "ndk", latest.name });
}
fn hostTag() ?[]const u8 {
return switch (builtin.os.tag) {
.linux => "linux-x86_64",
// All darwin hosts use the same prebuilt binaries
// (https://developer.android.com/ndk/guides/other_build_systems).
.macos => "darwin-x86_64",
.windows => "windows-x86_64",
else => null,
};
}
// We must map the target architecture to the corresponding NDK triple following the NDK
// documentation: https://android.googlesource.com/platform/ndk/+/master/docs/BuildSystemMaintainers.md#architectures
fn ndkTriple(target: std.Target) ?[]const u8 {
return switch (target.cpu.arch) {
.arm => "arm-linux-androideabi",
.aarch64 => "aarch64-linux-android",
.x86 => "i686-linux-android",
.x86_64 => "x86_64-linux-android",
else => null,
};
}

View File

@@ -0,0 +1,10 @@
.{
.name = .android_ndk,
.version = "0.0.2",
.fingerprint = 0xee68d62c5a97b68b,
.dependencies = .{},
.paths = .{
"build.zig",
"build.zig.zon",
},
}

View File

@@ -31,6 +31,11 @@ pub fn build(b: *std.Build) !void {
try apple_sdk.addPaths(b, lib);
}
if (target.result.abi.isAndroid()) {
const android_ndk = @import("android_ndk");
try android_ndk.addPaths(b, lib);
}
var flags: std.ArrayList([]const u8) = .empty;
defer flags.deinit(b.allocator);
try flags.appendSlice(b.allocator, &.{

View File

@@ -12,5 +12,6 @@
},
.apple_sdk = .{ .path = "../apple-sdk" },
.android_ndk = .{ .path = "../android-ndk" },
},
}

View File

@@ -20,6 +20,11 @@ pub fn build(b: *std.Build) !void {
try apple_sdk.addPaths(b, lib);
}
if (target.result.abi.isAndroid()) {
const android_ndk = @import("android_ndk");
try android_ndk.addPaths(b, lib);
}
var flags: std.ArrayList([]const u8) = .empty;
defer flags.deinit(b.allocator);
// Zig 0.13 bug: https://github.com/ziglang/zig/issues/20414

View File

@@ -5,5 +5,6 @@
.paths = .{""},
.dependencies = .{
.apple_sdk = .{ .path = "../apple-sdk" },
.android_ndk = .{ .path = "../android-ndk" },
},
}

View File

@@ -19,6 +19,11 @@ pub fn build(b: *std.Build) !void {
try apple_sdk.addPaths(b, lib);
}
if (target.result.abi.isAndroid()) {
const android_ndk = @import("android_ndk");
try android_ndk.addPaths(b, lib);
}
var flags: std.ArrayList([]const u8) = .empty;
defer flags.deinit(b.allocator);

View File

@@ -12,5 +12,6 @@
},
.apple_sdk = .{ .path = "../apple-sdk" },
.android_ndk = .{ .path = "../android-ndk" },
},
}

View File

@@ -1,4 +1,4 @@
# Italian translations for com.mitchellh.ghostty package
# Italian translations for com.mitchellh.ghostty package.
# Traduzioni italiane per il pacchetto com.mitchellh.ghostty.
# Copyright (C) 2025 "Mitchell Hashimoto, Ghostty contributors"
# This file is distributed under the same license as the com.mitchellh.ghostty package.
@@ -20,7 +20,7 @@ msgstr ""
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
msgstr "Apri in Ghostty"
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
@@ -93,23 +93,23 @@ msgstr "Ghostty: Ispettore del terminale"
#: src/apprt/gtk/ui/1.2/search-overlay.blp:29
msgid "Find…"
msgstr ""
msgstr "Cerca…"
#: src/apprt/gtk/ui/1.2/search-overlay.blp:64
msgid "Previous Match"
msgstr ""
msgstr "Corrispondenza precedente"
#: src/apprt/gtk/ui/1.2/search-overlay.blp:74
msgid "Next Match"
msgstr ""
msgstr "Corrispondenza successiva"
#: src/apprt/gtk/ui/1.2/surface.blp:6
msgid "Oh, no."
msgstr ""
msgstr "Oh no!"
#: src/apprt/gtk/ui/1.2/surface.blp:7
msgid "Unable to acquire an OpenGL context for rendering."
msgstr ""
msgstr "Impossibile ottenere un contesto OpenGL per il rendering."
#: src/apprt/gtk/ui/1.2/surface.blp:97
msgid ""
@@ -117,10 +117,13 @@ msgid ""
"through the content, but no input events will be sent to the running "
"application."
msgstr ""
"Questo terminale è in modalità di sola lettura. Puoi ancora vedere, "
"selezionare e scorrere il contenuto, ma non verrà inviato alcun evento "
"di input all'applicazione in esecuzione."
#: src/apprt/gtk/ui/1.2/surface.blp:107
msgid "Read-only"
msgstr ""
msgstr "Sola lettura"
#: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:200
msgid "Copy"
@@ -132,7 +135,7 @@ msgstr "Incolla"
#: src/apprt/gtk/ui/1.2/surface.blp:270
msgid "Notify on Next Command Finish"
msgstr ""
msgstr "Notifica al termine del prossimo comando"
#: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:273
msgid "Clear"
@@ -177,7 +180,7 @@ msgstr "Scheda"
#: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224
#: src/apprt/gtk/ui/1.5/window.blp:320
msgid "Change Tab Title…"
msgstr ""
msgstr "Cambia titolo scheda…"
#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57
#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229
@@ -311,15 +314,15 @@ msgstr ""
#: src/apprt/gtk/class/surface.zig:1108
msgid "Command Finished"
msgstr ""
msgstr "Comando terminato"
#: src/apprt/gtk/class/surface.zig:1109
msgid "Command Succeeded"
msgstr ""
msgstr "Comando riuscito"
#: src/apprt/gtk/class/surface.zig:1110
msgid "Command Failed"
msgstr ""
msgstr "Comando fallito"
#: src/apprt/gtk/class/surface_child_exited.zig:109
msgid "Command succeeded"
@@ -335,7 +338,7 @@ msgstr "Cambia il titolo del terminale"
#: src/apprt/gtk/class/title_dialog.zig:226
msgid "Change Tab Title"
msgstr ""
msgstr "Cambia il titolo della scheda"
#: src/apprt/gtk/class/window.zig:1007
msgid "Reloaded the configuration"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-10 08:14+0100\n"
"PO-Revision-Date: 2026-02-20 12:13+0100\n"
"Last-Translator: Tadas Lotuzas <tdslot@gmail.com>\n"
"Language-Team: Language LT\n"
"Language: LT\n"
@@ -18,7 +18,7 @@ msgstr ""
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
msgstr "Atidaryti su Ghostty"
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
@@ -177,7 +177,7 @@ msgstr "Kortelė"
#: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224
#: src/apprt/gtk/ui/1.5/window.blp:320
msgid "Change Tab Title…"
msgstr ""
msgstr "Keisti kortelės pavadinimą…"
#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57
#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229
@@ -334,7 +334,7 @@ msgstr "Keisti terminalo pavadinimą"
#: src/apprt/gtk/class/title_dialog.zig:226
msgid "Change Tab Title"
msgstr ""
msgstr "Keisti kortelės pavadinimą"
#: src/apprt/gtk/class/window.zig:1007
msgid "Reloaded the configuration"

View File

@@ -19,7 +19,7 @@ msgstr ""
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
msgstr "Отвори во Ghostty"
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
@@ -179,7 +179,7 @@ msgstr "Јазиче"
#: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224
#: src/apprt/gtk/ui/1.5/window.blp:320
msgid "Change Tab Title…"
msgstr ""
msgstr "Промени наслов на јазиче…"
#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57
#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229
@@ -336,7 +336,7 @@ msgstr "Промени наслов на терминал"
#: src/apprt/gtk/class/title_dialog.zig:226
msgid "Change Tab Title"
msgstr ""
msgstr "Промени наслов на јазиче"
#: src/apprt/gtk/class/window.zig:1007
msgid "Reloaded the configuration"

View File

@@ -9,7 +9,7 @@ msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-09 20:39+0100\n"
"PO-Revision-Date: 2026-02-18 20:59+0100\n"
"Last-Translator: Nico Geesink <geesinknico@gmail.com>\n"
"Language-Team: Dutch <vertaling@vrijschrift.org>\n"
"Language: nl\n"
@@ -20,7 +20,7 @@ msgstr ""
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
msgstr "Open in Ghostty"
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
@@ -180,7 +180,7 @@ msgstr "Tabblad"
#: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224
#: src/apprt/gtk/ui/1.5/window.blp:320
msgid "Change Tab Title…"
msgstr ""
msgstr "Wijzig tabbladtitel…"
#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57
#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229
@@ -337,7 +337,7 @@ msgstr "Titel van de terminal wijzigen"
#: src/apprt/gtk/class/title_dialog.zig:226
msgid "Change Tab Title"
msgstr ""
msgstr "Wijzig tabbladtitel"
#: src/apprt/gtk/class/window.zig:1007
msgid "Reloaded the configuration"

View File

@@ -19,7 +19,7 @@ msgstr ""
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
msgstr "在 Ghostty 中打开"
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
@@ -175,7 +175,7 @@ msgstr "标签页"
#: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224
#: src/apprt/gtk/ui/1.5/window.blp:320
msgid "Change Tab Title…"
msgstr ""
msgstr "更改标签页标题…"
#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57
#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229
@@ -326,7 +326,7 @@ msgstr "更改终端标题"
#: src/apprt/gtk/class/title_dialog.zig:226
msgid "Change Tab Title"
msgstr ""
msgstr "更改标签页标题"
#: src/apprt/gtk/class/window.zig:1007
msgid "Reloaded the configuration"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-10 15:32+0800\n"
"PO-Revision-Date: 2026-02-18 13:58+0800\n"
"Last-Translator: Yi-Jyun Pan <me@pan93.com>\n"
"Language-Team: Chinese (traditional)\n"
"Language: zh_TW\n"
@@ -18,7 +18,7 @@ msgstr ""
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
msgstr "在 Ghostty 中開啟"
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12

View File

@@ -330,6 +330,10 @@ pub const Tab = extern struct {
glib.free(@ptrCast(@constCast(v)));
priv.title = null;
}
if (priv.title_override) |v| {
glib.free(@ptrCast(@constCast(v)));
priv.title_override = null;
}
gobject.Object.virtual_methods.finalize.call(
Class.parent,

View File

@@ -339,6 +339,7 @@ pub const Window = extern struct {
.init("close-tab", actionCloseTab, s_variant_type),
.init("new-tab", actionNewTab, null),
.init("new-window", actionNewWindow, null),
.init("prompt-surface-title", actionPromptSurfaceTitle, null),
.init("prompt-tab-title", actionPromptTabTitle, null),
.init("prompt-context-tab-title", actionPromptContextTabTitle, null),
.init("ring-bell", actionRingBell, null),
@@ -1803,6 +1804,14 @@ pub const Window = extern struct {
tab.promptTabTitle();
}
fn actionPromptSurfaceTitle(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.c) void {
self.performBindingAction(.prompt_surface_title);
}
fn actionPromptTabTitle(
_: *gio.SimpleAction,
_: ?*glib.Variant,

View File

@@ -243,7 +243,7 @@ menu main_menu {
item {
label: _("Change Title…");
action: "win.prompt-title";
action: "win.prompt-surface-title";
}
item {

View File

@@ -62,6 +62,13 @@ pub fn initShared(
.{ .include_extensions = &.{".h"} },
);
if (lib.rootModuleTarget().abi.isAndroid()) {
// Support 16kb page sizes, required for Android 15+.
lib.link_z_max_page_size = 16384; // 16kb
try @import("android_ndk").addPaths(b, lib);
}
if (lib.rootModuleTarget().os.tag.isDarwin()) {
// Self-hosted x86_64 doesn't work for darwin. It may not work
// for other platforms too but definitely darwin.

View File

@@ -100,6 +100,7 @@ pub const tables = [_]config.Table{
},
.fields = &.{
width.field("width"),
wcwidth.field("wcwidth_zero_in_grapheme"),
grapheme_break_no_control.field("grapheme_break_no_control"),
is_symbol.field("is_symbol"),
d.field("is_emoji_vs_base"),

View File

@@ -39,6 +39,7 @@ pub const Path = @import("path.zig").Path;
pub const RepeatablePath = @import("path.zig").RepeatablePath;
const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig");
const KeyRemapSet = @import("../input/key_mods.zig").RemapSet;
const string = @import("string.zig");
// We do this instead of importing all of terminal/main.zig to
// limit the dependency graph. This is important because some things
@@ -1857,6 +1858,12 @@ class: ?[:0]const u8 = null,
/// If an invalid key is pressed, the sequence ends but the table remains
/// active.
///
/// * Chain actions work within tables, the `chain` keyword applies to
/// the most recently defined binding in the table. e.g. if you set
/// `table/ctrl+a=new_window` you can chain by using `chain=text:hello`.
/// Important: chain itself doesn't get prefixed with the table name,
/// since it applies to the most recent binding in any table.
///
/// * Prefixes like `global:` work within tables:
/// `foo/global:ctrl+a=new_window`.
///
@@ -2939,6 +2946,20 @@ keybind: Keybinds = .{},
///
/// * `vec4 iPreviousCursorColor` - Color of the previous terminal cursor.
///
/// * `vec4 iCurrentCursorStyle` - Style of the terminal cursor
///
/// Macros simplified use are defined for the various cursor styles:
///
/// - `CURSORSTYLE_BLOCK` or `0`
/// - `CURSORSTYLE_BLOCK_HOLLOW` or `1`
/// - `CURSORSTYLE_BAR` or `2`
/// - `CURSORSTYLE_UNDERLINE` or `3`
/// - `CURSORSTYLE_LOCK` or `4`
///
/// * `vec4 iPreviousCursorStyle` - Style of the previous terminal cursor
///
/// * `vec4 iCursorVisible` - Visibility of the terminal cursor.
///
/// * `float iTimeCursorChange` - Timestamp of terminal cursor change.
///
/// When the terminal cursor changes position or color, this is set to
@@ -5965,22 +5986,15 @@ pub const SelectionWordChars = struct {
pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void {
const value = input orelse return error.ValueRequired;
// Parse UTF-8 string into codepoints
// Parse string with Zig escape sequence support into codepoints
var list: std.ArrayList(u21) = .empty;
defer list.deinit(alloc);
// Always include null as first boundary
try list.append(alloc, 0);
// Parse the UTF-8 string
const utf8_view = std.unicode.Utf8View.init(value) catch {
// Invalid UTF-8, just use null boundary
self.codepoints = try list.toOwnedSlice(alloc);
return;
};
var utf8_it = utf8_view.iterator();
while (utf8_it.nextCodepoint()) |codepoint| {
var it = string.codepointIterator(value);
while (it.next() catch return error.InvalidValue) |codepoint| {
try list.append(alloc, codepoint);
}
@@ -6033,6 +6047,56 @@ pub const SelectionWordChars = struct {
try testing.expectEqual(@as(u21, ';'), chars.codepoints[3]);
try testing.expectEqual(@as(u21, ','), chars.codepoints[4]);
}
test "parseCLI escape sequences" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
// \t escape should be parsed as tab
var chars: Self = .{};
try chars.parseCLI(alloc, " \\t;,");
try testing.expectEqual(@as(usize, 5), chars.codepoints.len);
try testing.expectEqual(@as(u21, 0), chars.codepoints[0]);
try testing.expectEqual(@as(u21, ' '), chars.codepoints[1]);
try testing.expectEqual(@as(u21, '\t'), chars.codepoints[2]);
try testing.expectEqual(@as(u21, ';'), chars.codepoints[3]);
try testing.expectEqual(@as(u21, ','), chars.codepoints[4]);
}
test "parseCLI backslash escape" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
// \\ should be parsed as a single backslash
var chars: Self = .{};
try chars.parseCLI(alloc, "\\\\;");
try testing.expectEqual(@as(usize, 3), chars.codepoints.len);
try testing.expectEqual(@as(u21, 0), chars.codepoints[0]);
try testing.expectEqual(@as(u21, '\\'), chars.codepoints[1]);
try testing.expectEqual(@as(u21, ';'), chars.codepoints[2]);
}
test "parseCLI unicode escape" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
// \u{2502} should be parsed as │
var chars: Self = .{};
try chars.parseCLI(alloc, "\\u{2502};");
try testing.expectEqual(@as(usize, 3), chars.codepoints.len);
try testing.expectEqual(@as(u21, 0), chars.codepoints[0]);
try testing.expectEqual(@as(u21, '│'), chars.codepoints[1]);
try testing.expectEqual(@as(u21, ';'), chars.codepoints[2]);
}
};
/// FontVariation is a repeatable configuration value that sets a single
@@ -6169,6 +6233,15 @@ pub const Keybinds = struct {
/// which allows all table names to be available without reservation.
tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty,
/// The most recent binding target for `chain=` additions.
///
/// This is intentionally tracked at the Keybinds level so that chains can
/// apply across table boundaries according to parse order.
chain_target: union(enum) {
root,
table: []const u8,
} = .root,
pub fn init(self: *Keybinds, alloc: Allocator) !void {
// We don't clear the memory because it's in the arena and unlikely
// to be free-able anyways (since arenas can only clear the last
@@ -6176,6 +6249,7 @@ pub const Keybinds = struct {
// will be freed when the config is freed.
self.set = .{};
self.tables = .empty;
self.chain_target = .root;
// keybinds for opening and reloading config
try self.set.put(
@@ -6958,6 +7032,7 @@ pub const Keybinds = struct {
log.info("config has 'keybind = clear', all keybinds cleared", .{});
self.set = .{};
self.tables = .empty;
self.chain_target = .root;
return;
}
@@ -6995,16 +7070,39 @@ pub const Keybinds = struct {
if (binding.len == 0) {
log.debug("config has 'keybind = {s}/', table cleared", .{table_name});
gop.value_ptr.* = .{};
self.chain_target = .root;
return;
}
// Chains are only allowed at the root level. Their target is
// tracked globally by parse order in `self.chain_target`.
if (std.mem.startsWith(u8, binding, "chain=")) {
return error.InvalidFormat;
}
// Parse and add the binding to the table
try gop.value_ptr.parseAndPut(alloc, binding);
self.chain_target = .{ .table = gop.key_ptr.* };
return;
}
if (std.mem.startsWith(u8, value, "chain=")) {
switch (self.chain_target) {
.root => try self.set.parseAndPut(alloc, value),
.table => |table_name| {
const table = self.tables.getPtr(table_name) orelse {
self.chain_target = .root;
return error.InvalidFormat;
};
try table.parseAndPut(alloc, value);
},
}
return;
}
// Parse into default set
try self.set.parseAndPut(alloc, value);
self.chain_target = .root;
}
/// Deep copy of the struct. Required by Config.
@@ -7446,6 +7544,63 @@ pub const Keybinds = struct {
try testing.expect(keybinds.tables.contains("mytable"));
}
test "parseCLI chain without prior parsed binding is invalid" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try testing.expectError(
error.InvalidFormat,
keybinds.parseCLI(alloc, "chain=new_tab"),
);
}
test "parseCLI table chain syntax is invalid" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "foo/a=text:hello");
try testing.expectError(
error.InvalidFormat,
keybinds.parseCLI(alloc, "foo/chain=deactivate_key_table"),
);
}
test "parseCLI chain applies to most recent table binding" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "ctrl+n=activate_key_table:foo");
try keybinds.parseCLI(alloc, "foo/a=text:hello");
try keybinds.parseCLI(alloc, "chain=deactivate_key_table");
const root_entry = keybinds.set.get(.{
.mods = .{ .ctrl = true },
.key = .{ .unicode = 'n' },
}).?.value_ptr.*;
try testing.expect(root_entry == .leaf);
try testing.expect(root_entry.leaf.action == .activate_key_table);
const foo_entry = keybinds.tables.get("foo").?.get(.{
.key = .{ .unicode = 'a' },
}).?.value_ptr.*;
try testing.expect(foo_entry == .leaf_chained);
try testing.expectEqual(@as(usize, 2), foo_entry.leaf_chained.actions.items.len);
try testing.expect(foo_entry.leaf_chained.actions.items[0] == .text);
try testing.expect(foo_entry.leaf_chained.actions.items[1] == .deactivate_key_table);
}
test "clone with tables" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);

View File

@@ -36,6 +36,40 @@ pub fn parse(out: []u8, bytes: []const u8) ![]u8 {
return out[0..dst_i];
}
/// Creates an iterator that requires no allocation to extract codepoints
/// from the string literal, parsing escape sequences as it goes.
pub fn codepointIterator(bytes: []const u8) CodepointIterator {
return .{ .bytes = bytes, .i = 0 };
}
pub const CodepointIterator = struct {
bytes: []const u8,
i: usize,
pub fn next(self: *CodepointIterator) error{InvalidString}!?u21 {
if (self.i >= self.bytes.len) return null;
switch (self.bytes[self.i]) {
// An escape sequence
'\\' => return switch (std.zig.string_literal.parseEscapeSequence(
self.bytes,
&self.i,
)) {
.failure => error.InvalidString,
.success => |cp| cp,
},
// Not an escape, parse as UTF-8
else => |start| {
const cp_len = std.unicode.utf8ByteSequenceLength(start) catch
return error.InvalidString;
defer self.i += cp_len;
return std.unicode.utf8Decode(self.bytes[self.i..][0..cp_len]) catch
return error.InvalidString;
},
}
}
};
test "parse: empty" {
const testing = std.testing;
@@ -65,3 +99,48 @@ test "parse: escapes" {
try testing.expectEqualStrings("hello\u{1F601}world", result);
}
}
test "codepointIterator: empty" {
var it = codepointIterator("");
try std.testing.expectEqual(null, try it.next());
}
test "codepointIterator: ascii no escapes" {
var it = codepointIterator("abc");
try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, 'c'), (try it.next()).?);
try std.testing.expectEqual(null, try it.next());
}
test "codepointIterator: multibyte utf8" {
// │ is U+2502 (3 bytes in UTF-8)
var it = codepointIterator("a│b");
try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, '│'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?);
try std.testing.expectEqual(null, try it.next());
}
test "codepointIterator: escape sequences" {
var it = codepointIterator("a\\tb\\n\\\\");
try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, '\t'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, '\n'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, '\\'), (try it.next()).?);
try std.testing.expectEqual(null, try it.next());
}
test "codepointIterator: unicode escape" {
var it = codepointIterator("\\u{2502}x");
try std.testing.expectEqual(@as(u21, '│'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, 'x'), (try it.next()).?);
try std.testing.expectEqual(null, try it.next());
}
test "codepointIterator: emoji unicode escape" {
var it = codepointIterator("\\u{1F601}");
try std.testing.expectEqual(@as(u21, 0x1F601), (try it.next()).?);
try std.testing.expectEqual(null, try it.next());
}

View File

@@ -1895,7 +1895,7 @@ test "shape Bengali ligatures with out of order vowels" {
try testing.expectEqual(@as(u16, 0), cells[0].x);
try testing.expectEqual(@as(u16, 0), cells[1].x);
// See the giant "We need to reset the `cell_offset`" comment, but here
// we should technically have the rest of these be `x` of 1, but that
// we should technically have the rest of these be `x` of 2, but that
// would require going back in the stream to adjust past cells, and
// we don't take on that complexity.
try testing.expectEqual(@as(u16, 0), cells[2].x);

View File

@@ -1447,12 +1447,12 @@ test "shape Bengali ligatures with out of order vowels" {
// Whereas CoreText puts everything all into the first cell (see the
// corresponding test), HarfBuzz splits into two clusters.
try testing.expectEqual(@as(u16, 1), cells[2].x);
try testing.expectEqual(@as(u16, 1), cells[3].x);
try testing.expectEqual(@as(u16, 1), cells[4].x);
try testing.expectEqual(@as(u16, 1), cells[5].x);
try testing.expectEqual(@as(u16, 1), cells[6].x);
try testing.expectEqual(@as(u16, 1), cells[7].x);
try testing.expectEqual(@as(u16, 2), cells[2].x);
try testing.expectEqual(@as(u16, 2), cells[3].x);
try testing.expectEqual(@as(u16, 2), cells[4].x);
try testing.expectEqual(@as(u16, 2), cells[5].x);
try testing.expectEqual(@as(u16, 2), cells[6].x);
try testing.expectEqual(@as(u16, 2), cells[7].x);
// The vowel sign E renders before the SSA:
try testing.expect(cells[2].x_offset < cells[3].x_offset);

View File

@@ -570,12 +570,9 @@ pub const Action = union(enum) {
toggle_tab_overview,
/// Change the title of the current focused surface via a pop-up prompt.
///
/// This requires libadwaita 1.5 or newer on Linux. The current libadwaita
/// version can be found by running `ghostty +version`.
prompt_surface_title,
/// Change the title of the current tab/window via a pop-up prompt. The
/// Change the title of the current tab via a pop-up prompt. The
/// title set via this prompt overrides any title set by the terminal
/// and persists across focus changes within the tab.
prompt_tab_title,

View File

@@ -440,13 +440,13 @@ fn actionCommands(action: Action.Key) []const Command {
.prompt_surface_title => comptime &.{.{
.action = .prompt_surface_title,
.title = "Change Terminal Title...",
.title = "Change Terminal Title",
.description = "Prompt for a new title for the current terminal.",
}},
.prompt_tab_title => comptime &.{.{
.action = .prompt_tab_title,
.title = "Change Tab Title...",
.title = "Change Tab Title",
.description = "Prompt for a new title for the current tab.",
}},

View File

@@ -15,7 +15,7 @@ pub const Style = enum {
lock,
/// Create a cursor style from the terminal style request.
pub fn fromTerminal(term: terminal.CursorStyle) ?Style {
pub fn fromTerminal(term: terminal.CursorStyle) Style {
return switch (term) {
.bar => .bar,
.block => .block,

View File

@@ -756,6 +756,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.previous_cursor = @splat(0),
.current_cursor_color = @splat(0),
.previous_cursor_color = @splat(0),
.current_cursor_style = 0,
.previous_cursor_style = 0,
.cursor_visible = 0,
.cursor_change_time = 0,
.time_focus = 0,
.focus = 1, // assume focused initially
@@ -1226,6 +1229,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// kitty state on every frame because any cell change can move
// an image.
if (self.images.kittyRequiresUpdate(state.terminal)) {
// We need to grab the draw mutex since this updates
// our image state that drawFrame uses.
self.draw_mutex.lock();
defer self.draw_mutex.unlock();
self.images.kittyUpdate(
self.alloc,
state.terminal,
@@ -2007,11 +2014,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Only update when terminal state is dirty.
if (self.terminal_state.dirty == .false) return;
const uniforms: *shadertoy.Uniforms = &self.custom_shader_uniforms;
const colors: *const terminal.RenderState.Colors = &self.terminal_state.colors;
// 256-color palette
for (colors.palette, 0..) |color, i| {
self.custom_shader_uniforms.palette[i] = .{
uniforms.palette[i] = .{
@as(f32, @floatFromInt(color.r)) / 255.0,
@as(f32, @floatFromInt(color.g)) / 255.0,
@as(f32, @floatFromInt(color.b)) / 255.0,
@@ -2020,7 +2028,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}
// Background color
self.custom_shader_uniforms.background_color = .{
uniforms.background_color = .{
@as(f32, @floatFromInt(colors.background.r)) / 255.0,
@as(f32, @floatFromInt(colors.background.g)) / 255.0,
@as(f32, @floatFromInt(colors.background.b)) / 255.0,
@@ -2028,7 +2036,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
};
// Foreground color
self.custom_shader_uniforms.foreground_color = .{
uniforms.foreground_color = .{
@as(f32, @floatFromInt(colors.foreground.r)) / 255.0,
@as(f32, @floatFromInt(colors.foreground.g)) / 255.0,
@as(f32, @floatFromInt(colors.foreground.b)) / 255.0,
@@ -2037,7 +2045,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Cursor color
if (colors.cursor) |cursor_color| {
self.custom_shader_uniforms.cursor_color = .{
uniforms.cursor_color = .{
@as(f32, @floatFromInt(cursor_color.r)) / 255.0,
@as(f32, @floatFromInt(cursor_color.g)) / 255.0,
@as(f32, @floatFromInt(cursor_color.b)) / 255.0,
@@ -2051,7 +2059,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Cursor text color
if (self.config.cursor_text) |cursor_text| {
self.custom_shader_uniforms.cursor_text = .{
uniforms.cursor_text = .{
@as(f32, @floatFromInt(cursor_text.color.r)) / 255.0,
@as(f32, @floatFromInt(cursor_text.color.g)) / 255.0,
@as(f32, @floatFromInt(cursor_text.color.b)) / 255.0,
@@ -2061,7 +2069,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Selection background color
if (self.config.selection_background) |selection_bg| {
self.custom_shader_uniforms.selection_background_color = .{
uniforms.selection_background_color = .{
@as(f32, @floatFromInt(selection_bg.color.r)) / 255.0,
@as(f32, @floatFromInt(selection_bg.color.g)) / 255.0,
@as(f32, @floatFromInt(selection_bg.color.b)) / 255.0,
@@ -2071,13 +2079,21 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Selection foreground color
if (self.config.selection_foreground) |selection_fg| {
self.custom_shader_uniforms.selection_foreground_color = .{
uniforms.selection_foreground_color = .{
@as(f32, @floatFromInt(selection_fg.color.r)) / 255.0,
@as(f32, @floatFromInt(selection_fg.color.g)) / 255.0,
@as(f32, @floatFromInt(selection_fg.color.b)) / 255.0,
1.0,
};
}
// Cursor visibility
uniforms.cursor_visible = @intFromBool(self.terminal_state.cursor.visible);
// Cursor style
const cursor_style: renderer.CursorStyle = .fromTerminal(self.terminal_state.cursor.visual_style);
uniforms.previous_cursor_style = uniforms.current_cursor_style;
uniforms.current_cursor_style = @as(i32, @intFromEnum(cursor_style));
}
/// Update per-frame custom shader uniforms.
@@ -2087,7 +2103,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// We only need to do this if we have custom shaders.
if (!self.has_custom_shaders) return;
const uniforms = &self.custom_shader_uniforms;
const uniforms: *shadertoy.Uniforms = &self.custom_shader_uniforms;
const now = try std.time.Instant.now();
defer self.last_frame_time = now;
@@ -2121,7 +2137,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
0,
};
// Update custom cursor uniforms, if we have a cursor.
if (self.cells.getCursorGlyph()) |cursor| {
const cursor_width: f32 = @floatFromInt(cursor.glyph_size[0]);
const cursor_height: f32 = @floatFromInt(cursor.glyph_size[1]);

View File

@@ -844,7 +844,7 @@ pub const Image = union(enum) {
/// Converts the image data to a format that can be uploaded to the GPU.
/// If the data is already in a format that can be uploaded, this is a
/// no-op.
pub fn convert(self: *Image, alloc: Allocator) wuffs.Error!void {
fn convert(self: *Image, alloc: Allocator) wuffs.Error!void {
const p = self.getPendingPointer().?;
// As things stand, we currently convert all images to RGBA before
// uploading to the GPU. This just makes things easier. In the future
@@ -867,7 +867,7 @@ pub const Image = union(enum) {
/// Prepare the pending image data for upload to the GPU.
/// This doesn't need GPU access so is safe to call any time.
pub fn prepForUpload(self: *Image, alloc: Allocator) wuffs.Error!void {
fn prepForUpload(self: *Image, alloc: Allocator) wuffs.Error!void {
assert(self.isPending());
try self.convert(alloc);
}

View File

@@ -15,6 +15,9 @@ layout(binding = 1, std140) uniform Globals {
uniform vec4 iPreviousCursor;
uniform vec4 iCurrentCursorColor;
uniform vec4 iPreviousCursorColor;
uniform int iCurrentCursorStyle;
uniform int iPreviousCursorStyle;
uniform int iCursorVisible;
uniform float iTimeCursorChange;
uniform float iTimeFocus;
uniform int iFocus;
@@ -27,6 +30,12 @@ layout(binding = 1, std140) uniform Globals {
uniform vec3 iSelectionBackgroundColor;
};
#define CURSORSTYLE_BLOCK 0
#define CURSORSTYLE_BLOCK_HOLLOW 1
#define CURSORSTYLE_BAR 2
#define CURSORSTYLE_UNDERLINE 3
#define CURSORSTYLE_LOCK 4
layout(binding = 0) uniform sampler2D iChannel0;
// These are unused currently by Ghostty:

View File

@@ -24,6 +24,9 @@ pub const Uniforms = extern struct {
previous_cursor: [4]f32 align(16),
current_cursor_color: [4]f32 align(16),
previous_cursor_color: [4]f32 align(16),
current_cursor_style: i32 align(4),
previous_cursor_style: i32 align(4),
cursor_visible: i32 align(4),
cursor_change_time: f32 align(4),
time_focus: f32 align(4),
focus: i32 align(4),

View File

@@ -329,12 +329,16 @@ pub fn print(self: *Terminal, c: u21) !void {
@branchHint(.unlikely);
// We need the previous cell to determine if we're at a grapheme
// break or not. If we are NOT, then we are still combining the
// same grapheme. Otherwise, we can stay in this cell.
// same grapheme, and will be appending to prev.cell. Otherwise, we are
// in a new cell.
const Prev = struct { cell: *Cell, left: size.CellCountInt };
const prev: Prev = prev: {
var prev: Prev = prev: {
const left: size.CellCountInt = left: {
// If we have wraparound, then we always use the prev col
if (self.modes.get(.wraparound)) break :left 1;
// If we have wraparound, then we use the prev col unless
// there's a pending wrap, in which case we use the current.
if (self.modes.get(.wraparound)) {
break :left @intFromBool(!self.screens.active.cursor.pending_wrap);
}
// If we do not have wraparound, the logic is trickier. If
// we're not on the last column, then we just use the previous
@@ -380,6 +384,8 @@ pub fn print(self: *Terminal, c: u21) !void {
// If we can NOT break, this means that "c" is part of a grapheme
// with the previous char.
if (!grapheme_break) {
var desired_wide: enum { no_change, wide, narrow } = .no_change;
// If this is an emoji variation selector then we need to modify
// the cell width accordingly. VS16 makes the character wide and
// VS15 makes it narrow.
@@ -390,71 +396,132 @@ pub fn print(self: *Terminal, c: u21) !void {
if (!prev_props.emoji_vs_base) return;
switch (c) {
0xFE0F => wide: {
if (prev.cell.wide == .wide) break :wide;
0xFE0F => desired_wide = .wide,
0xFE0E => desired_wide = .narrow,
else => unreachable,
}
} else if (!unicode.table.get(c).width_zero_in_grapheme) {
// If we have a code point that contributes to the width of a
// grapheme, it necessarily means that we're at least at width
// 2, since the first code point must be at least width 1 to
// start. (Note that Prepend code points could effectively mean
// the first code point should be width 0, but we don't handle
// that yet.)
desired_wide = .wide;
}
// Move our cursor back to the previous. We'll move
// the cursor within this block to the proper location.
self.screens.active.cursorLeft(prev.left);
switch (desired_wide) {
.wide => wide: {
if (prev.cell.wide == .wide) break :wide;
// If we don't have space for the wide char, we need
// to insert spacers and wrap. Then we just print the wide
// char as normal.
if (self.screens.active.cursor.x == right_limit - 1) {
if (!self.modes.get(.wraparound)) return;
// Move our cursor back to the previous. We'll move
// the cursor within this block to the proper location.
self.screens.active.cursorLeft(prev.left);
// If we don't have space for the wide char, we need to
// insert spacers and wrap. We need special handling if the
// previous cell has grapheme data.
if (self.screens.active.cursor.x == right_limit - 1) {
if (!self.modes.get(.wraparound)) return;
const prev_cp = prev.cell.content.codepoint;
if (prev.cell.hasGrapheme()) {
// This is like printCell but without clearing the
// grapheme data from the cell, so we can move it
// later.
prev.cell.wide = if (right_limit == self.cols) .spacer_head else .narrow;
prev.cell.content.codepoint = 0;
try self.printWrap();
self.printCell(prev_cp, .wide);
const new_pin = self.screens.active.cursor.page_pin.*;
const new_rac = new_pin.rowAndCell();
transfer_graphemes: {
var old_pin = self.screens.active.cursor.page_pin.up(1) orelse break :transfer_graphemes;
old_pin.x = right_limit - 1;
const old_rac = old_pin.rowAndCell();
if (new_pin.node == old_pin.node) {
new_pin.node.data.moveGrapheme(prev.cell, new_rac.cell);
prev.cell.content_tag = .codepoint;
new_rac.cell.content_tag = .codepoint_grapheme;
new_rac.row.grapheme = true;
} else {
const cps = old_pin.node.data.lookupGrapheme(old_rac.cell).?;
for (cps) |cp| {
try self.screens.active.appendGrapheme(new_rac.cell, cp);
}
old_pin.node.data.clearGrapheme(old_rac.cell);
}
old_pin.node.data.updateRowGraphemeFlag(old_rac.row);
}
// Point prev.cell to our new previous cell that
// we'll be appending graphemes to
prev.cell = new_rac.cell;
} else {
self.printCell(
0,
if (right_limit == self.cols) .spacer_head else .narrow,
);
try self.printWrap();
self.printCell(prev_cp, .wide);
// Point prev.cell to our new previous cell that
// we'll be appending graphemes to
prev.cell = self.screens.active.cursor.page_cell;
}
} else {
prev.cell.wide = .wide;
}
self.printCell(prev.cell.content.codepoint, .wide);
// Write our spacer, since prev.cell is now wide
self.screens.active.cursorRight(1);
self.printCell(0, .spacer_tail);
// Write our spacer
// Move the cursor again so we're beyond our spacer
if (self.screens.active.cursor.x == right_limit - 1) {
self.screens.active.cursor.pending_wrap = true;
} else {
self.screens.active.cursorRight(1);
self.printCell(0, .spacer_tail);
}
},
// Move the cursor again so we're beyond our spacer
if (self.screens.active.cursor.x == right_limit - 1) {
self.screens.active.cursor.pending_wrap = true;
} else {
self.screens.active.cursorRight(1);
}
},
.narrow => narrow: {
// Prev cell is no longer wide
if (prev.cell.wide != .wide) break :narrow;
prev.cell.wide = .narrow;
0xFE0E => narrow: {
// Prev cell is no longer wide
if (prev.cell.wide != .wide) break :narrow;
prev.cell.wide = .narrow;
// Remove the wide spacer tail
const cell = self.screens.active.cursorCellLeft(prev.left - 1);
cell.wide = .narrow;
// Remove the wide spacer tail
const cell = self.screens.active.cursorCellLeft(prev.left - 1);
cell.wide = .narrow;
// Back track the cursor so that we don't end up with
// an extra space after the character. Since xterm is
// not VS aware, it cannot be used as a reference for
// this behavior; but it does follow the principle of
// least surprise, and also matches the behavior that
// can be observed in Kitty, which is one of the only
// other VS aware terminals.
if (self.screens.active.cursor.x == right_limit - 1) {
// If we're already at the right edge, we stay
// here and set the pending wrap to false since
// when we pend a wrap, we only move our cursor once
// even for wide chars (tests verify).
self.screens.active.cursor.pending_wrap = false;
} else {
// Otherwise, move back.
self.screens.active.cursorLeft(1);
}
// Back track the cursor so that we don't end up with
// an extra space after the character. Since xterm is
// not VS aware, it cannot be used as a reference for
// this behavior; but it does follow the principle of
// least surprise, and also matches the behavior that
// can be observed in Kitty, which is one of the only
// other VS aware terminals.
if (self.screens.active.cursor.x == right_limit - 1) {
// If we're already at the right edge, we stay
// here and set the pending wrap to false since
// when we pend a wrap, we only move our cursor once
// even for wide chars (tests verify).
self.screens.active.cursor.pending_wrap = false;
} else {
// Otherwise, move back.
self.screens.active.cursorLeft(1);
}
break :narrow;
},
break :narrow;
},
else => unreachable,
}
else => {},
}
log.debug("c={X} grapheme attach to left={} primary_cp={X}", .{
@@ -3834,19 +3901,23 @@ test "Terminal: print invalid VS15 in emoji ZWJ sequence" {
}
test "Terminal: VS15 to make narrow character with pending wrap" {
var t = try init(testing.allocator, .{ .rows = 5, .cols = 2 });
var t = try init(testing.allocator, .{ .rows = 5, .cols = 4 });
defer t.deinit(testing.allocator);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
try testing.expect(t.modes.get(.wraparound));
try t.print(0x1F34B); // Lemon, width=2
try t.print(0x2614); // Umbrella with rain drops, width=2
try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } }));
t.clearDirty();
// We only move one because we're in a pending wrap state.
// We only move to the end of the line because we're in a pending wrap
// state.
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x);
try testing.expect(t.screens.active.cursor.pending_wrap);
try t.print(0xFE0E); // VS15 to make narrow
@@ -3855,17 +3926,17 @@ test "Terminal: VS15 to make narrow character with pending wrap" {
// VS15 should clear the pending wrap state
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x);
try testing.expect(!t.screens.active.cursor.pending_wrap);
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("☔︎", str);
try testing.expectEqualStrings("🍋☔︎", str);
}
{
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?;
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
@@ -3873,6 +3944,102 @@ test "Terminal: VS15 to make narrow character with pending wrap" {
const cps = list_cell.node.data.lookupGrapheme(cell).?;
try testing.expectEqual(@as(usize, 1), cps.len);
}
// VS15 should not affect the previous grapheme
{
const lemon_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?.cell;
try testing.expectEqual(@as(u21, 0x1F34B), lemon_cell.content.codepoint);
try testing.expectEqual(Cell.Wide.wide, lemon_cell.wide);
const spacer_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?.cell;
try testing.expectEqual(@as(u21, 0), spacer_cell.content.codepoint);
try testing.expectEqual(Cell.Wide.spacer_tail, spacer_cell.wide);
}
}
test "Terminal: VS16 to make wide character on next line" {
var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 });
defer t.deinit(testing.allocator);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
t.cursorRight(2);
try t.print('#');
try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
try testing.expect(t.screens.active.cursor.pending_wrap);
try testing.expect(t.isDirty(.{ .screen = .{ .x = 2, .y = 0 } }));
t.clearDirty();
try t.print(0xFE0F); // VS16 to make wide
try testing.expect(t.isDirty(.{ .screen = .{ .x = 2, .y = 0 } }));
t.clearDirty();
try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y);
try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
try testing.expect(!t.screens.active.cursor.pending_wrap);
{
// Previous cell turns into spacer_head
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0), cell.content.codepoint);
try testing.expect(!cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.spacer_head, cell.wide);
}
{
// '#' cell is wide
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, '#'), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
// spacer_tail
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0), cell.content.codepoint);
try testing.expect(!cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
}
test "Terminal: VS16 to make wide character with pending wrap" {
var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 });
defer t.deinit(testing.allocator);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
t.cursorRight(1);
try t.print('#');
try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
try testing.expect(!t.screens.active.cursor.pending_wrap);
try t.print(0xFE0F); // VS16 to make wide
try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
try testing.expect(t.screens.active.cursor.pending_wrap);
{
// '#' cell is wide
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, '#'), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
// spacer_tail
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0), cell.content.codepoint);
try testing.expect(!cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
}
test "Terminal: VS16 to make wide character with mode 2027" {
@@ -4013,6 +4180,173 @@ test "Terminal: print invalid VS16 with second char" {
}
}
test "Terminal: print grapheme ò (o with nonspacing mark) should be narrow" {
var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 });
defer t.deinit(testing.allocator);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
try t.print('o');
try t.print(0x0300); // combining grave accent
// We should have 1 cell taken up.
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x);
// Assert various properties about our screen to verify
// we have all expected cells.
{
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 'o'), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.data.lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
}
}
test "Terminal: print Devanagari grapheme should be wide" {
var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 });
defer t.deinit(testing.allocator);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
// क्‍ष
try t.print(0x0915);
try t.print(0x094D);
try t.print(0x200D);
try t.print(0x0937);
// We should have 2 cells taken up. It is one character but "wide".
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
// Assert various properties about our screen to verify
// we have all expected cells.
{
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
}
test "Terminal: print Devanagari grapheme should be wide on next line" {
var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 });
defer t.deinit(testing.allocator);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
t.cursorRight(2);
// क्‍ष
try t.print(0x0915);
try t.print(0x094D);
try t.print(0x200D);
try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
try testing.expect(t.screens.active.cursor.pending_wrap);
// This one increases the width to wide
try t.print(0x0937);
// We should have 2 cells taken up. It is one character but "wide".
try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y);
try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
try testing.expect(!t.screens.active.cursor.pending_wrap);
{
// Previous cell turns into spacer_head
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0), cell.content.codepoint);
try testing.expect(!cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.spacer_head, cell.wide);
}
{
// Devanagari grapheme is wide
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
}
test "Terminal: print Devanagari grapheme should be wide on next page" {
const rows = pagepkg.std_capacity.rows;
const cols = pagepkg.std_capacity.cols;
var t = try init(testing.allocator, .{ .rows = rows, .cols = cols });
defer t.deinit(testing.allocator);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
t.cursorDown(rows - 1);
for (rows..t.screens.active.pages.pages.first.?.data.capacity.rows) |_| {
try t.index();
}
t.cursorRight(cols - 1);
try testing.expectEqual(cols - 1, t.screens.active.cursor.x);
try testing.expectEqual(rows - 1, t.screens.active.cursor.y);
// क्‍ष
try t.print(0x0915);
try t.print(0x094D);
try t.print(0x200D);
try testing.expectEqual(cols - 1, t.screens.active.cursor.x);
try testing.expect(t.screens.active.cursor.pending_wrap);
// This one increases the width to wide
try t.print(0x0937);
// We should have 2 cells taken up. It is one character but "wide".
try testing.expectEqual(rows - 1, t.screens.active.cursor.y);
try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
try testing.expect(!t.screens.active.cursor.pending_wrap);
{
// Previous cell turns into spacer_head
const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = cols - 1, .y = rows - 2 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0), cell.content.codepoint);
try testing.expect(!cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.spacer_head, cell.wide);
}
{
// Devanagari grapheme is wide
const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = rows - 1 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 1, .y = rows - 1 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
}
test "Terminal: print invalid VS16 with second char (combining)" {
var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 });
defer t.deinit(testing.allocator);

View File

@@ -1556,7 +1556,7 @@ pub const Page = struct {
/// WARNING: This will NOT change the content_tag on the cells because
/// there are scenarios where we want to move graphemes without changing
/// the content tag. Callers beware but assertIntegrity should catch this.
inline fn moveGrapheme(self: *Page, src: *Cell, dst: *Cell) void {
pub inline fn moveGrapheme(self: *Page, src: *Cell, dst: *Cell) void {
if (build_options.slow_runtime_safety) {
assert(src.hasGrapheme());
assert(!dst.hasGrapheme());

View File

@@ -13,6 +13,10 @@ pub const Properties = packed struct {
/// becomes a 2-em dash).
width: u2 = 0,
/// Whether the code point does not contribute to the width of a grapheme
/// cluster (not used for single code point cells).
width_zero_in_grapheme: bool = false,
/// Grapheme break property.
grapheme_break: uucode.x.types.GraphemeBreakNoControl = .other,
@@ -22,6 +26,7 @@ pub const Properties = packed struct {
// Needed for lut.Generator
pub fn eql(a: Properties, b: Properties) bool {
return a.width == b.width and
a.width_zero_in_grapheme == b.width_zero_in_grapheme and
a.grapheme_break == b.grapheme_break and
a.emoji_vs_base == b.emoji_vs_base;
}
@@ -34,11 +39,13 @@ pub const Properties = packed struct {
try writer.print(
\\.{{
\\ .width= {},
\\ .width_zero_in_grapheme= {},
\\ .grapheme_break= .{s},
\\ .emoji_vs_base= {},
\\}}
, .{
self.width,
self.width_zero_in_grapheme,
@tagName(self.grapheme_break),
self.emoji_vs_base,
});

View File

@@ -8,12 +8,14 @@ const Properties = @import("props.zig").Properties;
pub fn get(cp: u21) Properties {
if (cp > uucode.config.max_code_point) return .{
.width = 1,
.width_zero_in_grapheme = true,
.grapheme_break = .other,
.emoji_vs_base = false,
};
return .{
.width = uucode.get(.width, cp),
.width_zero_in_grapheme = uucode.get(.wcwidth_zero_in_grapheme, cp),
.grapheme_break = uucode.get(.grapheme_break_no_control, cp),
.emoji_vs_base = uucode.get(.is_emoji_vs_base, cp),
};

View File

@@ -42,6 +42,8 @@ extend-ignore-re = [
"draw[0-9A-F]+(_[0-9A-F]+)?\\(",
# Ignore test data in src/input/paste.zig
"\"hel\\\\x",
# Ignore long hex-like IDs such as 815E26BA2EF1E00F005C67B1
"[0-9A-F]{12,}",
]
[default.extend-words]